From 72835effa7ffe587565a064866dda2296222a4e9 Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Mon, 28 Jul 2025 16:29:22 -0700 Subject: [PATCH 01/21] Consolidates data models and method (de-Clauding) --- .../.cspell/custom-dictionary-workspace.txt | 1 + .../assistant/agentic/coordinator_support.py | 11 +- .../assistant/assistant.py | 11 +- .../assistant/common.py | 8 +- .../assistant/conversation_clients.py | 5 +- .../assistant/conversation_share_link.py | 169 ------ .../assistant/data.py | 562 +++--------------- .../assistant/domain/__init__.py | 2 - .../domain/knowledge_package_manager.py | 128 ++++ .../assistant/domain/share_manager.py | 168 ++++-- .../assistant/files.py | 8 +- .../assistant/respond.py | 31 +- .../assistant/storage.py | 45 +- .../assistant/storage_models.py | 43 -- .../coordinator_instructions.txt | 42 +- .../text_includes/coordinator_role.txt | 16 +- .../assistant/tools/__init__.py | 2 +- .../assistant/tools/base.py | 2 +- .../assistant/tools/information_requests.py | 2 +- .../assistant/tools/learning_objectives.py | 2 +- .../assistant/tools/learning_outcomes.py | 2 +- .../assistant/tools/progress_tracking.py | 14 +- .../assistant/tools/share_setup.py | 2 +- .../assistant/ui_tabs/brief.py | 13 +- .../assistant/ui_tabs/common.py | 49 +- .../assistant/ui_tabs/debug.py | 9 +- .../assistant/ui_tabs/learning.py | 25 +- .../assistant/ui_tabs/sharing.py | 21 +- .../docs/design/design.md | 131 ++++ ...nowledge-transfer-assistant.code-workspace | 10 +- .../tests/test_artifact_loading.py | 8 +- .../tests/test_share_storage.py | 32 +- .../tests/test_share_tools.py | 2 +- docs/.cspell/custom-dictionary-workspace.txt | 1 + 34 files changed, 679 insertions(+), 898 deletions(-) create mode 100644 assistants/knowledge-transfer-assistant/.cspell/custom-dictionary-workspace.txt delete mode 100644 assistants/knowledge-transfer-assistant/assistant/conversation_share_link.py create mode 100644 assistants/knowledge-transfer-assistant/assistant/domain/knowledge_package_manager.py delete mode 100644 assistants/knowledge-transfer-assistant/assistant/storage_models.py create mode 100644 assistants/knowledge-transfer-assistant/docs/design/design.md create mode 100644 docs/.cspell/custom-dictionary-workspace.txt diff --git a/assistants/knowledge-transfer-assistant/.cspell/custom-dictionary-workspace.txt b/assistants/knowledge-transfer-assistant/.cspell/custom-dictionary-workspace.txt new file mode 100644 index 000000000..255951c23 --- /dev/null +++ b/assistants/knowledge-transfer-assistant/.cspell/custom-dictionary-workspace.txt @@ -0,0 +1 @@ +agentic diff --git a/assistants/knowledge-transfer-assistant/assistant/agentic/coordinator_support.py b/assistants/knowledge-transfer-assistant/assistant/agentic/coordinator_support.py index 21577742c..7c5ccab81 100644 --- a/assistants/knowledge-transfer-assistant/assistant/agentic/coordinator_support.py +++ b/assistants/knowledge-transfer-assistant/assistant/agentic/coordinator_support.py @@ -9,6 +9,7 @@ from semantic_workbench_assistant.assistant_app import ConversationContext from ..data import RequestStatus +from ..domain.knowledge_package_manager import KnowledgePackageManager from ..logging import logger from ..storage import ShareStorage from ..domain.share_manager import ShareManager @@ -74,12 +75,16 @@ async def get_coordinator_next_action_suggestion(context: ConversationContext) - return f"The learning objective '{name}' doesn't have any outcomes yet. Let's define what your team should accomplish to meet it." # 7. Ready for transfer but not yet shared - if package.is_ready_for_transfer() and not package.is_actively_sharing(): + if KnowledgePackageManager.is_ready_for_transfer( + package + ) and not KnowledgePackageManager.is_actively_sharing(package): return "Your knowledge package is ready to share. Would you like to create a message and generate the invitation link?" # 8. Actively sharing - monitor and support ongoing transfer - if package.is_actively_sharing(): - if package.is_intended_to_accomplish_outcomes and not package._is_transfer_complete(): + if KnowledgePackageManager.is_actively_sharing(package): + if package.is_intended_to_accomplish_outcomes and not KnowledgePackageManager._is_transfer_complete( + package + ): team_count = len(package.team_conversations) return f"Great! Your knowledge is being shared with {team_count} team member{'s' if team_count != 1 else ''}. You can continue improving the package or respond to information requests as they come in." else: diff --git a/assistants/knowledge-transfer-assistant/assistant/assistant.py b/assistants/knowledge-transfer-assistant/assistant/assistant.py index b51bae482..c22dd2280 100644 --- a/assistants/knowledge-transfer-assistant/assistant/assistant.py +++ b/assistants/knowledge-transfer-assistant/assistant/assistant.py @@ -29,7 +29,7 @@ from .agentic.team_welcome import generate_team_welcome_message 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 .domain.share_manager import ShareManager from .data import InspectorTab, LogEntryType from .files import ShareFilesManager from .logging import logger @@ -38,7 +38,7 @@ from .respond import respond_to_conversation from .ui_tabs import BriefInspector, LearningInspector, SharingInspector, DebugInspector from .storage import ShareStorage -from .storage_models import ConversationRole +from .data import ConversationRole from .utils import ( DEFAULT_TEMPLATE_ID, load_text_include, @@ -119,7 +119,7 @@ async def on_conversation_created(context: ConversationContext) -> None: if not share_id: logger.error("No share ID found for shareable team conversation.") return - await ConversationKnowledgePackageManager.associate_conversation_with_share(context, share_id) + await ShareManager.set_conversation_role(context, share_id, ConversationRole.SHAREABLE_TEMPLATE) return case ConversationType.TEAM: @@ -135,8 +135,7 @@ async def on_conversation_created(context: ConversationContext) -> None: ) ) - await ConversationKnowledgePackageManager.associate_conversation_with_share(context, share_id) - await ConversationKnowledgePackageManager.set_conversation_role(context, share_id, ConversationRole.TEAM) + await ShareManager.set_conversation_role(context, share_id, ConversationRole.TEAM) await ShareFilesManager.synchronize_files_to_team_conversation(context=context, share_id=share_id) welcome_message, debug = await generate_team_welcome_message(context) @@ -527,7 +526,7 @@ async def on_participant_joined( if role != ConversationRole.TEAM: return - share_id = await ConversationKnowledgePackageManager.get_associated_share_id(context) + share_id = await ShareManager.get_associated_share_id(context) if not share_id: return diff --git a/assistants/knowledge-transfer-assistant/assistant/common.py b/assistants/knowledge-transfer-assistant/assistant/common.py index 36a65c5f8..8bd73c57a 100644 --- a/assistants/knowledge-transfer-assistant/assistant/common.py +++ b/assistants/knowledge-transfer-assistant/assistant/common.py @@ -10,11 +10,11 @@ from semantic_workbench_assistant.assistant_app import ConversationContext -from .conversation_share_link import ConversationKnowledgePackageManager +from .domain.share_manager import ShareManager from .data import LogEntryType from .logging import logger from .storage import ShareStorage -from .storage_models import ConversationRole +from .data import ConversationRole from semantic_workbench_api_model.workbench_model import Conversation @@ -91,7 +91,7 @@ async def get_shared_conversation_id(context: ConversationContext) -> Optional[s The shared conversation ID if found, None otherwise """ try: - share_id = await ConversationKnowledgePackageManager.get_associated_share_id(context) + share_id = await ShareManager.get_associated_share_id(context) if not share_id: return None @@ -126,7 +126,7 @@ async def log_transfer_action( related_entity_id: Optional ID of a related entity (e.g., request ID) additional_metadata: Optional additional metadata to include in the log """ - share_id = await ConversationKnowledgePackageManager.get_associated_share_id(context) + share_id = await ShareManager.get_associated_share_id(context) if not share_id: return diff --git a/assistants/knowledge-transfer-assistant/assistant/conversation_clients.py b/assistants/knowledge-transfer-assistant/assistant/conversation_clients.py index 4190b3980..a5e77573e 100644 --- a/assistants/knowledge-transfer-assistant/assistant/conversation_clients.py +++ b/assistants/knowledge-transfer-assistant/assistant/conversation_clients.py @@ -12,10 +12,9 @@ from semantic_workbench_assistant.assistant_app import ConversationContext from semantic_workbench_assistant.storage import read_model -from .conversation_share_link import ConversationKnowledgePackageManager from .logging import logger from .storage import ShareStorageManager -from .storage_models import ConversationRole +from .data import ConversationRole, ConversationShareInfo class ConversationClientManager: @@ -53,7 +52,7 @@ async def get_coordinator_client_for_share( return None, None # Read the role information to get the Coordinator conversation ID - role_data = read_model(role_file, ConversationKnowledgePackageManager.ConversationRoleInfo) + role_data = read_model(role_file, ConversationShareInfo) if not role_data or not role_data.conversation_id: return None, None diff --git a/assistants/knowledge-transfer-assistant/assistant/conversation_share_link.py b/assistants/knowledge-transfer-assistant/assistant/conversation_share_link.py deleted file mode 100644 index f984e9b43..000000000 --- a/assistants/knowledge-transfer-assistant/assistant/conversation_share_link.py +++ /dev/null @@ -1,169 +0,0 @@ -""" -Manages associations between conversations in a knowledge transfer. - -This module handles the linking of conversations to knowledge transfer shares, -defining roles (Coordinator/Team) and maintaining relationships. -""" - -from typing import List, Optional - -from pydantic import BaseModel -from semantic_workbench_assistant.assistant_app import ConversationContext -from semantic_workbench_assistant.storage import read_model, write_model - -from .data import TeamConversationInfo -from .logging import logger -from .storage import ShareStorageManager -from .storage_models import ConversationRole -from .utils import get_current_user - - -class ConversationKnowledgePackageManager: - """Manages the association between conversations and knowledge transfers.""" - - class ConversationRoleInfo(BaseModel): - """Stores a conversation's role in a knowledge transfer share.""" - - share_id: str - role: ConversationRole - conversation_id: str - - class ShareAssociation(BaseModel): - """Stores a conversation's share association.""" - - share_id: str - - @staticmethod - async def get_linked_conversations(context: ConversationContext) -> List[str]: - """ - Gets all conversations linked to this one through the same knowledge transfer share. - """ - try: - share_id = await ConversationKnowledgePackageManager.get_associated_share_id(context) - if not share_id: - return [] - - # Load the knowledge package - from .storage import ShareStorage - - knowledge_package = ShareStorage.read_share(share_id) - if not knowledge_package: - return [] - - # Get all linked conversations, excluding current conversation - conversation_id = str(context.id) - return knowledge_package.get_all_linked_conversations(exclude_current=conversation_id) - - except Exception as e: - logger.error(f"Error getting linked conversations: {e}") - return [] - - @staticmethod - async def set_conversation_role(context: ConversationContext, share_id: str, role: ConversationRole) -> None: - """ - Sets the role of a conversation in a knowledge transfer share. - """ - role_data = ConversationKnowledgePackageManager.ConversationRoleInfo( - share_id=share_id, role=role, conversation_id=str(context.id) - ) - role_path = ShareStorageManager.get_conversation_role_file_path(context) - write_model(role_path, role_data) - - @staticmethod - async def get_conversation_role(context: ConversationContext) -> Optional[ConversationRole]: - """ - Gets the role of a conversation in a knowledge transfer. - """ - role_path = ShareStorageManager.get_conversation_role_file_path(context) - role_data = read_model(role_path, ConversationKnowledgePackageManager.ConversationRoleInfo) - - if role_data: - return role_data.role - - return None - - @staticmethod - async def associate_conversation_with_share(context: ConversationContext, share_id: str) -> None: - """ - Associates a conversation with a knowledge share and captures redeemer information. - """ - logger.debug(f"Associating conversation {context.id} with share {share_id}") - - try: - # 1. Store the share association in the conversation's storage directory - share_data = ConversationKnowledgePackageManager.ShareAssociation(share_id=share_id) - share_path = ShareStorageManager.get_conversation_share_file_path(context) - logger.debug(f"Writing share association to {share_path}") - write_model(share_path, share_data) - - # 2. Capture redeemer information and store in knowledge package - # This method will now handle storing the conversation in JSON instead of file system - await ConversationKnowledgePackageManager._capture_redeemer_info(context, share_id) - - except Exception as e: - logger.error(f"Error associating conversation with share: {e}") - raise - - @staticmethod - async def _capture_redeemer_info(context: ConversationContext, share_id: str) -> None: - """ - Captures the redeemer (first non-assistant participant) information and stores it in the knowledge package. - Only captures info for actual team member conversations, not coordinator or shared conversations. - """ - try: - # Load the knowledge package - from .storage import ShareStorage - - knowledge_package = ShareStorage.read_share(share_id) - if not knowledge_package: - logger.warning(f"Could not load knowledge package {share_id} to capture redeemer info") - return - - conversation_id = str(context.id) - - # Skip if this is the coordinator conversation - if conversation_id == knowledge_package.coordinator_conversation_id: - logger.debug(f"Skipping redeemer capture for coordinator conversation {conversation_id}") - return - - # Skip if this is the shared conversation template - if conversation_id == knowledge_package.shared_conversation_id: - logger.debug(f"Skipping redeemer capture for shared conversation template {conversation_id}") - return - - # If we get here, it's a team member conversation - capture redeemer info - # Get current user information (the redeemer) - user_id, user_name = await get_current_user(context) - - if not user_id or not user_name: - logger.warning(f"Could not identify redeemer for conversation {conversation_id}") - return - - # Create team conversation info - team_conversation_info = TeamConversationInfo( - conversation_id=conversation_id, redeemer_user_id=user_id, redeemer_name=user_name - ) - - # Add to knowledge package - knowledge_package.team_conversations[conversation_id] = team_conversation_info - - # Save the updated knowledge package - ShareStorage.write_share(share_id, knowledge_package) - logger.debug(f"Captured redeemer info for team conversation {conversation_id}: {user_name} ({user_id})") - - except Exception as e: - logger.error(f"Error capturing redeemer info: {e}") - # Don't re-raise - this is not critical for the association process - - @staticmethod - async def get_associated_share_id(context: ConversationContext) -> Optional[str]: - """ - Gets the share ID associated with a conversation. - """ - share_path = ShareStorageManager.get_conversation_share_file_path(context) - share_data = read_model(share_path, ConversationKnowledgePackageManager.ShareAssociation) - - if share_data: - return share_data.share_id - - return None diff --git a/assistants/knowledge-transfer-assistant/assistant/data.py b/assistants/knowledge-transfer-assistant/assistant/data.py index defc77875..40d64a6f0 100644 --- a/assistants/knowledge-transfer-assistant/assistant/data.py +++ b/assistants/knowledge-transfer-assistant/assistant/data.py @@ -13,228 +13,102 @@ from pydantic import BaseModel, Field -class InspectorTab(str, Enum): - """ - Available inspector panel tabs for the knowledge transfer assistant. +class ConversationRole(str, Enum): + COORDINATOR = "coordinator" + TEAM = "team" + SHAREABLE_TEMPLATE = "shareable_template" - These correspond to the state_id values used in the inspector state providers - and for sending state events to update specific inspector panels. - """ +class ConversationShareInfo(BaseModel): + share_id: str + conversation_id: str + role: ConversationRole - BRIEF = "brief" # Knowledge brief and knowledge transfer overview - LEARNING = "learning" # Learning objectives and outcomes - SHARING = "sharing" # Information requests and sharing status - DEBUG = "debug" # Debug information and knowledge transfer state +class InspectorTab(str, Enum): + BRIEF = "brief" + LEARNING = "learning" + SHARING = "sharing" + DEBUG = "debug" class RequestPriority(str, Enum): - """ - Priority levels for information requests. - - Defines the urgency of a request from a team member to the Coordinator. - Higher priority requests should be addressed more quickly by the Coordinator. - MEDIUM is the default priority for most requests. - """ - - LOW = "low" # Non-urgent request, can be addressed when convenient - MEDIUM = "medium" # Standard priority for most requests - HIGH = "high" # Urgent request requiring prompt attention - CRITICAL = "critical" # Highest priority, requires immediate attention from the Coordinator + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + CRITICAL = "critical" class RequestStatus(str, Enum): - """ - Status options for information requests. - - Represents the lifecycle of an information request from creation to resolution. - Requests typically progress from NEW -> ACKNOWLEDGED -> IN_PROGRESS -> RESOLVED. - DEFERRED indicates requests that will be addressed later. - """ - - NEW = "new" # Request has been created but not yet acknowledged by the Coordinator - ACKNOWLEDGED = "acknowledged" # Coordinator has seen the request but hasn't started working on it - IN_PROGRESS = "in_progress" # Coordinator is actively working on the request - RESOLVED = "resolved" # Coordinator has provided a resolution to the request - DEFERRED = "deferred" # Request handling has been postponed to a later time + NEW = "new" + ACKNOWLEDGED = "acknowledged" + IN_PROGRESS = "in_progress" + RESOLVED = "resolved" + DEFERRED = "deferred" class BaseEntity(BaseModel): - """ - Base class for all knowledge transfer entities. - - Provides common fields and behavior that all knowledge transfer-related data models inherit. - This ensures consistency in how entities are created, versioned, and tracked. - All derived classes will have proper timestamps and creator information. - """ - version: int = 1 created_at: datetime = Field(default_factory=datetime.utcnow) updated_at: datetime = Field(default_factory=datetime.utcnow) - created_by: str # User ID - updated_by: str # User ID - conversation_id: str # Source conversation ID + created_by: str + updated_by: str + conversation_id: str class LearningOutcomeAchievement(BaseModel): - """ - Achievement record for a specific learning outcome by a team conversation. - - Each team conversation tracks its own achievements independently, - allowing multiple team members to have separate progress tracking. - """ - - outcome_id: str # ID of the learning outcome that was achieved - achieved: bool = True # Whether this outcome has been achieved - achieved_at: datetime = Field(default_factory=datetime.utcnow) # When the outcome was achieved + outcome_id: str + achieved: bool = True + achieved_at: datetime = Field(default_factory=datetime.utcnow) class TeamConversationInfo(BaseModel): - """ - Information about a team conversation participating in knowledge transfer. - - Each team conversation represents one redeemer (team member) who has - accessed the shared knowledge package. The conversation tracks the - redeemer's identity and their individual achievement progress. - """ - - conversation_id: str # The conversation ID for this team member - redeemer_user_id: str # User ID of the first non-assistant participant (the redeemer) - redeemer_name: str # Display name of the redeemer - joined_at: datetime = Field(default_factory=datetime.utcnow) # When this conversation joined the knowledge transfer - last_active_at: datetime = Field(default_factory=datetime.utcnow) # Last activity timestamp - - # Each team conversation tracks its own achievements independently + conversation_id: str + redeemer_user_id: str + redeemer_name: str + joined_at: datetime = Field(default_factory=datetime.utcnow) + last_active_at: datetime = Field(default_factory=datetime.utcnow) outcome_achievements: List[LearningOutcomeAchievement] = Field(default_factory=list) class LearningOutcome(BaseModel): - """ - A specific measurable learning outcome that defines knowledge transfer success. - - Learning outcomes are individual checkpoints that must be achieved - to accomplish a learning objective. Each outcome represents a concrete, - verifiable understanding or skill that can be marked as achieved. - - Achievement tracking is now handled per team conversation in the - TeamConversationInfo.outcome_achievements list, allowing multiple - team members to have independent progress tracking. - """ - - id: str = Field(default_factory=lambda: str(uuid.uuid4())) # Unique identifier for the outcome - description: str # Clear, specific description of what needs to be understood or accomplished + id: str = Field(default_factory=lambda: str(uuid.uuid4())) + description: str class LearningObjective(BaseModel): - """ - A specific learning objective with associated learning outcomes. - - Learning objectives represent the major knowledge areas that need to be understood - for the knowledge transfer to be successful. Each objective consists of a name, description, - priority level, and a list of specific learning outcomes that define when - the objective can be considered achieved. - - Objectives are typically set by the Coordinator during knowledge organization and then tracked - by both the Coordinator and team members throughout the knowledge transfer. - """ - - id: str = Field(default_factory=lambda: str(uuid.uuid4())) # Unique identifier for the objective - name: str # Short, clear name of the learning objective - description: str # Detailed description of what should be learned - priority: int = 1 # Priority level (1 = highest priority, increasing numbers = lower priority) - learning_outcomes: List[LearningOutcome] = Field(default_factory=list) # List of outcomes to achieve + id: str = Field(default_factory=lambda: str(uuid.uuid4())) + name: str + description: str + priority: int = 1 + learning_outcomes: List[LearningOutcome] = Field(default_factory=list) class KnowledgeBrief(BaseEntity): - """ - A thorough, comprehensive documentation of the knowledge to be transferred, - containing all relevant information necessary for understanding and - learning. - - The brief is the primary document that defines the knowledge package. It - serves as the central reference for both the Coordinator and team members to - understand what needs to be learned and why, capturing the comprehensive - context of the knowledge being transferred. - - The brief focuses on capturing comprehensive knowledge that help learners - understand the scope, purpose, and content of the knowledge transfer. - - Created by the Coordinator during the ORGANIZING phase, the brief must be - completed before the knowledge can move to the READY_FOR_TRANSFER state. - Once team members begin learning, the brief can still be updated, but major - changes should be communicated to all participants. - """ - - title: str # Short, distinctive title for the knowledge package to transfer - content: str # The brief content, including the knowledge's purpose, scope, and content - timeline: Optional[str] = None # Expected timeline for learning (optional) + title: str + content: str + timeline: Optional[str] = None class KnowledgeDigest(BaseEntity): - """ - A dynamic knowledge digest that gets automatically updated as the coordinator organizes knowledge. - - The knowledge digest captures and maintains important knowledge context that emerges during - conversations. It is automatically updated after each assistant message by analyzing - the conversation history and extracting key information in FAQ format. - - Unlike a traditional knowledge base with separate sections, the digest is a single - consolidated view that shows the most relevant information for the knowledge transfer. It serves as - a dynamic, evolving source of truth that all team members can reference. - """ - - content: str = "" # Markdown content for the knowledge digest (FAQ format) - is_auto_generated: bool = True # Whether the content was auto-generated or manually edited + content: str = "" + is_auto_generated: bool = True class InformationRequest(BaseEntity): - """ - A specific information need or blocker submitted by team members - that requires Coordinator support to resolve. - - Information requests are the primary communication mechanism for team members - to request assistance, information, or resources from the Coordinator. They represent - questions, blockers, or needs that arise during knowledge transfer. + request_id: str = Field(default_factory=lambda: str(uuid.uuid4())) + title: str + description: str + priority: RequestPriority = RequestPriority.MEDIUM + status: RequestStatus = RequestStatus.NEW + related_objective_ids: List[str] = Field(default_factory=list) + resolution: Optional[str] = None + resolved_at: Optional[datetime] = None + resolved_by: Optional[str] = None - The lifecycle of an information request typically follows: - 1. Created by a team member (NEW status) - 2. Seen by the Coordinator (ACKNOWLEDGED status) - 3. Worked on by the Coordinator (IN_PROGRESS status) - 4. Completed with a resolution (RESOLVED status) - - Requests can also be DEFERRED for later handling or CANCELLED if no longer relevant. - The request priority helps the Coordinator prioritize which requests to handle first. - """ - - # Request identification - request_id: str = Field(default_factory=lambda: str(uuid.uuid4())) # Unique ID for this request - - # Request details - title: str # Short summary of the request - description: str # Detailed explanation of what is needed - priority: RequestPriority = RequestPriority.MEDIUM # Urgency level of the request - status: RequestStatus = RequestStatus.NEW # Current status in the request lifecycle - - # Reference to the related learning objective(s) if applicable - related_objective_ids: List[str] = Field(default_factory=list) # IDs of learning objectives this request relates to - - # Resolution information - resolution: Optional[str] = None # The answer or solution provided by the Coordinator - resolved_at: Optional[datetime] = None # When the request was resolved - resolved_by: Optional[str] = None # User ID of the Coordinator who resolved this request - - # Updates and comments on this request - updates: List[Dict[str, Any]] = Field(default_factory=list) # History of status updates and comments + # History of status updates and comments + updates: List[Dict[str, Any]] = Field(default_factory=list) class LogEntryType(str, Enum): - """ - Types of log entries in the knowledge transfer log. - - These entry types categorize all events that can occur during a knowledge transfer. - Log entries provide a chronological history of actions and events in the knowledge transfer, - allowing both Coordinators and team members to track progress and changes. - """ - # Brief-related events BRIEFING_CREATED = "briefing_created" BRIEFING_UPDATED = "briefing_updated" @@ -271,311 +145,65 @@ class LogEntryType(str, Enum): KNOWLEDGE_DIGEST_UPDATE = "knowledge_digest_update" CUSTOM = "custom" - # Backward compatibility for old log entries - KB_UPDATE = "kb_update" # Legacy alias for KNOWLEDGE_DIGEST_UPDATE - GOAL_ADDED = "goal_added" # Legacy alias for LEARNING_OBJECTIVE_ADDED - class LogEntry(BaseModel): - """ - Individual entry in the knowledge transfer log. - - Log entries record all significant events that occur during a knowledge transfer. - Each entry has a specific type, message, and associated metadata. - - The chronological sequence of log entries forms a complete audit trail - of the knowledge transfer's progress, actions taken, and events that occurred. - This provides accountability and helps with post-knowledge transfer review. - - Log entries are typically created automatically by the system when - certain actions are taken, but can also be manually added by participants. - """ - - id: str = Field(default_factory=lambda: str(uuid.uuid4())) # Unique identifier for this log entry - timestamp: datetime = Field(default_factory=datetime.utcnow) # When this entry was created - entry_type: LogEntryType # The category/type of this log entry - message: str # Human-readable description of what happened - user_id: str # ID of the user who performed the action or triggered the event - user_name: str # Name of the user, for display purposes - - # Optional additional context for the entry - related_entity_id: Optional[str] = None # ID of related entity (e.g., information request ID) - entity_type: Optional[str] = None # Type of related entity (e.g., "information_request", "learning_objective") - metadata: Optional[Dict] = None # Additional structured data about the event + id: str = Field(default_factory=lambda: str(uuid.uuid4())) + timestamp: datetime = Field(default_factory=datetime.utcnow) + entry_type: LogEntryType + user_id: str + user_name: str + related_entity_id: Optional[str] = None + entity_type: Optional[str] = None + message: str + metadata: Optional[Dict] = None class KnowledgePackageLog(BaseModel): - """ - A chronological record of all actions and interactions during the knowledge transfer, - including updates and progress reports. - - The knowledge transfer log serves as the comprehensive history of everything that - happened during a knowledge transfer. It contains a chronological list of log entries - describing actions, state changes, and significant events. - - The log is used for: - - Real-time monitoring of knowledge transfer activity - - Post-knowledge transfer review and analysis - - Accountability and documentation purposes - - Tracking the sequence of events leading to outcomes - - Both the Coordinator and team members can view the knowledge transfer log, providing transparency - into what has occurred during the knowledge transfer. - """ - entries: List[LogEntry] = Field(default_factory=list) # Chronological list of log entries class KnowledgePackage(BaseModel): - """ - A comprehensive representation of a knowledge package, including its brief, digest, - information requests, logs, and other related entities. - - This model encapsulates all the components that make up a knowledge transfer package, - providing a single point of access to all relevant information for Coordinators and Team members. - It serves as the main interface for interacting with the knowledge transfer data. - - All fields from the former KnowledgePackageInfo have been flattened into this model - for simpler data management. - """ - - # Core package identification and state (formerly from KnowledgePackageInfo) - share_id: str # Unique identifier for the knowledge package - coordinator_conversation_id: Optional[str] = None # ID of the coordinator's conversation - shared_conversation_id: Optional[str] = None # ID of the shareable template conversation for generating share URLs - share_url: Optional[str] = None # Shareable URL for inviting team members - next_learning_actions: List[str] = Field(default_factory=list) # List of next learning actions - transfer_lifecycle: Dict[str, Any] = Field(default_factory=dict) # Transfer lifecycle metadata - archived: bool = False # Whether this knowledge package has been archived - is_intended_to_accomplish_outcomes: bool = ( - True # Whether this package is intended for specific learning outcomes vs general exploration - ) - knowledge_organized: bool = False # Whether the coordinator has confirmed all necessary knowledge has been captured - - # Metadata fields (formerly from KnowledgePackageInfo) - version: int = 1 # Version counter for tracking changes + share_id: str + coordinator_conversation_id: Optional[str] = None + shared_conversation_id: Optional[str] = None + team_conversations: Dict[str, TeamConversationInfo] = Field(default_factory=dict) + share_url: Optional[str] = None + + version: int = 1 created_at: datetime = Field(default_factory=datetime.utcnow) updated_at: datetime = Field(default_factory=datetime.utcnow) - updated_by: Optional[str] = None # User ID who last updated the package + updated_by: Optional[str] = None # Package components - audience: Optional[str] = None # Description of the intended audience and their existing knowledge level + audience: Optional[str] = None brief: Optional[KnowledgeBrief] learning_objectives: List[LearningObjective] = Field(default_factory=list) - takeaways: List[str] = Field(default_factory=list) # Key takeaways from the knowledge package - preferred_communication_style: Optional[str] = None # Preferred communication style for the audience - transfer_notes: Optional[str] = None # Status message or notes about the knowledge transfer + takeaways: List[str] = Field(default_factory=list) + preferred_communication_style: Optional[str] = None + transfer_notes: Optional[str] = None + digest: Optional[KnowledgeDigest] + # Lifecycle + is_intended_to_accomplish_outcomes: bool = True + next_learning_actions: List[str] = Field(default_factory=list) + transfer_lifecycle: Dict[str, Any] = Field(default_factory=dict) + knowledge_organized: bool = False + archived: bool = False requests: List[InformationRequest] = Field(default_factory=list) - digest: Optional[KnowledgeDigest] - log: Optional[KnowledgePackageLog] = Field(default_factory=lambda: KnowledgePackageLog()) - # Team conversation tracking with embedded achievements - team_conversations: Dict[str, TeamConversationInfo] = Field(default_factory=dict) + log: Optional[KnowledgePackageLog] = Field(default_factory=lambda: KnowledgePackageLog()) - def is_ready_for_transfer(self) -> bool: - """ - Determine if this knowledge package is ready for transfer to team members. - - A package is ready when it has: - - Knowledge organized and confirmed by coordinator - - A knowledge brief - - An audience definition - - Either: - - Learning objectives with outcomes (if is_intended_to_accomplish_outcomes is True), OR - - No learning objectives needed (if is_intended_to_accomplish_outcomes is False) - - Returns: - bool: True if ready for transfer, False otherwise - """ - has_basic_requirements = self.knowledge_organized and self.brief is not None and self.audience is not None - - if not has_basic_requirements: - return False - - # If this package is intended for general exploration (no specific outcomes) - if not self.is_intended_to_accomplish_outcomes: - return True - - # If this package is intended for specific learning outcomes - return bool(self.learning_objectives) and any(bool(obj.learning_outcomes) for obj in self.learning_objectives) - - def is_actively_sharing(self) -> bool: - """ - Determine if knowledge transfer is actively happening (team members have joined). - - A package is actively sharing when it's ready for transfer AND has team conversations. - This indicates that team members have actually redeemed share links and are engaging - with the knowledge. - - Returns: - bool: True if actively sharing, False otherwise - """ - return self.is_ready_for_transfer() and len(self.team_conversations) > 0 - - def get_stage_label(self, for_coordinator: bool = True) -> str: - """ - Get a human-readable stage label based on current package state. - - Args: - for_coordinator: Whether to return coordinator-focused or team-focused labels - - Returns: - str: Stage label with emoji - """ - if self.archived: - return "📦 Archived" - - if for_coordinator: - # Coordinator perspective - if not self.audience: - return "🎯 Defining Audience" - elif not self.knowledge_organized: - return "📋 Organizing Knowledge" - elif not self.brief: - return "📝 Creating Brief" - elif self.is_intended_to_accomplish_outcomes and not self.learning_objectives: - return "📚 Adding Objectives" - elif not self.is_ready_for_transfer(): - return "📋 Finalizing Setup" - elif self.is_intended_to_accomplish_outcomes and self._is_transfer_complete(): - return "✅ Transfer Complete" - elif self.is_actively_sharing(): - return "📤 Sharing in Progress" - else: - return "🚀 Ready for Transfer" - else: - # Team perspective - if self.archived: - return "📦 Archived" - elif not self.is_ready_for_transfer(): - return "⏳ Knowledge Being Organized" - elif not self.is_intended_to_accomplish_outcomes: - return "🔍 Exploring Knowledge" - elif self.is_intended_to_accomplish_outcomes: - return "🎯 Active Learning" - else: - return "🎯 Active Learning" - - def get_achievements_for_conversation(self, conversation_id: str) -> List[LearningOutcomeAchievement]: - """ - Get all achievements for a specific team conversation. - - Args: - conversation_id: The conversation ID to get achievements for - - Returns: - List of achievements for the conversation, empty list if conversation not found - """ - team_conv = self.team_conversations.get(conversation_id) - return team_conv.outcome_achievements if team_conv else [] - - def get_completion_for_conversation(self, conversation_id: str) -> tuple[int, int]: - """ - Get (achieved_count, total_count) for a specific conversation. - - Args: - conversation_id: The conversation ID to get completion for - - Returns: - Tuple of (achieved_outcomes, total_outcomes) for this conversation - """ - achievements = self.get_achievements_for_conversation(conversation_id) - achieved_outcome_ids = {a.outcome_id for a in achievements if a.achieved} - - total_outcomes = sum(len(obj.learning_outcomes) for obj in self.learning_objectives) - achieved_outcomes = len(achieved_outcome_ids) - - return achieved_outcomes, total_outcomes - - def is_outcome_achieved_by_conversation(self, outcome_id: str, conversation_id: str) -> bool: - """ - Check if a specific outcome is achieved by a specific conversation. - - Args: - outcome_id: The learning outcome ID to check - conversation_id: The conversation ID to check - - Returns: - True if the outcome is achieved by this conversation - """ - achievements = self.get_achievements_for_conversation(conversation_id) - return any(a.outcome_id == outcome_id and a.achieved for a in achievements) - - def get_overall_completion(self) -> tuple[int, int]: - """ - Get overall completion across all team conversations. - - Returns: - Tuple of (unique_achieved_outcomes, total_outcomes) across all team conversations - """ - all_achieved_outcomes = set() - for team_conv in self.team_conversations.values(): - achieved_ids = {a.outcome_id for a in team_conv.outcome_achievements if a.achieved} - all_achieved_outcomes.update(achieved_ids) - - total_outcomes = sum(len(obj.learning_outcomes) for obj in self.learning_objectives) - return len(all_achieved_outcomes), total_outcomes - - def _is_transfer_complete(self) -> bool: - """ - Check if knowledge transfer is complete (all outcomes achieved by at least one team member). - - Returns: - True if all learning outcomes have been achieved by at least one team conversation - """ - if not self.is_intended_to_accomplish_outcomes: - return False - - achieved_outcomes, total_outcomes = self.get_overall_completion() - return total_outcomes > 0 and achieved_outcomes == total_outcomes - - def get_all_linked_conversations(self, exclude_current: Optional[str] = None) -> List[str]: - """ - Get all conversations linked to this knowledge package. - - Args: - exclude_current: Conversation ID to exclude from results (typically current conversation) - - Returns: - List of conversation IDs (coordinator, shared template, and all team conversations) - """ - conversations = [] - - # Add coordinator conversation - if self.coordinator_conversation_id and self.coordinator_conversation_id != exclude_current: - conversations.append(self.coordinator_conversation_id) - - # Add shared template conversation (though usually excluded from notifications) - if self.shared_conversation_id and self.shared_conversation_id != exclude_current: - conversations.append(self.shared_conversation_id) - - # Add all team conversations - for conversation_id in self.team_conversations.keys(): - if conversation_id != exclude_current: - conversations.append(conversation_id) - - return conversations - def get_notification_conversations(self, exclude_current: Optional[str] = None) -> List[str]: - """ - Get conversations that should receive notifications (excludes shared template). +class CoordinatorConversationMessage(BaseModel): + message_id: str + content: str + sender_name: str + timestamp: datetime = Field(default_factory=datetime.utcnow) + is_assistant: bool = False - Args: - exclude_current: Conversation ID to exclude from results - - Returns: - List of conversation IDs that should receive notifications - """ - conversations = [] - # Add coordinator conversation - if self.coordinator_conversation_id and self.coordinator_conversation_id != exclude_current: - conversations.append(self.coordinator_conversation_id) +class CoordinatorConversationMessages(BaseModel): + knowledge_share_id: str + last_updated: datetime = Field(default_factory=datetime.utcnow) + messages: List[CoordinatorConversationMessage] = Field(default_factory=list) - # Add all team conversations (but NOT shared template) - for conversation_id in self.team_conversations.keys(): - if conversation_id != exclude_current: - conversations.append(conversation_id) - - return conversations diff --git a/assistants/knowledge-transfer-assistant/assistant/domain/__init__.py b/assistants/knowledge-transfer-assistant/assistant/domain/__init__.py index bde703cef..1941d2eec 100644 --- a/assistants/knowledge-transfer-assistant/assistant/domain/__init__.py +++ b/assistants/knowledge-transfer-assistant/assistant/domain/__init__.py @@ -33,10 +33,8 @@ class KnowledgeTransferManager: create_share = ShareManager.create_share join_share = ShareManager.join_share get_share_id = ShareManager.get_share_id - get_share_role = ShareManager.get_share_role get_share_log = ShareManager.get_share_log get_share = ShareManager.get_share - get_share_info = ShareManager.get_share_info # Knowledge Brief Operations get_knowledge_brief = KnowledgeBriefManager.get_knowledge_brief diff --git a/assistants/knowledge-transfer-assistant/assistant/domain/knowledge_package_manager.py b/assistants/knowledge-transfer-assistant/assistant/domain/knowledge_package_manager.py new file mode 100644 index 000000000..2c2bbd230 --- /dev/null +++ b/assistants/knowledge-transfer-assistant/assistant/domain/knowledge_package_manager.py @@ -0,0 +1,128 @@ +""" +Knowledge Package Service for business logic operations. + +Provides business logic methods for KnowledgePackage state assessment, +presentation, achievement tracking, and conversation management. +""" + +from typing import List, Optional, Tuple + +from ..data import KnowledgePackage, LearningOutcomeAchievement + + +class KnowledgePackageManager: + """Manager class for KnowledgePackage business logic operations.""" + + @staticmethod + def is_ready_for_transfer(package: KnowledgePackage) -> bool: + has_basic_requirements = ( + package.knowledge_organized and package.brief is not None and package.audience is not None + ) + + if not has_basic_requirements: + return False + + if not package.is_intended_to_accomplish_outcomes: + return True + + return bool(package.learning_objectives) and any( + bool(obj.learning_outcomes) for obj in package.learning_objectives + ) + + @staticmethod + def is_actively_sharing(package: KnowledgePackage) -> bool: + return KnowledgePackageManager.is_ready_for_transfer(package) and len(package.team_conversations) > 0 + + @staticmethod + def get_achievements_for_conversation( + package: KnowledgePackage, conversation_id: str + ) -> List[LearningOutcomeAchievement]: + team_conv = package.team_conversations.get(conversation_id) + return team_conv.outcome_achievements if team_conv else [] + + @staticmethod + def get_completion_for_conversation(package: KnowledgePackage, conversation_id: str) -> Tuple[int, int]: + achievements = KnowledgePackageManager.get_achievements_for_conversation(package, conversation_id) + achieved_outcome_ids = {a.outcome_id for a in achievements if a.achieved} + + total_outcomes = sum(len(obj.learning_outcomes) for obj in package.learning_objectives) + achieved_outcomes = len(achieved_outcome_ids) + + return achieved_outcomes, total_outcomes + + @staticmethod + def is_outcome_achieved_by_conversation(package: KnowledgePackage, outcome_id: str, conversation_id: str) -> bool: + achievements = KnowledgePackageManager.get_achievements_for_conversation(package, conversation_id) + return any(a.outcome_id == outcome_id and a.achieved for a in achievements) + + @staticmethod + def get_overall_completion(package: KnowledgePackage) -> Tuple[int, int]: + """ + Get overall completion across all team conversations. + Returns: + Tuple of (unique_achieved_outcomes, total_outcomes) across all team conversations + """ + all_achieved_outcomes = set() + for team_conv in package.team_conversations.values(): + achieved_ids = {a.outcome_id for a in team_conv.outcome_achievements if a.achieved} + all_achieved_outcomes.update(achieved_ids) + + total_outcomes = sum(len(obj.learning_outcomes) for obj in package.learning_objectives) + return len(all_achieved_outcomes), total_outcomes + + @staticmethod + def get_all_linked_conversations(package: KnowledgePackage, exclude_current: Optional[str] = None) -> List[str]: + """ + Get all conversations linked to this knowledge package. + Returns: + List of conversation IDs (coordinator, shared template, and all team conversations) + """ + conversations = [] + + # Add coordinator conversation + if package.coordinator_conversation_id and package.coordinator_conversation_id != exclude_current: + conversations.append(package.coordinator_conversation_id) + + # Add shared template conversation (though usually excluded from notifications) + if package.shared_conversation_id and package.shared_conversation_id != exclude_current: + conversations.append(package.shared_conversation_id) + + # Add all team conversations + for conversation_id in package.team_conversations.keys(): + if conversation_id != exclude_current: + conversations.append(conversation_id) + + return conversations + + @staticmethod + def get_notification_conversations(package: KnowledgePackage, exclude_current: Optional[str] = None) -> List[str]: + """ + Get conversations that should receive notifications (excludes shared template). + Returns: + List of conversation IDs that should receive notifications + """ + conversations = [] + + # Add coordinator conversation + if package.coordinator_conversation_id and package.coordinator_conversation_id != exclude_current: + conversations.append(package.coordinator_conversation_id) + + # Add all team conversations (but NOT shared template) + for conversation_id in package.team_conversations.keys(): + if conversation_id != exclude_current: + conversations.append(conversation_id) + + return conversations + + @staticmethod + def _is_transfer_complete(package: KnowledgePackage) -> bool: + """ + Check if knowledge transfer is complete (all outcomes achieved by at least one team member). + Returns: + True if all learning outcomes have been achieved by at least one team conversation + """ + if not package.is_intended_to_accomplish_outcomes: + return False + + achieved_outcomes, total_outcomes = KnowledgePackageManager.get_overall_completion(package) + return total_outcomes > 0 and achieved_outcomes == total_outcomes diff --git a/assistants/knowledge-transfer-assistant/assistant/domain/share_manager.py b/assistants/knowledge-transfer-assistant/assistant/domain/share_manager.py index 71934a2a2..f1b089c71 100644 --- a/assistants/knowledge-transfer-assistant/assistant/domain/share_manager.py +++ b/assistants/knowledge-transfer-assistant/assistant/domain/share_manager.py @@ -6,7 +6,7 @@ import uuid from datetime import datetime -from typing import Optional +from typing import List, Optional from semantic_workbench_api_model.workbench_model import ( ConversationPermission, @@ -15,13 +15,13 @@ ) from semantic_workbench_assistant.assistant_app import ConversationContext -from ..conversation_share_link import ConversationKnowledgePackageManager -from ..data import KnowledgePackage, KnowledgePackageLog +from assistant.domain.knowledge_package_manager import KnowledgePackageManager + +from ..data import ConversationShareInfo, KnowledgePackage, KnowledgePackageLog, ConversationRole, TeamConversationInfo from ..logging import logger from ..storage import ShareStorage, ShareStorageManager -from ..storage_models import ConversationRole from ..utils import get_current_user - +from semantic_workbench_assistant.storage import read_model, write_model class ShareManager: """Manages knowledge share creation, joining, and basic operations.""" @@ -140,7 +140,7 @@ async def create_share(context: ConversationContext) -> str: # Associate the conversation with the share logger.debug(f"Associating conversation {context.id} with share {share_id}") - await ConversationKnowledgePackageManager.associate_conversation_with_share(context, share_id) + await ShareManager.set_conversation_role(context, share_id, ConversationRole.COORDINATOR) # No need to set conversation role in share storage, as we use metadata logger.debug(f"Conversation {context.id} is Coordinator for share {share_id}") @@ -172,7 +172,7 @@ async def join_share( return False # Associate the conversation with the share - await ConversationKnowledgePackageManager.associate_conversation_with_share(context, share_id) + await ShareManager.set_conversation_role(context, share_id, role) # Role is set in metadata, not in storage @@ -199,43 +199,9 @@ async def get_share_id(context: ConversationContext) -> Optional[str]: The share ID string if the conversation is part of a share, None otherwise """ - return await ConversationKnowledgePackageManager.get_associated_share_id(context) + return await ShareManager.get_associated_share_id(context) - @staticmethod - async def get_share_role(context: ConversationContext) -> Optional[ConversationRole]: - """ - Gets the role of the current conversation in its share. - Each conversation participating in a share has a specific role: - - COORDINATOR: The primary conversation that created and manages the share - - TEAM: Conversations where team members are carrying out the share tasks - - This method examines the conversation metadata to determine the role - of the current conversation in the share. The role is stored in the - conversation metadata as "project_role". - - Args: - context: Current conversation context - - Returns: - The role (KnowledgePackageRole.COORDINATOR or KnowledgePackageRole.TEAM) if the conversation - is part of a share, None otherwise - """ - try: - conversation = await context.get_conversation() - metadata = conversation.metadata or {} - role_str = metadata.get("project_role", "coordinator") - - if role_str == "team": - return ConversationRole.TEAM - elif role_str == "coordinator": - return ConversationRole.COORDINATOR - else: - return None - except Exception as e: - logger.exception(f"Error detecting share role: {e}") - # Default to None if we can't determine - return None @staticmethod async def get_share_log(context: ConversationContext) -> Optional[KnowledgePackageLog]: @@ -263,28 +229,112 @@ async def get_share(context: ConversationContext) -> Optional[KnowledgePackage]: return share @staticmethod - async def get_share_info( - context: ConversationContext, share_id: Optional[str] = None - ) -> Optional[KnowledgePackage]: + async def get_linked_conversations(context: ConversationContext) -> List[str]: + """ + Gets all conversations linked to this one through the same knowledge transfer share. """ - Gets the share information including share URL and team conversation details. + try: + share_id = await ShareManager.get_associated_share_id(context) + if not share_id: + return [] - Args: - context: Current conversation context - share_id: Optional share ID (if not provided, will be retrieved from context) + # Load the knowledge package + from ..storage import ShareStorage - Returns: - KnowledgePackageInfo object or None if not found + knowledge_package = ShareStorage.read_share(share_id) + if not knowledge_package: + return [] + + # Get all linked conversations, excluding current conversation + conversation_id = str(context.id) + return KnowledgePackageManager.get_all_linked_conversations( + knowledge_package, exclude_current=conversation_id + ) + + except Exception as e: + logger.error(f"Error getting linked conversations: {e}") + return [] + + @staticmethod + async def set_conversation_role(context: ConversationContext, share_id: str, role: ConversationRole) -> None: + """ + Sets the role of a conversation in a knowledge transfer share. + """ + role_data = ConversationShareInfo( + share_id=share_id, role=role, conversation_id=str(context.id) + ) + role_path = ShareStorageManager.get_conversation_role_file_path(context) + write_model(role_path, role_data) + + @staticmethod + async def get_conversation_role(context: ConversationContext) -> Optional[ConversationRole]: + """ + Gets the role of a conversation in a knowledge transfer. + """ + role_path = ShareStorageManager.get_conversation_role_file_path(context) + role_data = read_model(role_path, ConversationShareInfo) + + if role_data: + return role_data.role + + return None + + @staticmethod + async def _capture_redeemer_info(context: ConversationContext, share_id: str) -> None: + """ + Captures the redeemer (first non-assistant participant) information and stores it in the knowledge package. + Only captures info for actual team member conversations, not coordinator or shared conversations. """ try: - if not share_id: - share_id = await ShareManager.get_share_id(context) - if not share_id: - return None - share_info = ShareStorage.read_share_info(share_id) - return share_info + knowledge_package = ShareStorage.read_share(share_id) + if not knowledge_package: + logger.warning(f"Could not load knowledge package {share_id} to capture redeemer info") + return + + conversation_id = str(context.id) + + # Skip if this is the coordinator conversation + if conversation_id == knowledge_package.coordinator_conversation_id: + logger.debug(f"Skipping redeemer capture for coordinator conversation {conversation_id}") + return + + # Skip if this is the shared conversation template + if conversation_id == knowledge_package.shared_conversation_id: + logger.debug(f"Skipping redeemer capture for shared conversation template {conversation_id}") + return + + # If we get here, it's a team member conversation - capture redeemer info + # Get current user information (the redeemer) + user_id, user_name = await get_current_user(context) + + if not user_id or not user_name: + logger.warning(f"Could not identify redeemer for conversation {conversation_id}") + return + + # Create team conversation info + team_conversation_info = TeamConversationInfo( + conversation_id=conversation_id, redeemer_user_id=user_id, redeemer_name=user_name + ) + + # Add to knowledge package + knowledge_package.team_conversations[conversation_id] = team_conversation_info + + # Save the updated knowledge package + ShareStorage.write_share(share_id, knowledge_package) + logger.debug(f"Captured redeemer info for team conversation {conversation_id}: {user_name} ({user_id})") except Exception as e: - logger.exception(f"Error getting share info: {e}") - return None + logger.error(f"Error capturing redeemer info: {e}") + # Don't re-raise - this is not critical for the association process + + @staticmethod + async def get_associated_share_id(context: ConversationContext) -> Optional[str]: + """ + Gets the share ID associated with a conversation. + """ + share_path = ShareStorageManager.get_conversation_role_file_path(context) + share_data = read_model(share_path, ConversationShareInfo) + if share_data: + return share_data.share_id + return None diff --git a/assistants/knowledge-transfer-assistant/assistant/files.py b/assistants/knowledge-transfer-assistant/assistant/files.py index 7303e56b3..e450bfda6 100644 --- a/assistants/knowledge-transfer-assistant/assistant/files.py +++ b/assistants/knowledge-transfer-assistant/assistant/files.py @@ -18,11 +18,11 @@ from .common import detect_assistant_role from .conversation_clients import ConversationClientManager -from .conversation_share_link import ConversationKnowledgePackageManager +from .domain.share_manager import ShareManager from .data import LogEntryType from .logging import logger from .storage import ShareStorage, ShareStorageManager, read_model, write_model -from .storage_models import ConversationRole +from .data import ConversationRole # Define helper function for safe logging without 'filename' conflict @@ -418,7 +418,7 @@ async def get_team_conversations(context: ConversationContext, share_id: str) -> """ try: # Get linked conversations - linked_conversations = await ConversationKnowledgePackageManager.get_linked_conversations(context) + linked_conversations = await ShareManager.get_linked_conversations(context) # Filter for team conversations team_conversations = [] @@ -426,7 +426,7 @@ async def get_team_conversations(context: ConversationContext, share_id: str) -> # Check if this is a team conversation temp_context = await ShareFilesManager.create_temporary_context(context, conv_id) if temp_context: - role = await ConversationKnowledgePackageManager.get_conversation_role(temp_context) + role = await ShareManager.get_conversation_role(temp_context) if role == ConversationRole.TEAM: team_conversations.append(conv_id) diff --git a/assistants/knowledge-transfer-assistant/assistant/respond.py b/assistants/knowledge-transfer-assistant/assistant/respond.py index 6179e88fc..4a214d133 100644 --- a/assistants/knowledge-transfer-assistant/assistant/respond.py +++ b/assistants/knowledge-transfer-assistant/assistant/respond.py @@ -32,9 +32,10 @@ from .config import assistant_config from .data import RequestStatus from .domain import KnowledgeTransferManager +from .domain.knowledge_package_manager import KnowledgePackageManager from .logging import logger from .storage import ShareStorage -from .storage_models import ConversationRole, CoordinatorConversationMessage +from .data import ConversationRole, CoordinatorConversationMessage from .string_utils import Context, ContextStrategy, Instructions, Prompt, TokenBudget from .tools import ShareTools from .ui_tabs.common import get_priority_emoji, get_status_emoji @@ -179,9 +180,9 @@ async def respond_to_conversation( ### # Project info - share_info = ShareStorage.read_share_info(share_id) - if share_info: - data = share_info.model_dump() + share = ShareStorage.read_share(share_id) + if share: + data = share.model_dump() # Delete fields that are not relevant to the knowledge transfer assistant. # FIXME: Reintroduce these properly. @@ -196,7 +197,7 @@ async def respond_to_conversation( if "lifecycle" in data: del data["lifecycle"] - share_info_text = share_info.model_dump_json(indent=2) + share_info_text = share.model_dump_json(indent=2) prompt.contexts.append(Context("Knowledge Info", share_info_text)) # Brief @@ -212,9 +213,9 @@ async def respond_to_conversation( ) # Audience (for coordinators to understand target audience) - if role == ConversationRole.COORDINATOR and share_info and share_info.audience: - audience_context = share_info.audience - if not share_info.is_intended_to_accomplish_outcomes: + if role == ConversationRole.COORDINATOR and share and share.audience: + audience_context = share.audience + if not share.is_intended_to_accomplish_outcomes: audience_context += "\n\n**Note:** This knowledge package is intended for general exploration, not specific learning outcomes." prompt.contexts.append( @@ -234,14 +235,16 @@ async def respond_to_conversation( # Show progress based on role if role == ConversationRole.COORDINATOR: # Coordinator sees overall progress across all team members - achieved_overall, total_overall = share.get_overall_completion() + achieved_overall, total_overall = KnowledgePackageManager.get_overall_completion(share) learning_objectives_text += ( f"Overall Progress: {achieved_overall}/{total_overall} outcomes achieved by team members\n\n" ) else: # Team member sees their personal progress if conversation_id in share.team_conversations: - achieved_personal, total_personal = share.get_completion_for_conversation(conversation_id) + achieved_personal, total_personal = KnowledgePackageManager.get_completion_for_conversation( + share, conversation_id + ) progress_pct = int((achieved_personal / total_personal * 100)) if total_personal > 0 else 0 learning_objectives_text += ( f"My Progress: {achieved_personal}/{total_personal} outcomes achieved ({progress_pct}%)\n\n" @@ -254,13 +257,15 @@ async def respond_to_conversation( if role == ConversationRole.COORDINATOR: # Show if achieved by any team member achieved_by_any = any( - share.is_outcome_achieved_by_conversation(criterion.id, conv_id) + KnowledgePackageManager.is_outcome_achieved_by_conversation(share, criterion.id, conv_id) for conv_id in share.team_conversations.keys() ) check = "✅" if achieved_by_any else "⬜" else: # Show if achieved by this team member - achieved_by_me = share.is_outcome_achieved_by_conversation(criterion.id, conversation_id) + achieved_by_me = KnowledgePackageManager.is_outcome_achieved_by_conversation( + share, criterion.id, conversation_id + ) check = "✅" if achieved_by_me else "⬜" learning_objectives_text += f" {check} {criterion.description}\n" @@ -292,7 +297,7 @@ async def respond_to_conversation( coordinator_requests += f" **Description:** {req.description}\n\n" if len(active_requests) > 10: - coordinator_requests += f'*...and {len(active_requests) - 10} more requests. Use get_share_info(info_type="requests") to see all.*\n' + coordinator_requests += f'*...and {len(active_requests) - 10} more requests.*\n' else: coordinator_requests = "No active information requests." prompt.contexts.append( diff --git a/assistants/knowledge-transfer-assistant/assistant/storage.py b/assistants/knowledge-transfer-assistant/assistant/storage.py index 8b849ecd5..56d8b7d98 100644 --- a/assistants/knowledge-transfer-assistant/assistant/storage.py +++ b/assistants/knowledge-transfer-assistant/assistant/storage.py @@ -26,7 +26,7 @@ LogEntry, LogEntryType, ) -from .storage_models import CoordinatorConversationMessage, CoordinatorConversationStorage +from .data import CoordinatorConversationMessage, CoordinatorConversationMessages from .utils import get_current_user @@ -84,27 +84,21 @@ def get_conversation_role_file_path(context: ConversationContext) -> pathlib.Pat storage_dir.mkdir(parents=True, exist_ok=True) return storage_dir / "share_role.json" - @staticmethod - def get_conversation_share_file_path(context: ConversationContext) -> pathlib.Path: - """Gets the path to the file that stores a conversation's share association.""" - storage_dir = storage_directory_for_context(context) - storage_dir.mkdir(parents=True, exist_ok=True) - file_path = storage_dir / "share_association.json" - return file_path - - class ShareStorage: """Unified storage operations for knowledge transfer share data.""" @staticmethod - def read_share_info(share_id: str) -> Optional[KnowledgePackage]: - """Reads the knowledge package (alias for read_share).""" - return ShareStorage.read_share(share_id) + def read_share(share_id: str) -> Optional[KnowledgePackage]: + """Reads the complete KnowledgePackage data.""" + path = ShareStorageManager.get_share_path(share_id) + return read_model(path, KnowledgePackage) @staticmethod - def write_share_info(share_id: str, package: KnowledgePackage) -> pathlib.Path: - """Writes the knowledge package (alias for write_share).""" - return ShareStorage.write_share(share_id, package) + def write_share(share_id: str, package: KnowledgePackage) -> pathlib.Path: + """Writes the complete KnowledgePackage data.""" + path = ShareStorageManager.get_share_path(share_id) + write_model(path, package) + return path @staticmethod def read_knowledge_brief(share_id: str) -> Optional[KnowledgeBrief]: @@ -145,12 +139,12 @@ def read_knowledge_digest(share_id: str) -> Optional[KnowledgeDigest]: return package.digest if package else None @staticmethod - def read_coordinator_conversation(share_id: str) -> Optional[CoordinatorConversationStorage]: + def read_coordinator_conversation(share_id: str) -> Optional[CoordinatorConversationMessages]: path = ShareStorageManager.get_coordinator_conversation_path(share_id) - return read_model(path, CoordinatorConversationStorage) + return read_model(path, CoordinatorConversationMessages) @staticmethod - def write_coordinator_conversation(share_id: str, conversation: CoordinatorConversationStorage) -> pathlib.Path: + def write_coordinator_conversation(share_id: str, conversation: CoordinatorConversationMessages) -> pathlib.Path: path = ShareStorageManager.get_coordinator_conversation_path(share_id) write_model(path, conversation) return path @@ -177,7 +171,7 @@ def append_coordinator_message( """ conversation = ShareStorage.read_coordinator_conversation(share_id) if not conversation: - conversation = CoordinatorConversationStorage(knowledge_share_id=share_id) + conversation = CoordinatorConversationMessages(knowledge_share_id=share_id) new_message = CoordinatorConversationMessage( message_id=message_id, @@ -257,18 +251,7 @@ def write_information_request(share_id: str, request: InformationRequest) -> pat return ShareStorage.write_share(share_id, package) - @staticmethod - def read_share(share_id: str) -> Optional[KnowledgePackage]: - """Reads the complete KnowledgePackage data.""" - path = ShareStorageManager.get_share_path(share_id) - return read_model(path, KnowledgePackage) - @staticmethod - def write_share(share_id: str, package: KnowledgePackage) -> pathlib.Path: - """Writes the complete KnowledgePackage data.""" - path = ShareStorageManager.get_share_path(share_id) - write_model(path, package) - return path @staticmethod def get_all_information_requests(share_id: str) -> List[InformationRequest]: diff --git a/assistants/knowledge-transfer-assistant/assistant/storage_models.py b/assistants/knowledge-transfer-assistant/assistant/storage_models.py deleted file mode 100644 index 28f2ccc00..000000000 --- a/assistants/knowledge-transfer-assistant/assistant/storage_models.py +++ /dev/null @@ -1,43 +0,0 @@ -""" -Models for knowledge transfer share storage entities. - -This module contains data models specific to storage operations, -separate from the core share data models. -""" - -from datetime import datetime -from enum import Enum -from typing import List - -from pydantic import BaseModel, Field - - -class ConversationRole(str, Enum): - """ - Enumeration of conversation roles in a knowledge transfer. - - This enum represents the role that a conversation plays in a knowledge transfer, - either as a Coordinator (managing the knowledge transfer) or as a Team member - (participating in the knowledge transfer). - """ - - COORDINATOR = "coordinator" - TEAM = "team" - - -class CoordinatorConversationMessage(BaseModel): - """Model for storing a message from Coordinator conversation for Team access.""" - - message_id: str - content: str - sender_name: str - timestamp: datetime = Field(default_factory=datetime.utcnow) - is_assistant: bool = False - - -class CoordinatorConversationStorage(BaseModel): - """Model for storing a collection of Coordinator conversation messages.""" - - knowledge_share_id: str - last_updated: datetime = Field(default_factory=datetime.utcnow) - messages: List[CoordinatorConversationMessage] = Field(default_factory=list) diff --git a/assistants/knowledge-transfer-assistant/assistant/text_includes/coordinator_instructions.txt b/assistants/knowledge-transfer-assistant/assistant/text_includes/coordinator_instructions.txt index 23b48795d..560a95b20 100644 --- a/assistants/knowledge-transfer-assistant/assistant/text_includes/coordinator_instructions.txt +++ b/assistants/knowledge-transfer-assistant/assistant/text_includes/coordinator_instructions.txt @@ -40,33 +40,35 @@ ### Package Status and Flow - If the package is missing key components (audience, brief, or required objectives/outcomes), guide the user to complete those. -- If the package is ready for transfer, provide the invitation link and assist in sharing it with an appropriate message. +- If the package is ready for transfer, provide the invitation link and assist in sharing it with an appropriate message tailored to the audience. -### Suggested Conversation Flow +### Conversation Flow -Allow the user to drive the interaction. However, your responsibility is to ensure that all essential knowledge package components are created, refined, and shared +Allow the user to drive the interaction. However, your responsibility is to ensure that all essential knowledge package components are created, refined, and shared. -This recommended flow aligns with the knowledge transfer lifecycle and the system’s internal readiness checks: +This flow aligns with the knowledge transfer lifecycle and the system’s internal readiness checks: 1. **Audience Definition** + Help the user describe who the knowledge is for and their level of experience. -2. **Knowledge Content** - Assist the user in uploading and/or describing the knowledge to be shared. +2. **Learning Objectives (Optional)** + + - Ask if the user wants to define specific learning objectives and outcomes for their team. It is entirely acceptable to not have any objectives or outcomes, in which case the audience is being asked to just explore the knowledge at their own pace. + - If yes, help create them and ensure that each objective includes at least one outcome. + - If no, confirm that the package is intended for general exploration. + +3. **Knowledge Content** -3. **Knowledge Brief** + Assist the user in uploading and/or describing the knowledge to be shared. The knowledge content can include attached files and anything the user adds to the conversation. Help the user fill in gaps in the knowledge content as needed. Everything the intended audience needs to know should be included in the knowledge content. + +4. **Knowledge Brief** - Guide the creation of an overview of the knowledge share that will be available to team members to see alongside their session. - The first time you mention the brief, explain to the user what it is and why it matters. - When talking about the brief, there is no need to explain its contents. The user can see it in their side panel. - A brief should not include learning objectives or outcomes, as these are separate components. -4. **Learning Objectives (Optional)** - - - Ask if the user wants to define specific learning objectives and outcomes for their team. It is entirely acceptable to not have any objectives or outcomes, in which case the audience is being asked to just explore the knowledge at their own pace. - - If yes, help create them and ensure that each objective includes at least one outcome. - - If no, confirm that the package is intended for general exploration. - 5. **Gap Analysis** - If learning objectives are defined, help the user ensure that the knowledge content covers all required outcomes. @@ -78,9 +80,9 @@ This recommended flow aligns with the knowledge transfer lifecycle and the syste - The message should be in the user's voice as they will copy and paste it into communication tools like Teams or email. - It shouldn't include all the details about the knowledge share, just a brief statement about what it is, why they should be interested to check it out, and the invitation link. - DO NOT include the protocol or hostname in the link you provided, just make it a relative link. Examples: - - /conversation-share/a5b400d4-b8c4-4484-ae83-dad98fe49b06/redeem - - /conversation-share/12345678-1234-1234-1234-123456789012/redeem - - /conversation-share/abcdef12-3456-7890-abcd-ef1234567890/redeem + - [New project knowledge share-out](/conversation-share/a5b400d4-b8c4-4484-ae83-dad98fe49b06/redeem) + - [Learning about MADE](/conversation-share/12345678-1234-1234-1234-123456789012/redeem) + - [Our Team Knowledge Base](/conversation-share/abcdef12-3456-7890-abcd-ef1234567890/redeem) After the knowledge package is shared: @@ -89,8 +91,8 @@ After the knowledge package is shared: ### Post-Transfer Support - Once shared, help the user: +Once shared, help the user: - - Respond to new team member information requests. - - Monitor and update objectives, outcomes, or brief content. - - Track team prog +- Respond to new team member information requests. +- Monitor and update objectives, outcomes, or brief content. +- Track team progress against learning objectives. diff --git a/assistants/knowledge-transfer-assistant/assistant/text_includes/coordinator_role.txt b/assistants/knowledge-transfer-assistant/assistant/text_includes/coordinator_role.txt index 03dc252b0..07956b7e7 100644 --- a/assistants/knowledge-transfer-assistant/assistant/text_includes/coordinator_role.txt +++ b/assistants/knowledge-transfer-assistant/assistant/text_includes/coordinator_role.txt @@ -1,13 +1,19 @@ # Role and Objective -You are a strategic assistant that supports a user (the "coordinator") in creating and refining a knowledge package that will be shared with team members. Act on user needs using available tools. Take action rather than suggesting it. +You are an autonomous AI assistant named the "Knowledge Transfer Assistant" that supports a user in creating and refining a knowledge package that will be shared with team members. -Your purpose is to help the user assemble and organize the core components of the package, based on the knowledge transfer lifecycle. These components include: +Please keep going until the user's query is completely resolved, before ending your turn and yielding back to the user. Only terminate your turn when you are sure that the problem is solved. + +If you are not sure about attachment content or knowledge package structure pertaining to the user’s request, gather the relevant information: do NOT guess or make up an answer. + +You MUST plan extensively before each function call, and reflect extensively on the outcomes of the previous function calls. DO NOT do this entire process by making function calls only, as this can impair your ability to solve the problem and think insightfully. + +Your purpose is to help the user assemble and organize the knowledge package. These package includes: - **Audience Definition**: A clear explanation of who the knowledge is for, including their context and expertise level. -- **Knowledge Content**: Files and descriptions that capture the subject matter to be shared. -- **Knowledge Brief**: An introductory overview that team members see first. It orients them to the content and explains why it matters. Don't worry about making the brief short. Suggest a concise yet comprehensive overview of the shared knowledge. +- **Knowledge Content**: File attachments and descriptions that capture the subject matter to be shared. +- **Knowledge Brief**: An introductory overview of the knowledge package for team members. It orients them to the content and explains why it matters. Don't worry about making the brief short. Suggest a simply worded, yet comprehensive overview of the shared knowledge. - **Learning Objectives and Outcomes (optional)**: If the user wants the team to achieve specific goals, help them define objectives and outcomes to track learning success. - **Invitation Link**: A shareable link to distribute the knowledge package, with a clear message and customized anchor text. -You are responsible for guiding the user through each phase of knowledge organization and transfer. After the knowledge package is shared, you continue supporting the user by helping respond to information requests and updating the package as needed. +You are responsible for guiding the user through the knowledge transfer. After the knowledge package is shared, continue supporting the user by helping respond to information requests and updating the package as needed. diff --git a/assistants/knowledge-transfer-assistant/assistant/tools/__init__.py b/assistants/knowledge-transfer-assistant/assistant/tools/__init__.py index 523772549..398a58c15 100644 --- a/assistants/knowledge-transfer-assistant/assistant/tools/__init__.py +++ b/assistants/knowledge-transfer-assistant/assistant/tools/__init__.py @@ -8,7 +8,7 @@ from openai_client.tools import ToolFunctions from semantic_workbench_assistant.assistant_app import ConversationContext -from ..storage_models import ConversationRole +from ..data import ConversationRole from .information_requests import InformationRequestTools from .learning_objectives import LearningObjectiveTools from .learning_outcomes import LearningOutcomeTools diff --git a/assistants/knowledge-transfer-assistant/assistant/tools/base.py b/assistants/knowledge-transfer-assistant/assistant/tools/base.py index c53cadaee..6e732a506 100644 --- a/assistants/knowledge-transfer-assistant/assistant/tools/base.py +++ b/assistants/knowledge-transfer-assistant/assistant/tools/base.py @@ -4,7 +4,7 @@ from semantic_workbench_assistant.assistant_app import ConversationContext -from assistant.storage_models import ConversationRole +from assistant.data import ConversationRole class ToolsBase: diff --git a/assistants/knowledge-transfer-assistant/assistant/tools/information_requests.py b/assistants/knowledge-transfer-assistant/assistant/tools/information_requests.py index ee307854e..461bfff7e 100644 --- a/assistants/knowledge-transfer-assistant/assistant/tools/information_requests.py +++ b/assistants/knowledge-transfer-assistant/assistant/tools/information_requests.py @@ -9,7 +9,7 @@ from assistant.data import RequestPriority from assistant.logging import logger from assistant.domain import KnowledgeTransferManager -from assistant.storage_models import ConversationRole +from assistant.data import ConversationRole from .base import ToolsBase diff --git a/assistants/knowledge-transfer-assistant/assistant/tools/learning_objectives.py b/assistants/knowledge-transfer-assistant/assistant/tools/learning_objectives.py index 5aab1b079..6a4844e48 100644 --- a/assistants/knowledge-transfer-assistant/assistant/tools/learning_objectives.py +++ b/assistants/knowledge-transfer-assistant/assistant/tools/learning_objectives.py @@ -7,7 +7,7 @@ from typing import List from assistant.domain import LearningObjectivesManager -from ..storage_models import ConversationRole +from ..data import ConversationRole from .base import ToolsBase diff --git a/assistants/knowledge-transfer-assistant/assistant/tools/learning_outcomes.py b/assistants/knowledge-transfer-assistant/assistant/tools/learning_outcomes.py index c9b4d9c45..5be53312b 100644 --- a/assistants/knowledge-transfer-assistant/assistant/tools/learning_outcomes.py +++ b/assistants/knowledge-transfer-assistant/assistant/tools/learning_outcomes.py @@ -5,7 +5,7 @@ """ from assistant.domain import LearningObjectivesManager -from assistant.storage_models import ConversationRole +from assistant.data import ConversationRole from .base import ToolsBase diff --git a/assistants/knowledge-transfer-assistant/assistant/tools/progress_tracking.py b/assistants/knowledge-transfer-assistant/assistant/tools/progress_tracking.py index 9257cd277..ba07621b5 100644 --- a/assistants/knowledge-transfer-assistant/assistant/tools/progress_tracking.py +++ b/assistants/knowledge-transfer-assistant/assistant/tools/progress_tracking.py @@ -13,9 +13,10 @@ from ..data import InspectorTab, LearningOutcomeAchievement, LogEntryType from assistant.domain import KnowledgeTransferManager +from assistant.domain.knowledge_package_manager import KnowledgePackageManager from assistant.notifications import Notifications from assistant.storage import ShareStorage -from assistant.storage_models import ConversationRole +from assistant.data import ConversationRole from .base import ToolsBase @@ -35,11 +36,8 @@ async def mark_learning_outcome_achieved(self, objective_index: int, criterion_i Each completed outcome moves the knowledge transfer closer to completion. When all outcomes are achieved, the transfer can be marked as complete. - IMPORTANT: Always use get_share_info() first to see the current objectives, outcomes, and their indices - before marking anything as complete. - Args: - objective_index: The index of the objective (0-based integer) from get_share_info() output + objective_index: The index of the objective (0-based integer) criterion_index: The index of the outcome within the objective (0-based integer) Returns: @@ -80,7 +78,7 @@ async def mark_learning_outcome_achieved(self, objective_index: int, criterion_i conversation_id = str(self.context.id) # Check if already achieved by this conversation - if knowledge_package.is_outcome_achieved_by_conversation(outcome.id, conversation_id): + if KnowledgePackageManager.is_outcome_achieved_by_conversation(knowledge_package, outcome.id, conversation_id): return f"Outcome '{outcome.description}' is already marked as achieved by this team member." # Get current user information @@ -151,7 +149,7 @@ async def mark_learning_outcome_achieved(self, objective_index: int, criterion_i # Check if all outcomes are achieved for transfer completion # Get the knowledge package to check completion status knowledge_package = ShareStorage.read_share(share_id) - if knowledge_package and knowledge_package._is_transfer_complete(): + if knowledge_package and KnowledgePackageManager._is_transfer_complete(knowledge_package): await self.context.send_messages( NewConversationMessage( content="🎉 All learning outcomes have been achieved! The knowledge transfer has been automatically marked as complete.", @@ -198,7 +196,7 @@ async def report_transfer_completion(self) -> str: return "No knowledge package found. Cannot complete transfer without package information." # Check if all outcomes are achieved - achieved_outcomes, total_outcomes = package.get_overall_completion() + achieved_outcomes, total_outcomes = KnowledgePackageManager.get_overall_completion(package) if achieved_outcomes < total_outcomes: remaining = total_outcomes - achieved_outcomes return f"Cannot complete knowledge transfer - {remaining} learning outcomes are still pending achievement." diff --git a/assistants/knowledge-transfer-assistant/assistant/tools/share_setup.py b/assistants/knowledge-transfer-assistant/assistant/tools/share_setup.py index 31a3095d6..f8da6f0cc 100644 --- a/assistants/knowledge-transfer-assistant/assistant/tools/share_setup.py +++ b/assistants/knowledge-transfer-assistant/assistant/tools/share_setup.py @@ -8,7 +8,7 @@ from assistant.domain import KnowledgeTransferManager from assistant.storage import ShareStorage -from assistant.storage_models import ConversationRole +from assistant.data import ConversationRole from .base import ToolsBase diff --git a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/brief.py b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/brief.py index 13ff2708e..dffb5b67c 100644 --- a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/brief.py +++ b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/brief.py @@ -10,9 +10,10 @@ ) from assistant.common import detect_assistant_role -from assistant.conversation_share_link import ConversationKnowledgePackageManager +from assistant.domain.share_manager import ShareManager from assistant.domain import KnowledgeTransferManager -from assistant.storage_models import ConversationRole +from assistant.data import ConversationRole +from .common import get_stage_label # Default instructional text to show when no brief has been created @@ -42,14 +43,14 @@ async def get(self, context: ConversationContext) -> AssistantConversationInspec conversation_role = await detect_assistant_role(context) # Get share information - share_id = await ConversationKnowledgePackageManager.get_associated_share_id(context) + share_id = await ShareManager.get_associated_share_id(context) if not share_id: return AssistantConversationInspectorStateDataModel( data={"content": "No active knowledge package. Start a conversation to create one."} ) brief = await KnowledgeTransferManager.get_knowledge_brief(context) - share_info = await KnowledgeTransferManager.get_share_info(context) + share_info = await KnowledgeTransferManager.get_share(context) if conversation_role == ConversationRole.COORDINATOR: markdown = await self._format_coordinator_brief(share_id, brief, share_info, context) @@ -70,7 +71,7 @@ async def _format_coordinator_brief( # Display knowledge transfer stage stage_label = "📋 Organizing Knowledge" if share_info: - stage_label = share_info.get_stage_label(for_coordinator=True) + stage_label = get_stage_label(share_info, for_coordinator=True) lines.append(f"**Stage:** {stage_label}") if share_info and share_info.transfer_notes: @@ -106,7 +107,7 @@ async def _format_team_brief(self, share_id: str, brief: Any, share_info: Any, c # Display knowledge transfer stage for team members stage_label = "📚 Learning Mode" if share_info: - stage_label = share_info.get_stage_label(for_coordinator=False) + stage_label = get_stage_label(share_info, for_coordinator=False) lines.append(f"**Stage:** {stage_label}") # Add status message if available diff --git a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/common.py b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/common.py index 9a674579b..eb66c5a1d 100644 --- a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/common.py +++ b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/common.py @@ -2,7 +2,8 @@ Common utilities for inspector modules. """ -from assistant.data import RequestPriority, RequestStatus +from assistant.data import KnowledgePackage, RequestPriority, RequestStatus +from assistant.domain.knowledge_package_manager import KnowledgePackageManager def get_status_emoji(status: RequestStatus) -> str: @@ -26,3 +27,49 @@ def get_priority_emoji(priority: RequestPriority) -> str: RequestPriority.CRITICAL: "⚠️", # Critical priority - warning sign } return priority_emojis.get(priority, "🔹") # Default to low priority emoji + + +def get_stage_label(package: KnowledgePackage, for_coordinator: bool = True) -> str: + """ + Get a human-readable stage label based on current package state. + + Args: + package: The knowledge package to get label for + for_coordinator: Whether to return coordinator-focused or team-focused labels + + Returns: + str: Stage label with emoji + """ + if package.archived: + return "📦 Archived" + + if for_coordinator: + # Coordinator perspective + if not package.audience: + return "🎯 Defining Audience" + elif not package.knowledge_organized: + return "📋 Organizing Knowledge" + elif not package.brief: + return "📝 Creating Brief" + elif package.is_intended_to_accomplish_outcomes and not package.learning_objectives: + return "📚 Adding Objectives" + elif not KnowledgePackageManager.is_ready_for_transfer(package): + return "📋 Finalizing Setup" + elif package.is_intended_to_accomplish_outcomes and KnowledgePackageManager._is_transfer_complete(package): + return "✅ Transfer Complete" + elif KnowledgePackageManager.is_actively_sharing(package): + return "📤 Sharing in Progress" + else: + return "🚀 Ready for Transfer" + else: + # Team perspective + if package.archived: + return "📦 Archived" + elif not KnowledgePackageManager.is_ready_for_transfer(package): + return "⏳ Knowledge Being Organized" + elif not package.is_intended_to_accomplish_outcomes: + return "🔍 Exploring Knowledge" + elif package.is_intended_to_accomplish_outcomes: + return "🎯 Active Learning" + else: + return "🎯 Active Learning" diff --git a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/debug.py b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/debug.py index 9657fed9f..49ffd20e3 100644 --- a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/debug.py +++ b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/debug.py @@ -9,8 +9,9 @@ ConversationContext, ) -from assistant.conversation_share_link import ConversationKnowledgePackageManager +from assistant.domain.share_manager import ShareManager from assistant.domain import KnowledgeTransferManager +from assistant.domain.knowledge_package_manager import KnowledgePackageManager from assistant.storage import ShareStorage @@ -35,7 +36,7 @@ async def get(self, context: ConversationContext) -> AssistantConversationInspec """Get debug information for display.""" # Get share information - share_id = await ConversationKnowledgePackageManager.get_associated_share_id(context) + share_id = await ShareManager.get_associated_share_id(context) if not share_id: return AssistantConversationInspectorStateDataModel( data={"content": "No active knowledge package. Start a conversation to create one."} @@ -100,8 +101,8 @@ async def _format_debug_info(self, share_id: str, context: ConversationContext) f"- **Learning Objectives:** {len(share.learning_objectives) if share.learning_objectives else 0}" ) lines.append(f"- **Knowledge Organized:** {share.knowledge_organized}") - lines.append(f"- **Ready for Transfer:** {share.is_ready_for_transfer()}") - lines.append(f"- **Actively Sharing:** {share.is_actively_sharing()}") + lines.append(f"- **Ready for Transfer:** {KnowledgePackageManager.is_ready_for_transfer(share)}") + lines.append(f"- **Actively Sharing:** {KnowledgePackageManager.is_actively_sharing(share)}") if share.coordinator_conversation_id: lines.append(f"- **Conversation ID:** `{share.coordinator_conversation_id}`") lines.append("") diff --git a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/learning.py b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/learning.py index fc756dae1..5ac1371f2 100644 --- a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/learning.py +++ b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/learning.py @@ -10,9 +10,10 @@ ) from assistant.common import detect_assistant_role -from assistant.conversation_share_link import ConversationKnowledgePackageManager +from assistant.domain.share_manager import ShareManager +from assistant.domain.knowledge_package_manager import KnowledgePackageManager from assistant.storage import ShareStorage -from assistant.storage_models import ConversationRole +from assistant.data import ConversationRole class LearningInspector: @@ -38,7 +39,7 @@ async def get(self, context: ConversationContext) -> AssistantConversationInspec conversation_role = await detect_assistant_role(context) # Get share information - share_id = await ConversationKnowledgePackageManager.get_associated_share_id(context) + share_id = await ShareManager.get_associated_share_id(context) if not share_id: return AssistantConversationInspectorStateDataModel( data={"content": "No active knowledge package. Start a conversation to create one."} @@ -74,7 +75,7 @@ async def _format_coordinator_objectives(self, share: Any, context: Conversation total_outcomes = sum(len(obj.learning_outcomes) for obj in share.learning_objectives if obj.learning_outcomes) if total_outcomes > 0 and share.team_conversations: for conv_id, team_conv in share.team_conversations.items(): - achieved, total = share.get_completion_for_conversation(conv_id) + achieved, total = KnowledgePackageManager.get_completion_for_conversation(share, conv_id) progress_pct = int((achieved / total * 100)) if total > 0 else 0 lines.append(f"- **{team_conv.redeemer_name}**: {achieved}/{total} outcomes ({progress_pct}%)") lines.append("") @@ -91,7 +92,7 @@ async def _format_coordinator_objectives(self, share: Any, context: Conversation for criterion in objective.learning_outcomes: # Check if any team conversation has achieved this outcome achieved_by_any = any( - share.is_outcome_achieved_by_conversation(criterion.id, conv_id) + KnowledgePackageManager.is_outcome_achieved_by_conversation(share, criterion.id, conv_id) for conv_id in share.team_conversations.keys() ) status_emoji = "✅" if achieved_by_any else "⬜" @@ -101,7 +102,7 @@ async def _format_coordinator_objectives(self, share: Any, context: Conversation total_team_count = len(share.team_conversations) for conv_id in share.team_conversations.keys(): - if share.is_outcome_achieved_by_conversation(criterion.id, conv_id): + if KnowledgePackageManager.is_outcome_achieved_by_conversation(share, criterion.id, conv_id): achieved_count += 1 achievement_info = "" @@ -132,7 +133,9 @@ async def _format_team_objectives(self, share: Any, context: ConversationContext # Show my personal progress conversation_id = str(context.id) - achieved_outcomes, total_outcomes = share.get_completion_for_conversation(conversation_id) + achieved_outcomes, total_outcomes = KnowledgePackageManager.get_completion_for_conversation( + share, conversation_id + ) progress_pct = int((achieved_outcomes / total_outcomes * 100)) if total_outcomes > 0 else 0 lines.append(f"**My Progress:** {achieved_outcomes}/{total_outcomes} outcomes achieved ({progress_pct}%)") lines.append("") @@ -146,13 +149,17 @@ async def _format_team_objectives(self, share: Any, context: ConversationContext lines.append("#### Learning Outcomes") for criterion in objective.learning_outcomes: # Check if I've achieved this outcome - achieved_by_me = share.is_outcome_achieved_by_conversation(criterion.id, conversation_id) + achieved_by_me = KnowledgePackageManager.is_outcome_achieved_by_conversation( + share, criterion.id, conversation_id + ) status_emoji = "✅" if achieved_by_me else "⬜" completion_info = "" if achieved_by_me: # Find my achievement record - my_achievements = share.get_achievements_for_conversation(conversation_id) + my_achievements = KnowledgePackageManager.get_achievements_for_conversation( + share, conversation_id + ) for achievement in my_achievements: if achievement.outcome_id == criterion.id and achievement.achieved: completion_info = f" (achieved on {achievement.achieved_at.strftime('%Y-%m-%d')})" diff --git a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/sharing.py b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/sharing.py index f2c2b3342..a4acd8662 100644 --- a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/sharing.py +++ b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/sharing.py @@ -10,11 +10,11 @@ ) from assistant.common import detect_assistant_role -from assistant.conversation_share_link import ConversationKnowledgePackageManager +from assistant.domain.share_manager import ShareManager from assistant.data import RequestStatus from assistant.domain import KnowledgeTransferManager -from assistant.storage import ShareStorage -from assistant.storage_models import ConversationRole +from assistant.domain.knowledge_package_manager import KnowledgePackageManager +from assistant.data import ConversationRole from .common import get_priority_emoji, get_status_emoji @@ -41,7 +41,7 @@ async def get(self, context: ConversationContext) -> AssistantConversationInspec conversation_role = await detect_assistant_role(context) # Get share information - share_id = await ConversationKnowledgePackageManager.get_associated_share_id(context) + share_id = await ShareManager.get_associated_share_id(context) if not share_id: return AssistantConversationInspectorStateDataModel( data={"content": "No active knowledge package. Start a conversation to create one."} @@ -61,12 +61,9 @@ async def _format_coordinator_requests(self, requests: List[Any], context: Conve lines: List[str] = [] - # Get share information first - share_id = await ConversationKnowledgePackageManager.get_associated_share_id(context) - # Share URL section at the top - share_info = await KnowledgeTransferManager.get_share_info(context, share_id) - share_url = share_info.share_url if share_info else None + share = await KnowledgeTransferManager.get_share(context) + share_url = share.share_url if share else None if share_url: lines.append("## Share Link") lines.append("") @@ -112,17 +109,13 @@ async def _format_coordinator_requests(self, requests: List[Any], context: Conve lines.append("_Team members can ask questions and create information requests that will appear here._") # Team summary - if share_id: - share = ShareStorage.read_share(share_id) - else: - share = None if share and share.team_conversations: lines.append("## Team Summary") lines.append(f"**Active team members:** {len(share.team_conversations)}") lines.append("") for conv_id, team_conv in share.team_conversations.items(): - achieved, total = share.get_completion_for_conversation(conv_id) + achieved, total = KnowledgePackageManager.get_completion_for_conversation(share, conv_id) progress_pct = int((achieved / total * 100)) if total > 0 else 0 lines.append(f"- **{team_conv.redeemer_name}**: {achieved}/{total} outcomes ({progress_pct}%)") lines.append(f" Joined: {team_conv.joined_at.strftime('%Y-%m-%d %H:%M')}") diff --git a/assistants/knowledge-transfer-assistant/docs/design/design.md b/assistants/knowledge-transfer-assistant/docs/design/design.md new file mode 100644 index 000000000..17642c05d --- /dev/null +++ b/assistants/knowledge-transfer-assistant/docs/design/design.md @@ -0,0 +1,131 @@ +# Knowledge Transfer + +## Clear separation of state + +- Conversation state: messages and attachments +- Knowledge Base meta for the assistant to guide the producer conversation + - Audience + - takeaways + - Objectives and outcomes + - Gaps +- Knowledge Base + - Digest + - Brief + - Files + - Facts + +## Producer assistance + +- Active listening +- Stage aware (audience, takeaways, kb prep, objectives/outcomes, brief, share msg) +- Use multi-agent feedback (ultra-think, panel of experts); especially on kb prep +- Produces KB + - Use grounding eval against all generated docs. + - Make kb docs visible for user modification. + +## Semantics vs Style + +### Semantics Approaches + +Fact lists + +Concepts + +Markdown (trees) + +## Knowledge Base Approaches + +Documents + +Trees + +Graphs + +## Producer Assistance + + + +## Learning Styles + +Maintaining preferences of the learner, such as: + +- visual or concrete examples +- pacing +- academic or plain-speaking +- open exploration or guided (preferred learning modes) + +## Learning Modes + +### Explorer + +The knowledge base is explored by the consumer asking the assistant to help in whichever way the consumer would like. + +### Self-guided Curriculum + +Learning objectives and outcomes are set by the producer. The progression is suggested, but the learner can go in any order. The system tracks when outcomes have been achieved. + +### Interviewee + +The producer/assistant creates a list of concepts or topics that the user will be taken through, progressing through Bloom's Taxonomy for each. + +## Issues + +Fluff + +Style. + +How is this better than one long doc? + +## Other things to try + +- Using Bloom's taxonomy to instruct the assistant for what it should do on the next turn. +- Scoring the user's taxonomy level on a given topic. +- Breaking larger corpus into lists of topics. +- Engaged/Frustrated/Confused detection. +- Engaged/Frustrated response adaptation. +- Learning style adaptation (concrete examples vs visual diagrams) +- Learner pace preference adaptation (faster/slower) +- Topic prerequisite detection (detecting which topics should be covered first) +- Response pattern rotation + + ## Related Research + + You’ve hit on a fundamental issue! The pre-AI-winter symbolic AI folks absolutely did make this distinction - they saw content/information as separable from its presentation/style. This shows up in several key areas of literature: + +Classical AI/Knowledge Representation: + +- Minsky’s Frames (1974) - attempted to separate semantic content from surface realization +- Schank’s Conceptual Dependency Theory - tried to represent meaning independent of linguistic form +- Knowledge Interchange Format (KIF) - explicitly designed to separate logical content from syntactic presentation + +Linguistics/Philosophy: + +- Frege’s sense/reference distinction - the foundational idea that meaning exists apart from expression +- Chomsky’s deep structure vs. surface structure - competence vs. performance +- Grice’s work on implicature - what’s said vs. what’s meant + +Modern tensions: + +The “distributional hypothesis” that underlies modern NLP (“you shall know a word by the company it keeps”) fundamentally entangles meaning with usage patterns. Some relevant recent work: + +- “Language Models are Few-Shot Learners” (GPT-3 paper) shows style and content are learned together +- Work on disentangled representations (like style transfer research) tries to separate these again +- Prompt engineering literature increasingly recognizes that HOW you ask affects WHAT you get + +The irony is that the old symbolic approaches failed partly because they couldn’t handle the messiness of natural language, while modern approaches succeed by embracing that messiness - but lose the clean content/style separation in the process. + +The knowledge representation vs. connectionist debate has some fascinating historical documents. +https://arxiv.org/pdf/1703.00955 + +“Toward Controlled Generation of Text” by Hu et al. (ICML 2017) is a singular representative study that directly addresses the content/style disentanglement issue in neural language models. + +This paper is particularly relevant because it: + +1. Explicitly tackles disentanglement: The paper aims at generating plausible text sentences whose attributes are controlled by learning disentangled latent representations with designated semantics +1. Separates content from style: The model augments unstructured variables z with a set of structured variables c each of which targets a salient and independent semantic feature of sentences +1. Addresses the core problem: The authors note that varying individual code may result in unexpected variation of other unspecified attributes besides the desired one - exactly the entanglement problem you’re describing +1. Introduces explicit independence constraints: To ensure true disentanglement, they introduce the independency constraint which separates these attributes with c by enforcing them to be fully captured by the unstructured part z + +The paper demonstrates this separation empirically - when they control for sentiment (a “style” attribute), other aspects like subject matter and tone (the “content”) remain unchanged when their independence constraint is active. Without it, changing the style inadvertently changes the content as well. + +This work bridges the gap between the symbolic AI tradition (which had clean content/style separation but couldn’t handle natural language well) and modern neural approaches (which handle language well but entangle everything together). It’s a foundational paper in the area of controllable text generation that directly addresses your concern about the lack of distinction between information and style in LLMs. diff --git a/assistants/knowledge-transfer-assistant/knowledge-transfer-assistant.code-workspace b/assistants/knowledge-transfer-assistant/knowledge-transfer-assistant.code-workspace index d3b27486e..70645b445 100644 --- a/assistants/knowledge-transfer-assistant/knowledge-transfer-assistant.code-workspace +++ b/assistants/knowledge-transfer-assistant/knowledge-transfer-assistant.code-workspace @@ -105,6 +105,14 @@ ] }, "settings": { - "cSpell.words": ["agentic"] + "cSpell.words": ["agentic"], + "cSpell.customDictionaries": { + "custom-dictionary-workspace": { + "name": "custom-dictionary-workspace", + "path": "${workspaceFolder:docs}/.cspell/custom-dictionary-workspace.txt", + "addWords": true, + "scope": "workspace" + } + } } } diff --git a/assistants/knowledge-transfer-assistant/tests/test_artifact_loading.py b/assistants/knowledge-transfer-assistant/tests/test_artifact_loading.py index 4a7458f98..974b50f64 100644 --- a/assistants/knowledge-transfer-assistant/tests/test_artifact_loading.py +++ b/assistants/knowledge-transfer-assistant/tests/test_artifact_loading.py @@ -10,11 +10,11 @@ import uuid from typing import Any, TypeVar -from assistant.conversation_share_link import ConversationKnowledgePackageManager +from assistant.domain.share_manager import ShareManager from assistant.data import KnowledgeBrief, KnowledgePackage, LearningObjective, LearningOutcome from assistant.domain import KnowledgeTransferManager from assistant.storage import ShareStorage, ShareStorageManager -from assistant.storage_models import ConversationRole +from assistant.data import ConversationRole from semantic_workbench_assistant import settings # Type variable for better type annotations @@ -70,7 +70,7 @@ async def mock_get_associated_share_id(context): return self.share_id patch2 = unittest.mock.patch.object( - ConversationKnowledgePackageManager, "get_associated_share_id", side_effect=mock_get_associated_share_id + ShareManager, "get_associated_share_id", side_effect=mock_get_associated_share_id ) self.mock_get_project = patch2.start() self.patches.append(patch2) @@ -80,7 +80,7 @@ async def mock_get_conversation_role(context): return ConversationRole.COORDINATOR patch3 = unittest.mock.patch.object( - ConversationKnowledgePackageManager, "get_conversation_role", side_effect=mock_get_conversation_role + ShareManager, "get_conversation_role", side_effect=mock_get_conversation_role ) self.mock_get_role = patch3.start() self.patches.append(patch3) diff --git a/assistants/knowledge-transfer-assistant/tests/test_share_storage.py b/assistants/knowledge-transfer-assistant/tests/test_share_storage.py index de514a7ed..3322b6486 100644 --- a/assistants/knowledge-transfer-assistant/tests/test_share_storage.py +++ b/assistants/knowledge-transfer-assistant/tests/test_share_storage.py @@ -9,7 +9,7 @@ import uuid from datetime import datetime -from assistant.conversation_share_link import ConversationKnowledgePackageManager +from assistant.domain.share_manager import ShareManager from assistant.data import ( InformationRequest, InspectorTab, @@ -24,12 +24,13 @@ RequestPriority, RequestStatus, ) +from assistant.domain.knowledge_package_manager import KnowledgePackageManager from assistant.notifications import Notifications from assistant.storage import ShareStorage, ShareStorageManager -from assistant.storage_models import ( +from assistant.data import ( ConversationRole, CoordinatorConversationMessage, - CoordinatorConversationStorage, + CoordinatorConversationMessages, ) from semantic_workbench_api_model.workbench_model import AssistantStateEvent from semantic_workbench_assistant import settings @@ -252,7 +253,7 @@ async def test_coordinator_conversation_storage(self): ), ] - conv_storage = CoordinatorConversationStorage( + conv_storage = CoordinatorConversationMessages( knowledge_share_id=self.share_id, messages=messages, ) @@ -415,31 +416,32 @@ async def test_conversation_tracking_in_json(self): self.assertIsInstance(package.team_conversations, dict) # Verify helper methods work - linked_conversations = package.get_all_linked_conversations() + linked_conversations = KnowledgePackageManager.get_all_linked_conversations(package) self.assertIsInstance(linked_conversations, list) - notification_conversations = package.get_notification_conversations() + notification_conversations = KnowledgePackageManager.get_notification_conversations(package) self.assertIsInstance(notification_conversations, list) async def test_conversation_association(self): - """Test conversation association with project.""" - # Mock ConversationKnowledgePackageManager.associate_conversation_with_share - with unittest.mock.patch("assistant.conversation_share_link.write_model") as mock_write_model: - # Mock conversation project path - conversation_project_file = ShareStorageManager.get_conversation_share_file_path(self.context) + """Test conversation role setting.""" + # Mock ShareManager.set_conversation_role + with unittest.mock.patch("assistant.domain.share_manager.write_model") as mock_write_model: + # Mock conversation role path + conversation_role_file = ShareStorageManager.get_conversation_role_file_path(self.context) - # Call associate_conversation_with_share - await ConversationKnowledgePackageManager.associate_conversation_with_share(self.context, self.share_id) + # Call set_conversation_role + await ShareManager.set_conversation_role(self.context, self.share_id, ConversationRole.COORDINATOR) # Verify write_model was called mock_write_model.assert_called_once() # Verify the file path in the call call_args = mock_write_model.call_args[0] - self.assertEqual(call_args[0], conversation_project_file) + self.assertEqual(call_args[0], conversation_role_file) - # Verify the ProjectAssociation object created + # Verify the ConversationShareInfo object created self.assertEqual(call_args[1].share_id, self.share_id) + self.assertEqual(call_args[1].role, ConversationRole.COORDINATOR) async def test_log_project_event(self): """Test logging a project event.""" diff --git a/assistants/knowledge-transfer-assistant/tests/test_share_tools.py b/assistants/knowledge-transfer-assistant/tests/test_share_tools.py index f99f1020b..f322d1ee9 100644 --- a/assistants/knowledge-transfer-assistant/tests/test_share_tools.py +++ b/assistants/knowledge-transfer-assistant/tests/test_share_tools.py @@ -8,7 +8,7 @@ import openai_client import pytest from assistant.agentic.analysis import detect_information_request_needs -from assistant.storage_models import ConversationRole +from assistant.data import ConversationRole from assistant.tools import ShareTools from semantic_workbench_assistant.assistant_app import ConversationContext diff --git a/docs/.cspell/custom-dictionary-workspace.txt b/docs/.cspell/custom-dictionary-workspace.txt new file mode 100644 index 000000000..255951c23 --- /dev/null +++ b/docs/.cspell/custom-dictionary-workspace.txt @@ -0,0 +1 @@ +agentic From 071966dff7a14c87c0b80aa3fbc34a439e5a158b Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Mon, 28 Jul 2025 16:29:44 -0700 Subject: [PATCH 02/21] Removes index-based outcomes in favor of UUID. --- .../assistant/tools/progress_tracking.py | 39 +++++++++++-------- .../tests/test_share_storage.py | 6 +-- 2 files changed, 26 insertions(+), 19 deletions(-) diff --git a/assistants/knowledge-transfer-assistant/assistant/tools/progress_tracking.py b/assistants/knowledge-transfer-assistant/assistant/tools/progress_tracking.py index ba07621b5..15fd0628e 100644 --- a/assistants/knowledge-transfer-assistant/assistant/tools/progress_tracking.py +++ b/assistants/knowledge-transfer-assistant/assistant/tools/progress_tracking.py @@ -23,7 +23,7 @@ class ProgressTrackingTools(ToolsBase): """Tools for tracking learning progress.""" - async def mark_learning_outcome_achieved(self, objective_index: int, criterion_index: int) -> str: + async def mark_learning_outcome_achieved(self, objective_id: str, outcome_id: str) -> str: """ Mark a learning outcome as achieved for tracking knowledge transfer progress. @@ -37,8 +37,8 @@ async def mark_learning_outcome_achieved(self, objective_index: int, criterion_i are achieved, the transfer can be marked as complete. Args: - objective_index: The index of the objective (0-based integer) - criterion_index: The index of the outcome within the objective (0-based integer) + objective_id: The UUID of the learning objective + outcome_id: The UUID of the learning outcome within the objective Returns: A message indicating success or failure @@ -57,24 +57,31 @@ async def mark_learning_outcome_achieved(self, objective_index: int, criterion_i if not brief: return "No knowledge brief found." - # Using 0-based indexing directly, no adjustment needed - # Get the knowledge package to access objectives knowledge_package = ShareStorage.read_share(share_id) if not knowledge_package or not knowledge_package.learning_objectives: return "No learning objectives found." - # Validate indices - if objective_index < 0 or objective_index >= len(knowledge_package.learning_objectives): - return f"Invalid objective index {objective_index}. Valid indexes are 0 to {len(knowledge_package.learning_objectives) - 1}. There are {len(knowledge_package.learning_objectives)} objectives." - - objective = knowledge_package.learning_objectives[objective_index] - - if criterion_index < 0 or criterion_index >= len(objective.learning_outcomes): - return f"Invalid outcome index {criterion_index}. Valid indexes for objective '{objective.name}' are 0 to {len(objective.learning_outcomes) - 1}. Objective '{objective.name}' has {len(objective.learning_outcomes)} outcomes." - - # Update the outcome - outcome = objective.learning_outcomes[criterion_index] + # Find the objective by ID + objective = None + for obj in knowledge_package.learning_objectives: + if obj.id == objective_id: + objective = obj + break + + if not objective: + return f"Learning objective with ID '{objective_id}' not found." + + # Find the outcome by ID within the objective + outcome = None + for out in objective.learning_outcomes: + if out.id == outcome_id: + outcome = out + break + + if not outcome: + return f"Learning outcome with ID '{outcome_id}' not found in objective '{objective.name}'." + conversation_id = str(self.context.id) # Check if already achieved by this conversation diff --git a/assistants/knowledge-transfer-assistant/tests/test_share_storage.py b/assistants/knowledge-transfer-assistant/tests/test_share_storage.py index 3322b6486..97169ba90 100644 --- a/assistants/knowledge-transfer-assistant/tests/test_share_storage.py +++ b/assistants/knowledge-transfer-assistant/tests/test_share_storage.py @@ -380,7 +380,7 @@ async def test_refresh_current_ui(self): async def test_knowledge_package_info(self): """Test reading and writing knowledge package info.""" # Read existing knowledge package - package = ShareStorage.read_share_info(self.share_id) + package = ShareStorage.read_share(self.share_id) # Verify it was loaded correctly self.assertIsNotNone(package, "Should load knowledge package") @@ -393,10 +393,10 @@ async def test_knowledge_package_info(self): package.next_learning_actions = ["Action 1", "Action 2"] # Write updated knowledge package - ShareStorage.write_share_info(self.share_id, package) + ShareStorage.write_share(self.share_id, package) # Read updated knowledge package - updated_package = ShareStorage.read_share_info(self.share_id) + updated_package = ShareStorage.read_share(self.share_id) # Verify updates were saved self.assertIsNotNone(updated_package, "Should load updated knowledge package") From f451094382585f3b96165cd98d416512011bfb19 Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Mon, 28 Jul 2025 17:24:22 -0700 Subject: [PATCH 03/21] De-duplicate more methods (de-Clauding) --- .../assistant/assistant.py | 34 ++-- .../assistant/common.py | 93 +--------- .../assistant/data.py | 3 +- .../assistant/domain/share_manager.py | 164 ++++++++++-------- .../assistant/files.py | 3 +- .../assistant/respond.py | 145 ++++++++-------- .../assistant/storage.py | 3 +- .../assistant/tools/progress_tracking.py | 6 +- .../assistant/ui_tabs/brief.py | 5 +- .../assistant/ui_tabs/debug.py | 2 +- .../assistant/ui_tabs/learning.py | 5 +- .../assistant/ui_tabs/sharing.py | 5 +- .../tests/test_artifact_loading.py | 8 +- 13 files changed, 200 insertions(+), 276 deletions(-) diff --git a/assistants/knowledge-transfer-assistant/assistant/assistant.py b/assistants/knowledge-transfer-assistant/assistant/assistant.py index c22dd2280..bad4cb501 100644 --- a/assistants/knowledge-transfer-assistant/assistant/assistant.py +++ b/assistants/knowledge-transfer-assistant/assistant/assistant.py @@ -27,13 +27,13 @@ ) from .agentic.team_welcome import generate_team_welcome_message -from .common import detect_assistant_role, detect_conversation_type, get_shared_conversation_id, ConversationType from .config import assistant_config from .domain.share_manager import ShareManager from .data import InspectorTab, LogEntryType from .files import ShareFilesManager from .logging import logger from .domain import KnowledgeTransferManager +from .domain.share_manager import ShareManager from .notifications import Notifications from .respond import respond_to_conversation from .ui_tabs import BriefInspector, LearningInspector, SharingInspector, DebugInspector @@ -111,10 +111,10 @@ async def on_conversation_created(context: ConversationContext) -> None: share_id = conversation_metadata.get("share_id") config = await assistant_config.get(context.assistant) - conversation_type = detect_conversation_type(conversation) + conversation_type = await ShareManager.get_conversation_role(context) match conversation_type: - case ConversationType.SHAREABLE_TEMPLATE: + case ConversationRole.SHAREABLE_TEMPLATE: # Associate the shareable template with a share ID if not share_id: logger.error("No share ID found for shareable team conversation.") @@ -122,7 +122,7 @@ async def on_conversation_created(context: ConversationContext) -> None: await ShareManager.set_conversation_role(context, share_id, ConversationRole.SHAREABLE_TEMPLATE) return - case ConversationType.TEAM: + case ConversationRole.TEAM: if not share_id: logger.error("No share ID found for team conversation.") return @@ -161,7 +161,7 @@ async def on_conversation_created(context: ConversationContext) -> None: return - case ConversationType.COORDINATOR: + case ConversationRole.COORDINATOR: try: # In the beginning, we created a share... share_id = await KnowledgeTransferManager.create_share(context) @@ -202,11 +202,11 @@ async def on_conversation_updated(context: ConversationContext) -> None: """ try: conversation = await context.get_conversation() - conversation_type = detect_conversation_type(conversation) - if conversation_type != ConversationType.COORDINATOR: + conversation_type = await ShareManager.get_conversation_role(context) + if conversation_type != ConversationRole.COORDINATOR: return - shared_conversation_id = await get_shared_conversation_id(context) + shared_conversation_id = await ShareManager.get_shared_conversation_id(context) if not shared_conversation_id: return @@ -246,7 +246,7 @@ async def on_message_created( # If this is a Coordinator conversation, store the message for Team access async with context.set_status("jotting..."): - role = await detect_assistant_role(context) + role = await ShareManager.get_conversation_role(context) if role == ConversationRole.COORDINATOR and message.message_type == MessageType.chat: try: if share_id: @@ -353,7 +353,7 @@ async def on_file_created( logger.warning(f"No share ID found or missing filename: share_id={share_id}, filename={file.filename}") return - role = await detect_assistant_role(context) + role = await ShareManager.get_conversation_role(context) # Process based on role if role == ConversationRole.COORDINATOR: @@ -397,7 +397,7 @@ async def on_file_created( metadata={ "file_id": getattr(file, "id", ""), "filename": file.filename, - "is_coordinator_file": role.value == "coordinator", + "is_coordinator_file": role == ConversationRole.COORDINATOR, }, ) @@ -417,7 +417,7 @@ async def on_file_updated( if not share_id or not file.filename: return - role = await detect_assistant_role(context) + role = await ShareManager.get_conversation_role(context) if role == ConversationRole.COORDINATOR: # For Coordinator files: # 1. Update in share storage @@ -452,7 +452,7 @@ async def on_file_updated( metadata={ "file_id": getattr(file, "id", ""), "filename": file.filename, - "is_coordinator_file": role.value == "coordinator", + "is_coordinator_file": role == ConversationRole.COORDINATOR, }, ) @@ -472,7 +472,7 @@ async def on_file_deleted( if not share_id or not file.filename: return - role = await detect_assistant_role(context) + role = await ShareManager.get_conversation_role(context) if role == ConversationRole.COORDINATOR: # For Coordinator files: # 1. Delete from share storage @@ -495,7 +495,7 @@ async def on_file_deleted( metadata={ "file_id": getattr(file, "id", ""), "filename": file.filename, - "is_coordinator_file": role.value == "coordinator", + "is_coordinator_file": role == ConversationRole.COORDINATOR, }, ) @@ -522,11 +522,11 @@ async def on_participant_joined( ) ) - role = await detect_assistant_role(context) + role = await ShareManager.get_conversation_role(context) if role != ConversationRole.TEAM: return - share_id = await ShareManager.get_associated_share_id(context) + share_id = await ShareManager.get_share_id(context) if not share_id: return diff --git a/assistants/knowledge-transfer-assistant/assistant/common.py b/assistants/knowledge-transfer-assistant/assistant/common.py index 8bd73c57a..1ac26aeca 100644 --- a/assistants/knowledge-transfer-assistant/assistant/common.py +++ b/assistants/knowledge-transfer-assistant/assistant/common.py @@ -5,104 +5,13 @@ 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 from .domain.share_manager import ShareManager from .data import LogEntryType -from .logging import logger from .storage import ShareStorage -from .data 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: - """ - Detects whether this conversation is in Coordinator or Team mode. - - This method examines the conversation metadata to determine the role of the - current conversation in the knowledge transfer. The role is always stored in - the conversation metadata as "share_role". - - Args: - context: The conversation context to examine - - Returns: - ConversationRole.COORDINATOR or ConversationRole.TEAM - """ - try: - conversation = await context.get_conversation() - metadata = conversation.metadata or {} - role_str = metadata.get("project_role", "coordinator") - - if role_str == "team": - return ConversationRole.TEAM - else: - return ConversationRole.COORDINATOR - except Exception as e: - logger.exception(f"Error detecting assistant role: {e}") - # Default to coordinator role if we can't determine - 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 ShareManager.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( @@ -126,7 +35,7 @@ async def log_transfer_action( related_entity_id: Optional ID of a related entity (e.g., request ID) additional_metadata: Optional additional metadata to include in the log """ - share_id = await ShareManager.get_associated_share_id(context) + share_id = await ShareManager.get_share_id(context) if not share_id: return diff --git a/assistants/knowledge-transfer-assistant/assistant/data.py b/assistants/knowledge-transfer-assistant/assistant/data.py index 40d64a6f0..e1cd1e847 100644 --- a/assistants/knowledge-transfer-assistant/assistant/data.py +++ b/assistants/knowledge-transfer-assistant/assistant/data.py @@ -18,11 +18,13 @@ class ConversationRole(str, Enum): TEAM = "team" SHAREABLE_TEMPLATE = "shareable_template" + class ConversationShareInfo(BaseModel): share_id: str conversation_id: str role: ConversationRole + class InspectorTab(str, Enum): BRIEF = "brief" LEARNING = "learning" @@ -206,4 +208,3 @@ class CoordinatorConversationMessages(BaseModel): knowledge_share_id: str last_updated: datetime = Field(default_factory=datetime.utcnow) messages: List[CoordinatorConversationMessage] = Field(default_factory=list) - diff --git a/assistants/knowledge-transfer-assistant/assistant/domain/share_manager.py b/assistants/knowledge-transfer-assistant/assistant/domain/share_manager.py index f1b089c71..189b6f0f7 100644 --- a/assistants/knowledge-transfer-assistant/assistant/domain/share_manager.py +++ b/assistants/knowledge-transfer-assistant/assistant/domain/share_manager.py @@ -17,15 +17,75 @@ from assistant.domain.knowledge_package_manager import KnowledgePackageManager -from ..data import ConversationShareInfo, KnowledgePackage, KnowledgePackageLog, ConversationRole, TeamConversationInfo +from ..data import ( + ConversationShareInfo, + CoordinatorConversationMessages, + KnowledgePackage, + KnowledgePackageLog, + ConversationRole, + TeamConversationInfo, +) from ..logging import logger from ..storage import ShareStorage, ShareStorageManager from ..utils import get_current_user from semantic_workbench_assistant.storage import read_model, write_model + class ShareManager: """Manages knowledge share creation, joining, and basic operations.""" + @staticmethod + async def create_share(context: ConversationContext) -> str: + """ + Creates a new knowledge share and associates the current conversation with it. + + This is the initial step in knowledge transfer creation. It: + 1. Generates a unique knowledge share ID + 2. Associates the current conversation with that share + 3. Sets the current conversation as Coordinator for the share + 4. Creates empty share data structures (brief, knowledge digest, etc.) + 5. Logs the creation event + + After creating a share, the Coordinator should proceed to create a knowledge brief + with specific learning objectives and success criteria. + + Args: + context: Current conversation context containing user/assistant information + + Returns: + Tuple of (success, share_id) where: + - success: Boolean indicating if the creation was successful + - share_id: If successful, the UUID of the newly created share + """ + + share_id = str(uuid.uuid4()) + + share_dir = ShareStorageManager.get_share_dir(share_id) + logger.debug(f"Created share directory: {share_dir}") + + # Create and save the initial knowledge package + knowledge_package = KnowledgePackage( + share_id=share_id, + coordinator_conversation_id=str(context.id), + brief=None, + digest=None, + ) + + # Save the knowledge package + ShareStorage.write_share(share_id, knowledge_package) + logger.debug(f"Created and saved knowledge package: {knowledge_package}") + + # Associate the conversation with the share + logger.debug(f"Associating conversation {context.id} with share {share_id}") + await ShareManager.set_conversation_role(context, share_id, ConversationRole.COORDINATOR) + + # No need to set conversation role in share storage, as we use metadata + logger.debug(f"Conversation {context.id} is Coordinator for share {share_id}") + + # Note: Conversation linking is now handled via JSON data, no directory needed + + return share_id + @staticmethod async def create_shareable_team_conversation(context: ConversationContext, share_id: str) -> str: """ @@ -98,56 +158,18 @@ async def create_shareable_team_conversation(context: ConversationContext, share return share_url @staticmethod - async def create_share(context: ConversationContext) -> str: + async def get_shared_conversation_id(context: ConversationContext) -> Optional[str]: """ - Creates a new knowledge share and associates the current conversation with it. - - This is the initial step in knowledge transfer creation. It: - 1. Generates a unique knowledge share ID - 2. Associates the current conversation with that share - 3. Sets the current conversation as Coordinator for the share - 4. Creates empty share data structures (brief, knowledge digest, etc.) - 5. Logs the creation event - - After creating a share, the Coordinator should proceed to create a knowledge brief - with specific learning objectives and success criteria. - - Args: - context: Current conversation context containing user/assistant information - - Returns: - Tuple of (success, share_id) where: - - success: Boolean indicating if the creation was successful - - share_id: If successful, the UUID of the newly created share + Retrieves the share ID and finds the associated shareable template conversation ID. """ - - share_id = str(uuid.uuid4()) - - share_dir = ShareStorageManager.get_share_dir(share_id) - logger.debug(f"Created share directory: {share_dir}") - - # Create and save the initial knowledge package - knowledge_package = KnowledgePackage( - share_id=share_id, - coordinator_conversation_id=str(context.id), - brief=None, - digest=None, - ) - - # Save the knowledge package - ShareStorage.write_share(share_id, knowledge_package) - logger.debug(f"Created and saved knowledge package: {knowledge_package}") - - # Associate the conversation with the share - logger.debug(f"Associating conversation {context.id} with share {share_id}") - await ShareManager.set_conversation_role(context, share_id, ConversationRole.COORDINATOR) - - # No need to set conversation role in share storage, as we use metadata - logger.debug(f"Conversation {context.id} is Coordinator for share {share_id}") - - # Note: Conversation linking is now handled via JSON data, no directory needed - - return share_id + try: + share = await ShareManager.get_share(context) + if not share or not share.shared_conversation_id: + return None + return share.shared_conversation_id + except Exception as e: + logger.error(f"Error getting shared conversation ID: {e}") + return None @staticmethod async def join_share( @@ -199,18 +221,11 @@ async def get_share_id(context: ConversationContext) -> Optional[str]: The share ID string if the conversation is part of a share, None otherwise """ - return await ShareManager.get_associated_share_id(context) - - - - @staticmethod - async def get_share_log(context: ConversationContext) -> Optional[KnowledgePackageLog]: - """Gets the knowledge transfer log for the current conversation's share.""" - share_id = await ShareManager.get_share_id(context) - if not share_id: - return None - - return ShareStorage.read_share_log(share_id) + share_path = ShareStorageManager.get_conversation_role_file_path(context) + share_data = read_model(share_path, ConversationShareInfo) + if share_data: + return share_data.share_id + return None @staticmethod async def get_share(context: ConversationContext) -> Optional[KnowledgePackage]: @@ -234,7 +249,7 @@ async def get_linked_conversations(context: ConversationContext) -> List[str]: Gets all conversations linked to this one through the same knowledge transfer share. """ try: - share_id = await ShareManager.get_associated_share_id(context) + share_id = await ShareManager.get_share_id(context) if not share_id: return [] @@ -260,9 +275,7 @@ async def set_conversation_role(context: ConversationContext, share_id: str, rol """ Sets the role of a conversation in a knowledge transfer share. """ - role_data = ConversationShareInfo( - share_id=share_id, role=role, conversation_id=str(context.id) - ) + role_data = ConversationShareInfo(share_id=share_id, role=role, conversation_id=str(context.id)) role_path = ShareStorageManager.get_conversation_role_file_path(context) write_model(role_path, role_data) @@ -286,7 +299,6 @@ async def _capture_redeemer_info(context: ConversationContext, share_id: str) -> Only captures info for actual team member conversations, not coordinator or shared conversations. """ try: - knowledge_package = ShareStorage.read_share(share_id) if not knowledge_package: logger.warning(f"Could not load knowledge package {share_id} to capture redeemer info") @@ -329,12 +341,20 @@ async def _capture_redeemer_info(context: ConversationContext, share_id: str) -> # Don't re-raise - this is not critical for the association process @staticmethod - async def get_associated_share_id(context: ConversationContext) -> Optional[str]: + async def get_share_log(context: ConversationContext) -> Optional[KnowledgePackageLog]: + """Gets the knowledge transfer log for the current conversation's share.""" + share_id = await ShareManager.get_share_id(context) + if not share_id: + return None + + return ShareStorage.read_share_log(share_id) + + @staticmethod + async def get_coordinator_conversation(context: ConversationContext) -> Optional[CoordinatorConversationMessages]: """ - Gets the share ID associated with a conversation. + Gets the coordinator conversation. """ - share_path = ShareStorageManager.get_conversation_role_file_path(context) - share_data = read_model(share_path, ConversationShareInfo) - if share_data: - return share_data.share_id + share_id = await ShareManager.get_share_id(context) + if share_id: + return ShareStorage.read_coordinator_conversation(share_id) return None diff --git a/assistants/knowledge-transfer-assistant/assistant/files.py b/assistants/knowledge-transfer-assistant/assistant/files.py index e450bfda6..e4370531b 100644 --- a/assistants/knowledge-transfer-assistant/assistant/files.py +++ b/assistants/knowledge-transfer-assistant/assistant/files.py @@ -16,7 +16,6 @@ from semantic_workbench_api_model.workbench_model import MessageType, NewConversationMessage from semantic_workbench_assistant.assistant_app import ConversationContext -from .common import detect_assistant_role from .conversation_clients import ConversationClientManager from .domain.share_manager import ShareManager from .data import LogEntryType @@ -577,7 +576,7 @@ async def process_share_update_notification( """ try: # First verify that this is a Team conversation - role = await detect_assistant_role(context) + role = await ShareManager.get_conversation_role(context) if role != ConversationRole.TEAM: logger.warning("Only Team conversations should process file update notifications") diff --git a/assistants/knowledge-transfer-assistant/assistant/respond.py b/assistants/knowledge-transfer-assistant/assistant/respond.py index 4a214d133..214661f67 100644 --- a/assistants/knowledge-transfer-assistant/assistant/respond.py +++ b/assistants/knowledge-transfer-assistant/assistant/respond.py @@ -26,16 +26,15 @@ ConversationContext, ) +from assistant.domain.share_manager import ShareManager + from .agentic.analysis import detect_information_request_needs from .agentic.coordinator_support import CoordinatorSupport -from .common import detect_assistant_role from .config import assistant_config -from .data import RequestStatus +from .data import ConversationRole, CoordinatorConversationMessage, RequestStatus from .domain import KnowledgeTransferManager from .domain.knowledge_package_manager import KnowledgePackageManager from .logging import logger -from .storage import ShareStorage -from .data import ConversationRole, CoordinatorConversationMessage from .string_utils import Context, ContextStrategy, Instructions, Prompt, TokenBudget from .tools import ShareTools from .ui_tabs.common import get_priority_emoji, get_status_emoji @@ -117,7 +116,7 @@ async def respond_to_conversation( model = config.request_config.openai_model # Requirements - role = await detect_assistant_role(context) + role = await ShareManager.get_conversation_role(context) or ConversationRole.COORDINATOR metadata["debug"]["role"] = role share_id = await KnowledgeTransferManager.get_share_id(context) if not share_id: @@ -180,31 +179,30 @@ async def respond_to_conversation( ### # Project info - share = ShareStorage.read_share(share_id) + share = await ShareManager.get_share(context) if share: - data = share.model_dump() - - # Delete fields that are not relevant to the knowledge transfer assistant. - # FIXME: Reintroduce these properly. - if "state" in data: - del data["state"] - if "progress_percentage" in data: - del data["progress_percentage"] - if "completed_criteria" in data: - del data["completed_criteria"] - if "total_criteria" in data: - del data["total_criteria"] - if "lifecycle" in data: - del data["lifecycle"] - - share_info_text = share.model_dump_json(indent=2) + share_info_text = share.model_dump_json( + indent=2, + exclude={ + "brief", + "learning_objectives", + "takeaways", + "preferred_communication_style", + "transfer_notes", + "digest", + "next_learning_actions", + "transfer_lifecycle", + "archived", + "requests", + "log", + }, + ) prompt.contexts.append(Context("Knowledge Info", share_info_text)) # Brief - briefing = ShareStorage.read_knowledge_brief(share_id) - brief_text = "" - if briefing: - brief_text = f"**Title:** {briefing.title}\n**Description:** {briefing.content}" + if share and share.brief: + brief_text = "" + brief_text = f"**Title:** {share.brief.title}\n**Description:** {share.brief.content}" prompt.contexts.append( Context( "Knowledge Brief", @@ -227,7 +225,6 @@ async def respond_to_conversation( ) # Learning objectives - share = ShareStorage.read_share(share_id) if share and share.learning_objectives: learning_objectives_text = "" conversation_id = str(context.id) @@ -251,7 +248,7 @@ async def respond_to_conversation( ) for i, objective in enumerate(share.learning_objectives): - brief_text += f"{i + 1}. **{objective.name}** - {objective.description}\n" + learning_objectives_text += f"{i + 1}. **{objective.name}** - {objective.description}\n" if objective.learning_outcomes: for criterion in objective.learning_outcomes: if role == ConversationRole.COORDINATOR: @@ -277,57 +274,61 @@ async def respond_to_conversation( ) # Knowledge digest - knowledge_digest = ShareStorage.read_knowledge_digest(share_id) - if knowledge_digest and knowledge_digest.content: + if share and share.digest and share.digest.content: prompt.contexts.append( - Context("Knowledge digest", knowledge_digest.content, "The assistant-maintained knowledge digest.") + Context("Knowledge digest", share.digest.content, "The assistant-maintained knowledge digest.") ) # Information requests - all_requests = ShareStorage.get_all_information_requests(share_id) - if role == ConversationRole.COORDINATOR: - active_requests = [r for r in all_requests if r.status != RequestStatus.RESOLVED] - if active_requests: - coordinator_requests = "> 📋 **Use the request ID (not the title) with resolve_information_request()**\n\n" - for req in active_requests[:10]: # Limit to 10 for brevity - priority_emoji = get_priority_emoji(req.priority) - status_emoji = get_status_emoji(req.status) - coordinator_requests += f"{priority_emoji} **{req.title}** {status_emoji}\n" - coordinator_requests += f" **Request ID:** `{req.request_id}`\n" - coordinator_requests += f" **Description:** {req.description}\n\n" - - if len(active_requests) > 10: - coordinator_requests += f'*...and {len(active_requests) - 10} more requests.*\n' - else: - coordinator_requests = "No active information requests." - prompt.contexts.append( - Context( - "Information Requests", - coordinator_requests, + if share: + all_requests = share.requests + if role == ConversationRole.COORDINATOR: + active_requests = [r for r in all_requests if r.status != RequestStatus.RESOLVED] + if active_requests: + coordinator_requests = ( + "> 📋 **Use the request ID (not the title) with resolve_information_request()**\n\n" + ) + for req in active_requests[:10]: # Limit to 10 for brevity + priority_emoji = get_priority_emoji(req.priority) + status_emoji = get_status_emoji(req.status) + coordinator_requests += f"{priority_emoji} **{req.title}** {status_emoji}\n" + coordinator_requests += f" **Request ID:** `{req.request_id}`\n" + coordinator_requests += f" **Description:** {req.description}\n\n" + + if len(active_requests) > 10: + coordinator_requests += f"*...and {len(active_requests) - 10} more requests.*\n" + else: + coordinator_requests = "No active information requests." + prompt.contexts.append( + Context( + "Information Requests", + coordinator_requests, + ) ) - ) - else: # team role - information_requests_info = "" - my_requests = [] - - # Filter for requests from this conversation that aren't resolved. - my_requests = [ - r for r in all_requests if r.conversation_id == str(context.id) and r.status != RequestStatus.RESOLVED - ] - - if my_requests: + else: # team role information_requests_info = "" - for req in my_requests: - information_requests_info += f"- **{req.title}** (ID: `{req.request_id}`, Priority: {req.priority})\n" - else: - information_requests_info = "No active information requests." + my_requests = [] + + # Filter for requests from this conversation that aren't resolved. + my_requests = [ + r for r in all_requests if r.conversation_id == str(context.id) and r.status != RequestStatus.RESOLVED + ] + + if my_requests: + information_requests_info = "" + for req in my_requests: + information_requests_info += ( + f"- **{req.title}** (ID: `{req.request_id}`, Priority: {req.priority})\n" + ) + else: + information_requests_info = "No active information requests." - prompt.contexts.append( - Context( - "Information Requests", - information_requests_info, + prompt.contexts.append( + Context( + "Information Requests", + information_requests_info, + ) ) - ) # Add next action suggestions for coordinator if role == ConversationRole.COORDINATOR: @@ -355,7 +356,7 @@ async def respond_to_conversation( ### # Get the coordinator conversation and add it as an attachment. - coordinator_conversation = ShareStorage.read_coordinator_conversation(share_id) + coordinator_conversation = await ShareManager.get_coordinator_conversation(context) if coordinator_conversation: # Limit messages to the configured max token count. total_coordinator_conversation_tokens = 0 diff --git a/assistants/knowledge-transfer-assistant/assistant/storage.py b/assistants/knowledge-transfer-assistant/assistant/storage.py index 56d8b7d98..868b4129a 100644 --- a/assistants/knowledge-transfer-assistant/assistant/storage.py +++ b/assistants/knowledge-transfer-assistant/assistant/storage.py @@ -84,6 +84,7 @@ def get_conversation_role_file_path(context: ConversationContext) -> pathlib.Pat storage_dir.mkdir(parents=True, exist_ok=True) return storage_dir / "share_role.json" + class ShareStorage: """Unified storage operations for knowledge transfer share data.""" @@ -251,8 +252,6 @@ def write_information_request(share_id: str, request: InformationRequest) -> pat return ShareStorage.write_share(share_id, package) - - @staticmethod def get_all_information_requests(share_id: str) -> List[InformationRequest]: """Gets all information requests from the main share data.""" diff --git a/assistants/knowledge-transfer-assistant/assistant/tools/progress_tracking.py b/assistants/knowledge-transfer-assistant/assistant/tools/progress_tracking.py index 15fd0628e..8b8f2e167 100644 --- a/assistants/knowledge-transfer-assistant/assistant/tools/progress_tracking.py +++ b/assistants/knowledge-transfer-assistant/assistant/tools/progress_tracking.py @@ -68,7 +68,7 @@ async def mark_learning_outcome_achieved(self, objective_id: str, outcome_id: st if obj.id == objective_id: objective = obj break - + if not objective: return f"Learning objective with ID '{objective_id}' not found." @@ -78,10 +78,10 @@ async def mark_learning_outcome_achieved(self, objective_id: str, outcome_id: st if out.id == outcome_id: outcome = out break - + if not outcome: return f"Learning outcome with ID '{outcome_id}' not found in objective '{objective.name}'." - + conversation_id = str(self.context.id) # Check if already achieved by this conversation diff --git a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/brief.py b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/brief.py index dffb5b67c..049f70749 100644 --- a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/brief.py +++ b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/brief.py @@ -9,7 +9,6 @@ ConversationContext, ) -from assistant.common import detect_assistant_role from assistant.domain.share_manager import ShareManager from assistant.domain import KnowledgeTransferManager from assistant.data import ConversationRole @@ -40,10 +39,10 @@ async def is_enabled(self, context: ConversationContext) -> bool: async def get(self, context: ConversationContext) -> AssistantConversationInspectorStateDataModel: """Get brief and status information for display.""" - conversation_role = await detect_assistant_role(context) + conversation_role = await ShareManager.get_conversation_role(context) # Get share information - share_id = await ShareManager.get_associated_share_id(context) + share_id = await ShareManager.get_share_id(context) if not share_id: return AssistantConversationInspectorStateDataModel( data={"content": "No active knowledge package. Start a conversation to create one."} diff --git a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/debug.py b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/debug.py index 49ffd20e3..c3bf2d2f9 100644 --- a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/debug.py +++ b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/debug.py @@ -36,7 +36,7 @@ async def get(self, context: ConversationContext) -> AssistantConversationInspec """Get debug information for display.""" # Get share information - share_id = await ShareManager.get_associated_share_id(context) + share_id = await ShareManager.get_share_id(context) if not share_id: return AssistantConversationInspectorStateDataModel( data={"content": "No active knowledge package. Start a conversation to create one."} diff --git a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/learning.py b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/learning.py index 5ac1371f2..de6425636 100644 --- a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/learning.py +++ b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/learning.py @@ -9,7 +9,6 @@ ConversationContext, ) -from assistant.common import detect_assistant_role from assistant.domain.share_manager import ShareManager from assistant.domain.knowledge_package_manager import KnowledgePackageManager from assistant.storage import ShareStorage @@ -36,10 +35,10 @@ async def is_enabled(self, context: ConversationContext) -> bool: async def get(self, context: ConversationContext) -> AssistantConversationInspectorStateDataModel: """Get learning objectives and progress information.""" - conversation_role = await detect_assistant_role(context) + conversation_role = await ShareManager.get_conversation_role(context) # Get share information - share_id = await ShareManager.get_associated_share_id(context) + share_id = await ShareManager.get_share_id(context) if not share_id: return AssistantConversationInspectorStateDataModel( data={"content": "No active knowledge package. Start a conversation to create one."} diff --git a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/sharing.py b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/sharing.py index a4acd8662..cce40ea7a 100644 --- a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/sharing.py +++ b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/sharing.py @@ -9,7 +9,6 @@ ConversationContext, ) -from assistant.common import detect_assistant_role from assistant.domain.share_manager import ShareManager from assistant.data import RequestStatus from assistant.domain import KnowledgeTransferManager @@ -38,10 +37,10 @@ async def is_enabled(self, context: ConversationContext) -> bool: async def get(self, context: ConversationContext) -> AssistantConversationInspectorStateDataModel: """Get information requests for display.""" - conversation_role = await detect_assistant_role(context) + conversation_role = await ShareManager.get_conversation_role(context) # Get share information - share_id = await ShareManager.get_associated_share_id(context) + share_id = await ShareManager.get_share_id(context) if not share_id: return AssistantConversationInspectorStateDataModel( data={"content": "No active knowledge package. Start a conversation to create one."} diff --git a/assistants/knowledge-transfer-assistant/tests/test_artifact_loading.py b/assistants/knowledge-transfer-assistant/tests/test_artifact_loading.py index 974b50f64..6173f8dd0 100644 --- a/assistants/knowledge-transfer-assistant/tests/test_artifact_loading.py +++ b/assistants/knowledge-transfer-assistant/tests/test_artifact_loading.py @@ -65,13 +65,11 @@ def mock_storage_directory_for_context(context, *args, **kwargs): self.mock_storage_directory = patch1.start() self.patches.append(patch1) - # Patch get_associated_share_id - async def mock_get_associated_share_id(context): + # Patch get_share_id + async def mock_get_share_id(context): return self.share_id - patch2 = unittest.mock.patch.object( - ShareManager, "get_associated_share_id", side_effect=mock_get_associated_share_id - ) + patch2 = unittest.mock.patch.object(ShareManager, "get_share_id", side_effect=mock_get_share_id) self.mock_get_project = patch2.start() self.patches.append(patch2) From 6b23bf6515eb1c73208e11dfd61976dc6bffce2c Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Mon, 28 Jul 2025 17:33:31 -0700 Subject: [PATCH 04/21] Remove misplaced notify method. --- .../assistant/storage.py | 13 ------------- .../assistant/tools/progress_tracking.py | 7 ------- 2 files changed, 20 deletions(-) diff --git a/assistants/knowledge-transfer-assistant/assistant/storage.py b/assistants/knowledge-transfer-assistant/assistant/storage.py index 868b4129a..373f0e3f6 100644 --- a/assistants/knowledge-transfer-assistant/assistant/storage.py +++ b/assistants/knowledge-transfer-assistant/assistant/storage.py @@ -264,19 +264,6 @@ def get_all_information_requests(share_id: str) -> List[InformationRequest]: requests.sort(key=lambda r: r.updated_at, reverse=True) return requests - @staticmethod - async def refresh_all_share_uis(context: ConversationContext, share_id: str, tabs: List["InspectorTab"]) -> None: - """ - Refreshes the UI inspector panels of all conversations in a knowledge transfer. - Args: - context: Current conversation context - share_id: The share ID - tabs: List of InspectorTab values to update. If None, updates all tabs. - """ - - from .notifications import Notifications - - await Notifications.notify_all_state_update(context, share_id, tabs) @staticmethod async def log_share_event( diff --git a/assistants/knowledge-transfer-assistant/assistant/tools/progress_tracking.py b/assistants/knowledge-transfer-assistant/assistant/tools/progress_tracking.py index 8b8f2e167..14415c0a0 100644 --- a/assistants/knowledge-transfer-assistant/assistant/tools/progress_tracking.py +++ b/assistants/knowledge-transfer-assistant/assistant/tools/progress_tracking.py @@ -148,11 +148,6 @@ async def mark_learning_outcome_achieved(self, objective_id: str, outcome_id: st self.context, share_id, [InspectorTab.LEARNING, InspectorTab.BRIEF] ) - # Update all share UI inspectors - await ShareStorage.refresh_all_share_uis( - self.context, share_id, [InspectorTab.LEARNING, InspectorTab.BRIEF] - ) - # Check if all outcomes are achieved for transfer completion # Get the knowledge package to check completion status knowledge_package = ShareStorage.read_share(share_id) @@ -242,8 +237,6 @@ async def report_transfer_completion(self) -> str: ) await Notifications.notify_all_state_update(self.context, share_id, [InspectorTab.BRIEF]) - await ShareStorage.refresh_all_share_uis(self.context, share_id, [InspectorTab.BRIEF]) - await self.context.send_messages( NewConversationMessage( content="🎉 **Knowledge Transfer Complete**: All learning objectives have been achieved and the knowledge transfer is now complete. The Coordinator has been notified.", From d39a554ba5a3a49887d3b54a7b923af158fd4c39 Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Mon, 28 Jul 2025 18:29:46 -0700 Subject: [PATCH 05/21] Consistently accesses domain models instead of storage directly (de-Claude). --- .../assistant/agentic/coordinator_support.py | 7 +- .../assistant/agentic/team_welcome.py | 17 ++-- .../assistant/assistant.py | 17 ++-- .../assistant/common.py | 8 +- .../assistant/domain/__init__.py | 1 + .../assistant/domain/audience_manager.py | 23 ++---- .../domain/information_request_manager.py | 17 ++-- .../domain/knowledge_brief_manager.py | 6 +- .../domain/knowledge_digest_manager.py | 3 +- .../domain/learning_objectives_manager.py | 45 +++++------ .../assistant/domain/share_manager.py | 80 ++++++++++++++++++- .../assistant/files.py | 5 +- .../assistant/notifications.py | 9 ++- .../assistant/storage.py | 6 +- .../assistant/tools/progress_tracking.py | 25 +++--- .../assistant/tools/share_setup.py | 37 +++------ .../assistant/ui_tabs/debug.py | 3 +- .../assistant/ui_tabs/learning.py | 3 +- 18 files changed, 167 insertions(+), 145 deletions(-) diff --git a/assistants/knowledge-transfer-assistant/assistant/agentic/coordinator_support.py b/assistants/knowledge-transfer-assistant/assistant/agentic/coordinator_support.py index 7c5ccab81..fa712d53e 100644 --- a/assistants/knowledge-transfer-assistant/assistant/agentic/coordinator_support.py +++ b/assistants/knowledge-transfer-assistant/assistant/agentic/coordinator_support.py @@ -11,7 +11,6 @@ from ..data import RequestStatus from ..domain.knowledge_package_manager import KnowledgePackageManager from ..logging import logger -from ..storage import ShareStorage from ..domain.share_manager import ShareManager @@ -35,12 +34,12 @@ async def get_coordinator_next_action_suggestion(context: ConversationContext) - logger.warning("No share ID found for this conversation") return None - package = ShareStorage.read_share(share_id) + package = await ShareManager.get_share(context) if not package: return None - brief = ShareStorage.read_knowledge_brief(share_id) - requests = ShareStorage.get_all_information_requests(share_id) + brief = package.brief + requests = package.requests active_requests = [r for r in requests if r.status == RequestStatus.NEW] # 1. Unresolved requests come first diff --git a/assistants/knowledge-transfer-assistant/assistant/agentic/team_welcome.py b/assistants/knowledge-transfer-assistant/assistant/agentic/team_welcome.py index 5a2553aae..13b772542 100644 --- a/assistants/knowledge-transfer-assistant/assistant/agentic/team_welcome.py +++ b/assistants/knowledge-transfer-assistant/assistant/agentic/team_welcome.py @@ -12,8 +12,7 @@ from openai.types.chat import ChatCompletionMessageParam from semantic_workbench_assistant.assistant_app import ConversationContext -from assistant.domain import KnowledgeTransferManager -from assistant.storage import ShareStorage +from assistant.domain.share_manager import ShareManager from ..config import assistant_config from ..logging import logger @@ -27,14 +26,17 @@ async def generate_team_welcome_message(context: ConversationContext) -> tuple[s config = await assistant_config.get(context.assistant) - share_id = await KnowledgeTransferManager.get_share_id(context) - if not share_id: - raise ValueError("Project ID not found in context") + share = await ShareManager.get_share(context) + if not share: + logger.warning("No active knowledge package found for welcome message generation") + return config.team_config.default_welcome_message, debug + + share_id = share.share_id share_data: dict[str, str] = {} # Knowledge Brief - briefing = ShareStorage.read_knowledge_brief(share_id) + briefing = share.brief brief_text = "" if briefing: brief_text = dedent(f""" @@ -47,7 +49,6 @@ async def generate_team_welcome_message(context: ConversationContext) -> tuple[s share_data["briefing"] = brief_text # Learning Objectives - share = ShareStorage.read_share(share_id) if share and share.learning_objectives: brief_text += "\n#### LEARNING OBJECTIVES:\n\n" @@ -61,7 +62,7 @@ async def generate_team_welcome_message(context: ConversationContext) -> tuple[s share_data["learning_objectives"] = brief_text # Knowledge Digest - knowledge_digest = ShareStorage.read_knowledge_digest(share_id) + knowledge_digest = share.digest if knowledge_digest and knowledge_digest.content: knowledge_digest_text = dedent(f""" ### ASSISTANT KNOWLEDGE DIGEST - KEY KNOWLEDGE SHARE INFORMATION diff --git a/assistants/knowledge-transfer-assistant/assistant/assistant.py b/assistants/knowledge-transfer-assistant/assistant/assistant.py index bad4cb501..43c76361f 100644 --- a/assistants/knowledge-transfer-assistant/assistant/assistant.py +++ b/assistants/knowledge-transfer-assistant/assistant/assistant.py @@ -37,7 +37,6 @@ from .notifications import Notifications from .respond import respond_to_conversation from .ui_tabs import BriefInspector, LearningInspector, SharingInspector, DebugInspector -from .storage import ShareStorage from .data import ConversationRole from .utils import ( DEFAULT_TEMPLATE_ID, @@ -260,8 +259,8 @@ async def on_message_created( break # Store the message for Team access - ShareStorage.append_coordinator_message( - share_id=share_id, + await ShareManager.append_coordinator_message( + context=context, message_id=str(message.id), content=message.content, sender_name=sender_name, @@ -389,9 +388,8 @@ async def on_file_created( # Team files don't need special handling as they're already in the conversation # Log file creation to knowledge transfer log for all files - await ShareStorage.log_share_event( + await ShareManager.log_share_event( context=context, - share_id=share_id, entry_type="file_shared", message=f"File shared: {file.filename}", metadata={ @@ -444,9 +442,8 @@ async def on_file_updated( # 3. Update all UIs but don't send notifications to reduce noise await Notifications.notify_all_state_update(context, share_id, [InspectorTab.DEBUG]) - await ShareStorage.log_share_event( + await ShareManager.log_share_event( context=context, - share_id=share_id, entry_type="file_shared", message=f"File updated: {file.filename}", metadata={ @@ -487,9 +484,8 @@ async def on_file_deleted( await Notifications.notify_all_state_update(context, share_id, [InspectorTab.DEBUG]) # Team files don't need special handling - await ShareStorage.log_share_event( + await ShareManager.log_share_event( context=context, - share_id=share_id, entry_type="file_deleted", message=f"File deleted: {file.filename}", metadata={ @@ -532,9 +528,8 @@ async def on_participant_joined( await ShareFilesManager.synchronize_files_to_team_conversation(context=context, share_id=share_id) - await ShareStorage.log_share_event( + await ShareManager.log_share_event( context=context, - share_id=share_id, entry_type=LogEntryType.PARTICIPANT_JOINED, message=f"Participant joined: {participant.name}", metadata={ diff --git a/assistants/knowledge-transfer-assistant/assistant/common.py b/assistants/knowledge-transfer-assistant/assistant/common.py index 1ac26aeca..e84ec503c 100644 --- a/assistants/knowledge-transfer-assistant/assistant/common.py +++ b/assistants/knowledge-transfer-assistant/assistant/common.py @@ -11,7 +11,6 @@ from .domain.share_manager import ShareManager from .data import LogEntryType -from .storage import ShareStorage async def log_transfer_action( @@ -35,13 +34,8 @@ async def log_transfer_action( related_entity_id: Optional ID of a related entity (e.g., request ID) additional_metadata: Optional additional metadata to include in the log """ - share_id = await ShareManager.get_share_id(context) - if not share_id: - return - - await ShareStorage.log_share_event( + await ShareManager.log_share_event( context=context, - share_id=share_id, entry_type=entry_type.value, message=message, related_entity_id=related_entity_id, diff --git a/assistants/knowledge-transfer-assistant/assistant/domain/__init__.py b/assistants/knowledge-transfer-assistant/assistant/domain/__init__.py index 1941d2eec..b07bb51b8 100644 --- a/assistants/knowledge-transfer-assistant/assistant/domain/__init__.py +++ b/assistants/knowledge-transfer-assistant/assistant/domain/__init__.py @@ -35,6 +35,7 @@ class KnowledgeTransferManager: get_share_id = ShareManager.get_share_id get_share_log = ShareManager.get_share_log get_share = ShareManager.get_share + log_share_event = ShareManager.log_share_event # Knowledge Brief Operations get_knowledge_brief = KnowledgeBriefManager.get_knowledge_brief diff --git a/assistants/knowledge-transfer-assistant/assistant/domain/audience_manager.py b/assistants/knowledge-transfer-assistant/assistant/domain/audience_manager.py index 99f56a3af..1badd0b05 100644 --- a/assistants/knowledge-transfer-assistant/assistant/domain/audience_manager.py +++ b/assistants/knowledge-transfer-assistant/assistant/domain/audience_manager.py @@ -15,7 +15,6 @@ from ..data import InspectorTab, LogEntryType from ..logging import logger -from ..storage import ShareStorage class AudienceManager: @@ -39,29 +38,21 @@ async def update_audience( - message: Result message """ try: - share_id = await ShareManager.get_share_id(context) - if not share_id: - return ( - False, - "No knowledge package associated with this conversation. Please create a knowledge brief first.", - ) - # Get existing knowledge package - package = ShareStorage.read_share(share_id) - if not package: + share = await ShareManager.get_share(context) + if not share: return False, "No knowledge package found. Please create a knowledge brief first." # Update the audience - package.audience = audience_description.strip() - package.updated_at = datetime.utcnow() + share.audience = audience_description.strip() + share.updated_at = datetime.utcnow() # Save the updated package - ShareStorage.write_share(share_id, package) + await ShareManager.set_share(context, share) # Log the event - await ShareStorage.log_share_event( + await ShareManager.log_share_event( context=context, - share_id=share_id, entry_type=LogEntryType.STATUS_CHANGED.value, message=f"Updated target audience: {audience_description}", metadata={ @@ -70,7 +61,7 @@ async def update_audience( ) await Notifications.notify(context, "Audience updated.") - await Notifications.notify_all_state_update(context, share_id, [InspectorTab.DEBUG]) + await Notifications.notify_all_state_update(context, share.share_id, [InspectorTab.DEBUG]) return True, f"Target audience updated successfully: {audience_description}" diff --git a/assistants/knowledge-transfer-assistant/assistant/domain/information_request_manager.py b/assistants/knowledge-transfer-assistant/assistant/domain/information_request_manager.py index 6052535ba..6ed046b1a 100644 --- a/assistants/knowledge-transfer-assistant/assistant/domain/information_request_manager.py +++ b/assistants/knowledge-transfer-assistant/assistant/domain/information_request_manager.py @@ -62,9 +62,8 @@ async def create_information_request( ShareStorage.write_information_request(share_id, information_request) - await ShareStorage.log_share_event( + await ShareManager.log_share_event( context=context, - share_id=share_id, entry_type=LogEntryType.REQUEST_CREATED.value, message=f"Created information request: {title}", related_entity_id=information_request.request_id, @@ -141,9 +140,8 @@ async def resolve_information_request( ShareStorage.write_information_request(share_id, information_request) # Log the resolution - await ShareStorage.log_share_event( + await ShareManager.log_share_event( context=context, - share_id=share_id, entry_type=LogEntryType.REQUEST_RESOLVED.value, message=f"Resolved information request: {information_request.title}", related_entity_id=information_request.request_id, @@ -217,9 +215,8 @@ async def delete_information_request( actual_request_id = information_request.request_id # Log the deletion - await ShareStorage.log_share_event( + await ShareManager.log_share_event( context=context, - share_id=share_id, entry_type=LogEntryType.REQUEST_DELETED.value, message=f"Information request '{request_title}' was deleted by {current_username}", related_entity_id=actual_request_id, @@ -231,10 +228,10 @@ async def delete_information_request( ) # Delete the information request from the main share data - package = ShareStorage.read_share(share_id) - if package and package.requests: - package.requests = [req for req in package.requests if req.request_id != actual_request_id] - ShareStorage.write_share(share_id, package) + share = await ShareManager.get_share(context) + if share and share.requests: + share.requests = [req for req in share.requests if req.request_id != actual_request_id] + await ShareManager.set_share(context, share) # Notify about the deletion await Notifications.notify_self_and_other( diff --git a/assistants/knowledge-transfer-assistant/assistant/domain/knowledge_brief_manager.py b/assistants/knowledge-transfer-assistant/assistant/domain/knowledge_brief_manager.py index 3b681d435..276e62ced 100644 --- a/assistants/knowledge-transfer-assistant/assistant/domain/knowledge_brief_manager.py +++ b/assistants/knowledge-transfer-assistant/assistant/domain/knowledge_brief_manager.py @@ -57,17 +57,15 @@ async def update_knowledge_brief( existing_brief = ShareStorage.read_knowledge_brief(share_id) if existing_brief: # This is an update - await ShareStorage.log_share_event( + await ShareManager.log_share_event( context=context, - share_id=share_id, entry_type=LogEntryType.BRIEFING_UPDATED.value, message=f"Updated brief: {title}", ) else: # This is a creation - await ShareStorage.log_share_event( + await ShareManager.log_share_event( context=context, - share_id=share_id, entry_type=LogEntryType.BRIEFING_CREATED.value, message=f"Created brief: {title}", ) diff --git a/assistants/knowledge-transfer-assistant/assistant/domain/knowledge_digest_manager.py b/assistants/knowledge-transfer-assistant/assistant/domain/knowledge_digest_manager.py index 90342f4bc..5895e9342 100644 --- a/assistants/knowledge-transfer-assistant/assistant/domain/knowledge_digest_manager.py +++ b/assistants/knowledge-transfer-assistant/assistant/domain/knowledge_digest_manager.py @@ -73,9 +73,8 @@ async def update_knowledge_digest( update_type = "auto-generated" if is_auto_generated else "manual" message = f"{'Created' if is_new else 'Updated'} knowledge digest ({update_type})" - await ShareStorage.log_share_event( + await ShareManager.log_share_event( context=context, - share_id=share_id, entry_type=event_type.value, message=message, ) diff --git a/assistants/knowledge-transfer-assistant/assistant/domain/learning_objectives_manager.py b/assistants/knowledge-transfer-assistant/assistant/domain/learning_objectives_manager.py index 4ce8c57aa..8eba31a42 100644 --- a/assistants/knowledge-transfer-assistant/assistant/domain/learning_objectives_manager.py +++ b/assistants/knowledge-transfer-assistant/assistant/domain/learning_objectives_manager.py @@ -11,7 +11,6 @@ from ..data import InspectorTab, KnowledgePackage, LearningObjective, LearningOutcome, LogEntryType from ..logging import logger from ..notifications import Notifications -from ..storage import ShareStorage from ..utils import require_current_user from .share_manager import ShareManager @@ -48,7 +47,7 @@ async def add_learning_objective( learning_outcomes=criterion_objects, ) - share = ShareStorage.read_share(share_id) + share = await ShareManager.get_share(context) if not share: # Create a new share if it doesn't exist share = KnowledgePackage( @@ -62,11 +61,10 @@ async def add_learning_objective( else: share.learning_objectives.append(new_learning_objective) - ShareStorage.write_share(share_id, share) + await ShareManager.set_share(context, share) - await ShareStorage.log_share_event( + await ShareManager.log_share_event( context=context, - share_id=share_id, entry_type=LogEntryType.LEARNING_OBJECTIVE_ADDED.value, message=f"Added learning objective: {objective_name}", ) @@ -93,7 +91,7 @@ async def update_learning_objective( if not current_user_id: return False, "Could not identify current user." - share = ShareStorage.read_share(share_id) + share = await ShareManager.get_share(context) if not share or not share.learning_objectives: return False, "No learning objectives found." @@ -126,12 +124,11 @@ async def update_learning_objective( if not changes_made: return True, "No changes specified" - ShareStorage.write_share(share_id, share) + await ShareManager.set_share(context, share) changes_text = ", ".join(changes_made) - await ShareStorage.log_share_event( + await ShareManager.log_share_event( context=context, - share_id=share_id, entry_type=LogEntryType.LEARNING_OBJECTIVE_UPDATED.value, message=f"Updated learning objective '{objective.name}': {changes_text}", metadata={ @@ -161,7 +158,7 @@ async def delete_learning_objective( if not current_user_id: return False, "Could not identify current user." - share = ShareStorage.read_share(share_id) + share = await ShareManager.get_share(context) if not share or not share.learning_objectives: return False, "No learning objectives found." @@ -195,11 +192,10 @@ async def delete_learning_objective( # Remove the objective from the share share.learning_objectives.pop(objective_index) - ShareStorage.write_share(share_id, share) + await ShareManager.set_share(context, share) - await ShareStorage.log_share_event( + await ShareManager.log_share_event( context=context, - share_id=share_id, entry_type=LogEntryType.LEARNING_OBJECTIVE_UPDATED.value, message=f"Deleted learning objective '{objective_name}' and all its outcomes", metadata={ @@ -220,7 +216,7 @@ async def get_learning_outcomes(context: ConversationContext) -> List[LearningOu if not share_id: return [] - share = ShareStorage.read_share(share_id) + share = await ShareManager.get_share(context) if not share: return [] @@ -247,7 +243,7 @@ async def add_learning_outcome( if not current_user_id: return False, "Could not identify current user." - share = ShareStorage.read_share(share_id) + share = await ShareManager.get_share(context) if not share or not share.learning_objectives: return False, "No learning objectives found. Please add objectives before adding outcomes." @@ -272,12 +268,11 @@ async def add_learning_outcome( objective.learning_outcomes.append(new_outcome) # Save the updated knowledge package - ShareStorage.write_share(share_id, share) + await ShareManager.set_share(context, share) # Log the outcome addition - await ShareStorage.log_share_event( + await ShareManager.log_share_event( context=context, - share_id=share_id, entry_type=LogEntryType.LEARNING_OBJECTIVE_UPDATED.value, message=f"Added learning outcome to objective '{objective.name}': {outcome_description}", metadata={ @@ -310,7 +305,7 @@ async def update_learning_outcome( if not current_user_id: return False, "Could not identify current user." - share = ShareStorage.read_share(share_id) + share = await ShareManager.get_share(context) if not share or not share.learning_objectives: return False, "No learning objectives found. Please add objectives before updating outcomes." @@ -343,12 +338,11 @@ async def update_learning_outcome( outcome.description = new_description.strip() # Save the updated knowledge package - ShareStorage.write_share(share_id, share) + await ShareManager.set_share(context, share) # Log the outcome update - await ShareStorage.log_share_event( + await ShareManager.log_share_event( context=context, - share_id=share_id, entry_type=LogEntryType.LEARNING_OBJECTIVE_UPDATED.value, message=f"Updated learning outcome in objective '{objective.name}': '{old_description}' → '{new_description}'", metadata={ @@ -381,7 +375,7 @@ async def delete_learning_outcome( if not current_user_id: return False, "Could not identify current user." - share = ShareStorage.read_share(share_id) + share = await ShareManager.get_share(context) if not share or not share.learning_objectives: return False, "No learning objectives found. Please add objectives before deleting outcomes." @@ -422,12 +416,11 @@ async def delete_learning_outcome( ] # Save the updated knowledge package - ShareStorage.write_share(share_id, share) + await ShareManager.set_share(context, share) # Log the outcome deletion - await ShareStorage.log_share_event( + await ShareManager.log_share_event( context=context, - share_id=share_id, entry_type=LogEntryType.LEARNING_OBJECTIVE_UPDATED.value, message=f"Deleted learning outcome from objective '{objective.name}': {deleted_description}", metadata={ diff --git a/assistants/knowledge-transfer-assistant/assistant/domain/share_manager.py b/assistants/knowledge-transfer-assistant/assistant/domain/share_manager.py index 189b6f0f7..be45c8c30 100644 --- a/assistants/knowledge-transfer-assistant/assistant/domain/share_manager.py +++ b/assistants/knowledge-transfer-assistant/assistant/domain/share_manager.py @@ -146,7 +146,7 @@ async def create_shareable_team_conversation(context: ConversationContext, share share_url = f"/conversation-share/{share.id}/redeem" # Store shared conversation info in KnowledgePackage - knowledge_package = ShareStorage.read_share(share_id) + knowledge_package = await ShareManager.get_share(context) if knowledge_package: knowledge_package.shared_conversation_id = str(conversation.id) knowledge_package.share_url = share_url @@ -243,6 +243,23 @@ async def get_share(context: ConversationContext) -> Optional[KnowledgePackage]: return None return share + @staticmethod + async def set_share(context: ConversationContext, share: KnowledgePackage) -> None: + """ + Sets the share information for the current conversation's share. + + This updates the share data in storage, including the log if it exists. + """ + share_id = await ShareManager.get_share_id(context) + if not share_id: + raise ValueError("Cannot set share: no share ID found for this conversation") + + ShareStorage.write_share(share_id, share) + + # If the log exists, write it as well + if share.log: + ShareStorage.write_share_log(share_id, share.log) + @staticmethod async def get_linked_conversations(context: ConversationContext) -> List[str]: """ @@ -349,6 +366,33 @@ async def get_share_log(context: ConversationContext) -> Optional[KnowledgePacka return ShareStorage.read_share_log(share_id) + @staticmethod + async def append_coordinator_message( + context: ConversationContext, + message_id: str, + content: str, + sender_name: str, + is_assistant: bool = False, + timestamp: Optional[datetime] = None, + ) -> None: + """ + Appends a message to the coordinator conversation log. + + This is used to log messages sent by the coordinator in the knowledge transfer process. + """ + share_id = await ShareManager.get_share_id(context) + if not share_id: + raise ValueError("Cannot append message: no share ID found for this conversation") + + ShareStorage.append_coordinator_message( + share_id=share_id, + message_id=message_id, + content=content, + sender_name=sender_name, + is_assistant=is_assistant, + timestamp=timestamp or datetime.utcnow(), + ) + @staticmethod async def get_coordinator_conversation(context: ConversationContext) -> Optional[CoordinatorConversationMessages]: """ @@ -358,3 +402,37 @@ async def get_coordinator_conversation(context: ConversationContext) -> Optional if share_id: return ShareStorage.read_coordinator_conversation(share_id) return None + + @staticmethod + async def log_share_event( + context: ConversationContext, + entry_type: str, + message: str, + related_entity_id: Optional[str] = None, + metadata: Optional[dict] = None, + ) -> bool: + """ + Logs an event to the knowledge transfer log. + + Args: + context: Current conversation context + entry_type: Type of log entry + message: Log message + related_entity_id: Optional ID of a related entity + metadata: Optional additional metadata + + Returns: + True if logged successfully, False otherwise + """ + share_id = await ShareManager.get_share_id(context) + if not share_id: + return False + + return await ShareStorage.log_share_event( + context=context, + share_id=share_id, + entry_type=entry_type, + message=message, + related_entity_id=related_entity_id, + metadata=metadata, + ) diff --git a/assistants/knowledge-transfer-assistant/assistant/files.py b/assistants/knowledge-transfer-assistant/assistant/files.py index e4370531b..1947a54d9 100644 --- a/assistants/knowledge-transfer-assistant/assistant/files.py +++ b/assistants/knowledge-transfer-assistant/assistant/files.py @@ -20,7 +20,7 @@ from .domain.share_manager import ShareManager from .data import LogEntryType from .logging import logger -from .storage import ShareStorage, ShareStorageManager, read_model, write_model +from .storage import ShareStorageManager, read_model, write_model from .data import ConversationRole @@ -536,9 +536,8 @@ async def synchronize_files_to_team_conversation( f"Synchronized files to Team conversation: {len(successful_files)} new, {len(skipped_files)} existing" ) - await ShareStorage.log_share_event( + await ShareManager.log_share_event( context=context, - share_id=share_id, entry_type=LogEntryType.FILE_SHARED, message=sync_message, metadata={ diff --git a/assistants/knowledge-transfer-assistant/assistant/notifications.py b/assistants/knowledge-transfer-assistant/assistant/notifications.py index 7ea216dca..53391a394 100644 --- a/assistants/knowledge-transfer-assistant/assistant/notifications.py +++ b/assistants/knowledge-transfer-assistant/assistant/notifications.py @@ -3,10 +3,11 @@ from semantic_workbench_api_model.workbench_model import AssistantStateEvent, MessageType, NewConversationMessage from semantic_workbench_assistant.assistant_app import ConversationContext +from assistant.domain.share_manager import ShareManager + from .data import InspectorTab from .logging import logger from .conversation_clients import ConversationClientManager -from .storage import ShareStorage class Notifications: @@ -33,7 +34,7 @@ async def notify_self_and_other( # Always notify current conversation await Notifications.notify(context, message) - knowledge_package = ShareStorage.read_share(share_id) + knowledge_package = await ShareManager.get_share(context) if not knowledge_package: return @@ -65,7 +66,7 @@ async def notify_self_and_other( async def notify_all(context: ConversationContext, share_id: str, message: str) -> None: """Send text message notification to all knowledge transfer conversations.""" - knowledge_package = ShareStorage.read_share(share_id) + knowledge_package = await ShareManager.get_share(context) if not knowledge_package: return @@ -127,7 +128,7 @@ async def notify_all_state_update(context: ConversationContext, share_id: str, t await Notifications.notify_state_update(context, tabs) # Refresh other conversations - knowledge_package = ShareStorage.read_share(share_id) + knowledge_package = await ShareManager.get_share(context) if not knowledge_package: return diff --git a/assistants/knowledge-transfer-assistant/assistant/storage.py b/assistants/knowledge-transfer-assistant/assistant/storage.py index 373f0e3f6..eb38fa1d5 100644 --- a/assistants/knowledge-transfer-assistant/assistant/storage.py +++ b/assistants/knowledge-transfer-assistant/assistant/storage.py @@ -6,10 +6,7 @@ import pathlib from datetime import datetime -from typing import Any, Dict, List, Optional, TYPE_CHECKING - -if TYPE_CHECKING: - from .data import InspectorTab +from typing import Any, Dict, List, Optional from semantic_workbench_assistant import settings from semantic_workbench_assistant.assistant_app import ConversationContext @@ -264,7 +261,6 @@ def get_all_information_requests(share_id: str) -> List[InformationRequest]: requests.sort(key=lambda r: r.updated_at, reverse=True) return requests - @staticmethod async def log_share_event( context: ConversationContext, diff --git a/assistants/knowledge-transfer-assistant/assistant/tools/progress_tracking.py b/assistants/knowledge-transfer-assistant/assistant/tools/progress_tracking.py index 14415c0a0..a9c6c3dc7 100644 --- a/assistants/knowledge-transfer-assistant/assistant/tools/progress_tracking.py +++ b/assistants/knowledge-transfer-assistant/assistant/tools/progress_tracking.py @@ -11,11 +11,12 @@ NewConversationMessage, ) +from assistant.domain.share_manager import ShareManager + from ..data import InspectorTab, LearningOutcomeAchievement, LogEntryType from assistant.domain import KnowledgeTransferManager from assistant.domain.knowledge_package_manager import KnowledgePackageManager from assistant.notifications import Notifications -from assistant.storage import ShareStorage from assistant.data import ConversationRole from .base import ToolsBase @@ -58,8 +59,8 @@ async def mark_learning_outcome_achieved(self, objective_id: str, outcome_id: st return "No knowledge brief found." # Get the knowledge package to access objectives - knowledge_package = ShareStorage.read_share(share_id) - if not knowledge_package or not knowledge_package.learning_objectives: + knowledge_package = await ShareManager.get_share(self.context) + if not knowledge_package or not knowledge_package: return "No learning objectives found." # Find the objective by ID @@ -114,12 +115,11 @@ async def mark_learning_outcome_achieved(self, objective_id: str, outcome_id: st knowledge_package.team_conversations[conversation_id].last_active_at = datetime.utcnow() # Save the updated knowledge package with the achieved outcome - ShareStorage.write_share(share_id, knowledge_package) + await ShareManager.set_share(self.context, knowledge_package) # Log the outcome achievement - await ShareStorage.log_share_event( + await ShareManager.log_share_event( context=self.context, - share_id=share_id, entry_type=LogEntryType.OUTCOME_ATTAINED.value, message=f"Learning outcome achieved: {outcome.description}", related_entity_id=None, @@ -127,8 +127,6 @@ async def mark_learning_outcome_achieved(self, objective_id: str, outcome_id: st ) # Update knowledge package - knowledge_package = ShareStorage.read_share(share_id) - if knowledge_package: # Update metadata knowledge_package.updated_at = datetime.utcnow() @@ -136,7 +134,7 @@ async def mark_learning_outcome_achieved(self, objective_id: str, outcome_id: st knowledge_package.version += 1 # Save the updated knowledge package - ShareStorage.write_share(share_id, knowledge_package) + await ShareManager.set_share(self.context, knowledge_package) # Notify linked conversations with a message await Notifications.notify_all( @@ -150,7 +148,7 @@ async def mark_learning_outcome_achieved(self, objective_id: str, outcome_id: st # Check if all outcomes are achieved for transfer completion # Get the knowledge package to check completion status - knowledge_package = ShareStorage.read_share(share_id) + knowledge_package = await ShareManager.get_share(self.context) if knowledge_package and KnowledgePackageManager._is_transfer_complete(knowledge_package): await self.context.send_messages( NewConversationMessage( @@ -193,7 +191,7 @@ async def report_transfer_completion(self) -> str: if not share_id: return "No knowledge package associated with this conversation. Unable to report transfer completion." - package = ShareStorage.read_share(share_id) + package = await ShareManager.get_share(self.context) if not package: return "No knowledge package found. Cannot complete transfer without package information." @@ -218,12 +216,11 @@ async def report_transfer_completion(self) -> str: package.updated_at = datetime.utcnow() package.updated_by = current_user_id package.version += 1 - ShareStorage.write_share(share_id, package) + await ShareManager.set_share(self.context, package) # Log the milestone transition - await ShareStorage.log_share_event( + await ShareManager.log_share_event( context=self.context, - share_id=share_id, entry_type=LogEntryType.SHARE_COMPLETED.value, message="Transfer marked as COMPLETED", metadata={"milestone": "transfer_completed"}, diff --git a/assistants/knowledge-transfer-assistant/assistant/tools/share_setup.py b/assistants/knowledge-transfer-assistant/assistant/tools/share_setup.py index f8da6f0cc..f778efe76 100644 --- a/assistants/knowledge-transfer-assistant/assistant/tools/share_setup.py +++ b/assistants/knowledge-transfer-assistant/assistant/tools/share_setup.py @@ -7,7 +7,7 @@ from datetime import datetime from assistant.domain import KnowledgeTransferManager -from assistant.storage import ShareStorage +from assistant.domain.share_manager import ShareManager from assistant.data import ConversationRole from .base import ToolsBase @@ -52,22 +52,17 @@ async def set_knowledge_organized(self, is_organized: bool) -> str: if self.role is not ConversationRole.COORDINATOR: return "Only Coordinator can mark knowledge as organized." - # Get share ID - share_id = await KnowledgeTransferManager.get_share_id(self.context) - if not share_id: - return "No knowledge package associated with this conversation." - # Get existing knowledge package - package = ShareStorage.read_share(share_id) - if not package: + share = await ShareManager.get_share(self.context) + if not share: return "No knowledge package found. Please create a knowledge brief first." # Update the knowledge organized flag - package.knowledge_organized = is_organized - package.updated_at = datetime.utcnow() + share.knowledge_organized = is_organized + share.updated_at = datetime.utcnow() # Save the updated package - ShareStorage.write_share(share_id, package) + await ShareManager.set_share(self.context, share) # Provide appropriate feedback if is_organized: @@ -91,10 +86,6 @@ async def update_brief(self, title: str, description: str) -> str: if self.role is not ConversationRole.COORDINATOR: return "Only Coordinator can create knowledge briefs." - share_id = await KnowledgeTransferManager.get_share_id(self.context) - if not share_id: - return "No knowledge package associated with this conversation. Please create a knowledge package first." - brief = await KnowledgeTransferManager.update_knowledge_brief( context=self.context, title=title, @@ -120,22 +111,16 @@ async def set_learning_intention(self, is_for_specific_outcomes: bool) -> str: if self.role is not ConversationRole.COORDINATOR: return "Only Coordinator can set learning intention." - # Get share ID - share_id = await KnowledgeTransferManager.get_share_id(self.context) - if not share_id: - return "No knowledge package associated with this conversation. Please create a knowledge brief first." - - # Get existing knowledge package - package = ShareStorage.read_share(share_id) - if not package: + share = await ShareManager.get_share(self.context) + if not share: return "No knowledge package found. Please create a knowledge brief first." # Update the intention - package.is_intended_to_accomplish_outcomes = is_for_specific_outcomes - package.updated_at = datetime.utcnow() + share.is_intended_to_accomplish_outcomes = is_for_specific_outcomes + share.updated_at = datetime.utcnow() # Save the updated package - ShareStorage.write_share(share_id, package) + await ShareManager.set_share(self.context, share) # Provide appropriate guidance based on the choice if is_for_specific_outcomes: diff --git a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/debug.py b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/debug.py index c3bf2d2f9..8c3f80914 100644 --- a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/debug.py +++ b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/debug.py @@ -12,7 +12,6 @@ from assistant.domain.share_manager import ShareManager from assistant.domain import KnowledgeTransferManager from assistant.domain.knowledge_package_manager import KnowledgePackageManager -from assistant.storage import ShareStorage class DebugInspector: @@ -87,7 +86,7 @@ async def _format_debug_info(self, share_id: str, context: ConversationContext) # Add share metadata for debugging try: - share = ShareStorage.read_share(share_id) + share = await ShareManager.get_share(context) if share: lines.append("## Share Metadata") lines.append("") diff --git a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/learning.py b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/learning.py index de6425636..66e5da05c 100644 --- a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/learning.py +++ b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/learning.py @@ -11,7 +11,6 @@ from assistant.domain.share_manager import ShareManager from assistant.domain.knowledge_package_manager import KnowledgePackageManager -from assistant.storage import ShareStorage from assistant.data import ConversationRole @@ -44,7 +43,7 @@ async def get(self, context: ConversationContext) -> AssistantConversationInspec data={"content": "No active knowledge package. Start a conversation to create one."} ) - share = ShareStorage.read_share(share_id) + share = await ShareManager.get_share(context) if conversation_role == ConversationRole.COORDINATOR: markdown = await self._format_coordinator_objectives(share, context) From e2092ac5a0ca78643e5a285094c5589d9455000d Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Tue, 29 Jul 2025 09:32:24 -0700 Subject: [PATCH 06/21] Remove Clauded aggregate model. --- .../assistant/agentic/coordinator_support.py | 10 +- .../assistant/assistant.py | 67 ++++----- .../assistant/domain/__init__.py | 56 +------- .../domain/knowledge_package_manager.py | 128 ------------------ .../domain/knowledge_transfer_manager.py | 48 +++++++ .../domain/learning_objectives_manager.py | 39 +++++- .../assistant/domain/share_manager.py | 72 +++++----- .../assistant/respond.py | 14 +- .../assistant/tools/information_requests.py | 17 +-- .../assistant/tools/progress_tracking.py | 40 +++--- .../assistant/tools/share_setup.py | 8 +- .../assistant/ui_tabs/brief.py | 13 +- .../assistant/ui_tabs/common.py | 10 +- .../assistant/ui_tabs/debug.py | 16 ++- .../assistant/ui_tabs/learning.py | 14 +- .../assistant/ui_tabs/sharing.py | 10 +- .../tests/test_artifact_loading.py | 10 +- .../tests/test_share_manager.py | 9 +- .../tests/test_share_storage.py | 6 +- 19 files changed, 245 insertions(+), 342 deletions(-) delete mode 100644 assistants/knowledge-transfer-assistant/assistant/domain/knowledge_package_manager.py create mode 100644 assistants/knowledge-transfer-assistant/assistant/domain/knowledge_transfer_manager.py diff --git a/assistants/knowledge-transfer-assistant/assistant/agentic/coordinator_support.py b/assistants/knowledge-transfer-assistant/assistant/agentic/coordinator_support.py index fa712d53e..1075b480e 100644 --- a/assistants/knowledge-transfer-assistant/assistant/agentic/coordinator_support.py +++ b/assistants/knowledge-transfer-assistant/assistant/agentic/coordinator_support.py @@ -9,7 +9,7 @@ from semantic_workbench_assistant.assistant_app import ConversationContext from ..data import RequestStatus -from ..domain.knowledge_package_manager import KnowledgePackageManager +from ..domain.knowledge_transfer_manager import KnowledgeTransferManager from ..logging import logger from ..domain.share_manager import ShareManager @@ -74,14 +74,14 @@ async def get_coordinator_next_action_suggestion(context: ConversationContext) - return f"The learning objective '{name}' doesn't have any outcomes yet. Let's define what your team should accomplish to meet it." # 7. Ready for transfer but not yet shared - if KnowledgePackageManager.is_ready_for_transfer( + if KnowledgeTransferManager.is_ready_for_transfer( package - ) and not KnowledgePackageManager.is_actively_sharing(package): + ) and not KnowledgeTransferManager.is_actively_sharing(package): return "Your knowledge package is ready to share. Would you like to create a message and generate the invitation link?" # 8. Actively sharing - monitor and support ongoing transfer - if KnowledgePackageManager.is_actively_sharing(package): - if package.is_intended_to_accomplish_outcomes and not KnowledgePackageManager._is_transfer_complete( + if KnowledgeTransferManager.is_actively_sharing(package): + if package.is_intended_to_accomplish_outcomes and not KnowledgeTransferManager._is_transfer_complete( package ): team_count = len(package.team_conversations) diff --git a/assistants/knowledge-transfer-assistant/assistant/assistant.py b/assistants/knowledge-transfer-assistant/assistant/assistant.py index 43c76361f..da7644caf 100644 --- a/assistants/knowledge-transfer-assistant/assistant/assistant.py +++ b/assistants/knowledge-transfer-assistant/assistant/assistant.py @@ -26,13 +26,14 @@ ConversationContext, ) +from assistant.domain.knowledge_digest_manager import KnowledgeDigestManager + from .agentic.team_welcome import generate_team_welcome_message from .config import assistant_config from .domain.share_manager import ShareManager from .data import InspectorTab, LogEntryType from .files import ShareFilesManager from .logging import logger -from .domain import KnowledgeTransferManager from .domain.share_manager import ShareManager from .notifications import Notifications from .respond import respond_to_conversation @@ -163,10 +164,10 @@ async def on_conversation_created(context: ConversationContext) -> None: case ConversationRole.COORDINATOR: try: # In the beginning, we created a share... - share_id = await KnowledgeTransferManager.create_share(context) + share_id = await ShareManager.create_share(context) # 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( + share_url = await ShareManager.create_shareable_team_conversation( context=context, share_id=share_id ) @@ -240,23 +241,25 @@ async def on_message_created( } try: - share_id = await KnowledgeTransferManager.get_share_id(context) - metadata["debug"]["share_id"] = share_id + share = await ShareManager.get_share(context) + if not share: + return + + metadata["debug"]["share_id"] = share.share_id # If this is a Coordinator conversation, store the message for Team access async with context.set_status("jotting..."): role = await ShareManager.get_conversation_role(context) if role == ConversationRole.COORDINATOR and message.message_type == MessageType.chat: try: - if share_id: - # Get the sender's name - sender_name = "Coordinator" - if message.sender: - participants = await context.get_participants() - for participant in participants.participants: - if participant.id == message.sender.participant_id: - sender_name = participant.name - break + # Get the sender's name + sender_name = "Coordinator" + if message.sender: + participants = await context.get_participants() + for participant in participants.participants: + if participant.id == message.sender.participant_id: + sender_name = participant.name + break # Store the message for Team access await ShareManager.append_coordinator_message( @@ -292,7 +295,7 @@ async def on_message_created( # If the message is from a Coordinator, update the whiteboard in the background if role == ConversationRole.COORDINATOR and message.message_type == MessageType.chat: - asyncio.create_task(KnowledgeTransferManager.auto_update_knowledge_digest(context)) + asyncio.create_task(KnowledgeDigestManager.auto_update_knowledge_digest(context)) except Exception as e: logger.exception(f"Error handling message: {e}") @@ -347,9 +350,9 @@ async def on_file_created( 1. Use as-is without copying to share storage """ try: - share_id = await KnowledgeTransferManager.get_share_id(context) - if not share_id or not file.filename: - logger.warning(f"No share ID found or missing filename: share_id={share_id}, filename={file.filename}") + share = await ShareManager.get_share(context) + if not share or not file.filename: + logger.warning(f"No share found or missing filename. filename={file.filename}") return role = await ShareManager.get_conversation_role(context) @@ -361,7 +364,7 @@ async def on_file_created( success = await ShareFilesManager.copy_file_to_share_storage( context=context, - share_id=share_id, + share_id=share.share_id, file=file, is_coordinator_file=True, ) @@ -372,19 +375,19 @@ async def on_file_created( # 2. Synchronize to all Team conversations # Get all Team conversations - team_conversations = await ShareFilesManager.get_team_conversations(context, share_id) + team_conversations = await ShareFilesManager.get_team_conversations(context, share.share_id) if team_conversations: for team_conv_id in team_conversations: await ShareFilesManager.copy_file_to_conversation( context=context, - share_id=share_id, + share_id=share.share_id, filename=file.filename, target_conversation_id=team_conv_id, ) # 3. Update all UIs but don't send notifications to reduce noise - await Notifications.notify_all_state_update(context, share_id, [InspectorTab.DEBUG]) + await Notifications.notify_all_state_update(context, share.share_id, [InspectorTab.DEBUG]) # Team files don't need special handling as they're already in the conversation # Log file creation to knowledge transfer log for all files @@ -411,8 +414,8 @@ async def on_file_updated( ) -> None: try: # Get share ID - share_id = await KnowledgeTransferManager.get_share_id(context) - if not share_id or not file.filename: + share = await ShareManager.get_share(context) + if not share or not file.filename: return role = await ShareManager.get_conversation_role(context) @@ -421,7 +424,7 @@ async def on_file_updated( # 1. Update in share storage success = await ShareFilesManager.copy_file_to_share_storage( context=context, - share_id=share_id, + share_id=share.share_id, file=file, is_coordinator_file=True, ) @@ -430,17 +433,17 @@ async def on_file_updated( logger.error(f"Failed to update file in share storage: {file.filename}") return - team_conversations = await ShareFilesManager.get_team_conversations(context, share_id) + team_conversations = await ShareFilesManager.get_team_conversations(context, share.share_id) for team_conv_id in team_conversations: await ShareFilesManager.copy_file_to_conversation( context=context, - share_id=share_id, + share_id=share.share_id, filename=file.filename, target_conversation_id=team_conv_id, ) # 3. Update all UIs but don't send notifications to reduce noise - await Notifications.notify_all_state_update(context, share_id, [InspectorTab.DEBUG]) + await Notifications.notify_all_state_update(context, share.share_id, [InspectorTab.DEBUG]) await ShareManager.log_share_event( context=context, @@ -465,8 +468,8 @@ async def on_file_deleted( ) -> None: try: # Get share ID - share_id = await KnowledgeTransferManager.get_share_id(context) - if not share_id or not file.filename: + share = await ShareManager.get_share(context) + if not share or not file.filename: return role = await ShareManager.get_conversation_role(context) @@ -474,14 +477,14 @@ async def on_file_deleted( # For Coordinator files: # 1. Delete from share storage success = await ShareFilesManager.delete_file_from_knowledge_share_storage( - context=context, share_id=share_id, filename=file.filename + context=context, share_id=share.share_id, filename=file.filename ) if not success: logger.error(f"Failed to delete file from share storage: {file.filename}") # 2. Update all UIs about the deletion but don't send notifications to reduce noise - await Notifications.notify_all_state_update(context, share_id, [InspectorTab.DEBUG]) + await Notifications.notify_all_state_update(context, share.share_id, [InspectorTab.DEBUG]) # Team files don't need special handling await ShareManager.log_share_event( diff --git a/assistants/knowledge-transfer-assistant/assistant/domain/__init__.py b/assistants/knowledge-transfer-assistant/assistant/domain/__init__.py index b07bb51b8..021539407 100644 --- a/assistants/knowledge-transfer-assistant/assistant/domain/__init__.py +++ b/assistants/knowledge-transfer-assistant/assistant/domain/__init__.py @@ -10,62 +10,8 @@ from .learning_objectives_manager import LearningObjectivesManager from .audience_manager import AudienceManager from .share_manager import ShareManager +from .knowledge_transfer_manager import KnowledgeTransferManager - -class KnowledgeTransferManager: - """ - Manages the creation, modification, and lifecycle of knowledge transfer packages. - - The KnowledgeTransferManager provides a centralized set of operations for working with project data. - It handles all the core business logic for interacting with projects, ensuring that - operations are performed consistently and following the proper rules and constraints. - - This class implements the primary interface for both Coordinators and team members to interact - with project entities like briefs, information requests, and knowledge bases. It abstracts - away the storage details and provides a clean API for project operations. - - All methods are implemented as static methods to facilitate easy calling from - different parts of the codebase without requiring instance creation. - """ - - # Share/Project Management - create_shareable_team_conversation = ShareManager.create_shareable_team_conversation - create_share = ShareManager.create_share - join_share = ShareManager.join_share - get_share_id = ShareManager.get_share_id - get_share_log = ShareManager.get_share_log - get_share = ShareManager.get_share - log_share_event = ShareManager.log_share_event - - # Knowledge Brief Operations - get_knowledge_brief = KnowledgeBriefManager.get_knowledge_brief - update_knowledge_brief = KnowledgeBriefManager.update_knowledge_brief - - # Learning Objectives & Outcomes - add_learning_objective = LearningObjectivesManager.add_learning_objective - update_learning_objective = LearningObjectivesManager.update_learning_objective - delete_learning_objective = LearningObjectivesManager.delete_learning_objective - get_learning_outcomes = LearningObjectivesManager.get_learning_outcomes - add_learning_outcome = LearningObjectivesManager.add_learning_outcome - update_learning_outcome = LearningObjectivesManager.update_learning_outcome - delete_learning_outcome = LearningObjectivesManager.delete_learning_outcome - - # Information Request Management - get_information_requests = InformationRequestManager.get_information_requests - create_information_request = InformationRequestManager.create_information_request - resolve_information_request = InformationRequestManager.resolve_information_request - delete_information_request = InformationRequestManager.delete_information_request - - # Knowledge Digest Operations - get_knowledge_digest = KnowledgeDigestManager.get_knowledge_digest - update_knowledge_digest = KnowledgeDigestManager.update_knowledge_digest - auto_update_knowledge_digest = KnowledgeDigestManager.auto_update_knowledge_digest - - # Project Lifecycle Management - update_audience = AudienceManager.update_audience - - -# Export individual managers for direct access if needed __all__ = [ "KnowledgeTransferManager", "InformationRequestManager", diff --git a/assistants/knowledge-transfer-assistant/assistant/domain/knowledge_package_manager.py b/assistants/knowledge-transfer-assistant/assistant/domain/knowledge_package_manager.py deleted file mode 100644 index 2c2bbd230..000000000 --- a/assistants/knowledge-transfer-assistant/assistant/domain/knowledge_package_manager.py +++ /dev/null @@ -1,128 +0,0 @@ -""" -Knowledge Package Service for business logic operations. - -Provides business logic methods for KnowledgePackage state assessment, -presentation, achievement tracking, and conversation management. -""" - -from typing import List, Optional, Tuple - -from ..data import KnowledgePackage, LearningOutcomeAchievement - - -class KnowledgePackageManager: - """Manager class for KnowledgePackage business logic operations.""" - - @staticmethod - def is_ready_for_transfer(package: KnowledgePackage) -> bool: - has_basic_requirements = ( - package.knowledge_organized and package.brief is not None and package.audience is not None - ) - - if not has_basic_requirements: - return False - - if not package.is_intended_to_accomplish_outcomes: - return True - - return bool(package.learning_objectives) and any( - bool(obj.learning_outcomes) for obj in package.learning_objectives - ) - - @staticmethod - def is_actively_sharing(package: KnowledgePackage) -> bool: - return KnowledgePackageManager.is_ready_for_transfer(package) and len(package.team_conversations) > 0 - - @staticmethod - def get_achievements_for_conversation( - package: KnowledgePackage, conversation_id: str - ) -> List[LearningOutcomeAchievement]: - team_conv = package.team_conversations.get(conversation_id) - return team_conv.outcome_achievements if team_conv else [] - - @staticmethod - def get_completion_for_conversation(package: KnowledgePackage, conversation_id: str) -> Tuple[int, int]: - achievements = KnowledgePackageManager.get_achievements_for_conversation(package, conversation_id) - achieved_outcome_ids = {a.outcome_id for a in achievements if a.achieved} - - total_outcomes = sum(len(obj.learning_outcomes) for obj in package.learning_objectives) - achieved_outcomes = len(achieved_outcome_ids) - - return achieved_outcomes, total_outcomes - - @staticmethod - def is_outcome_achieved_by_conversation(package: KnowledgePackage, outcome_id: str, conversation_id: str) -> bool: - achievements = KnowledgePackageManager.get_achievements_for_conversation(package, conversation_id) - return any(a.outcome_id == outcome_id and a.achieved for a in achievements) - - @staticmethod - def get_overall_completion(package: KnowledgePackage) -> Tuple[int, int]: - """ - Get overall completion across all team conversations. - Returns: - Tuple of (unique_achieved_outcomes, total_outcomes) across all team conversations - """ - all_achieved_outcomes = set() - for team_conv in package.team_conversations.values(): - achieved_ids = {a.outcome_id for a in team_conv.outcome_achievements if a.achieved} - all_achieved_outcomes.update(achieved_ids) - - total_outcomes = sum(len(obj.learning_outcomes) for obj in package.learning_objectives) - return len(all_achieved_outcomes), total_outcomes - - @staticmethod - def get_all_linked_conversations(package: KnowledgePackage, exclude_current: Optional[str] = None) -> List[str]: - """ - Get all conversations linked to this knowledge package. - Returns: - List of conversation IDs (coordinator, shared template, and all team conversations) - """ - conversations = [] - - # Add coordinator conversation - if package.coordinator_conversation_id and package.coordinator_conversation_id != exclude_current: - conversations.append(package.coordinator_conversation_id) - - # Add shared template conversation (though usually excluded from notifications) - if package.shared_conversation_id and package.shared_conversation_id != exclude_current: - conversations.append(package.shared_conversation_id) - - # Add all team conversations - for conversation_id in package.team_conversations.keys(): - if conversation_id != exclude_current: - conversations.append(conversation_id) - - return conversations - - @staticmethod - def get_notification_conversations(package: KnowledgePackage, exclude_current: Optional[str] = None) -> List[str]: - """ - Get conversations that should receive notifications (excludes shared template). - Returns: - List of conversation IDs that should receive notifications - """ - conversations = [] - - # Add coordinator conversation - if package.coordinator_conversation_id and package.coordinator_conversation_id != exclude_current: - conversations.append(package.coordinator_conversation_id) - - # Add all team conversations (but NOT shared template) - for conversation_id in package.team_conversations.keys(): - if conversation_id != exclude_current: - conversations.append(conversation_id) - - return conversations - - @staticmethod - def _is_transfer_complete(package: KnowledgePackage) -> bool: - """ - Check if knowledge transfer is complete (all outcomes achieved by at least one team member). - Returns: - True if all learning outcomes have been achieved by at least one team conversation - """ - if not package.is_intended_to_accomplish_outcomes: - return False - - achieved_outcomes, total_outcomes = KnowledgePackageManager.get_overall_completion(package) - return total_outcomes > 0 and achieved_outcomes == total_outcomes diff --git a/assistants/knowledge-transfer-assistant/assistant/domain/knowledge_transfer_manager.py b/assistants/knowledge-transfer-assistant/assistant/domain/knowledge_transfer_manager.py new file mode 100644 index 000000000..87b851e04 --- /dev/null +++ b/assistants/knowledge-transfer-assistant/assistant/domain/knowledge_transfer_manager.py @@ -0,0 +1,48 @@ +""" +Knowledge Package Service for business logic operations. + +Provides business logic methods for KnowledgePackage state assessment, +presentation, achievement tracking, and conversation management. +""" + + +from assistant.domain.learning_objectives_manager import LearningObjectivesManager + +from ..data import KnowledgePackage + + +class KnowledgeTransferManager: + """Manager class for KnowledgePackage business logic operations.""" + + @staticmethod + def is_ready_for_transfer(package: KnowledgePackage) -> bool: + has_basic_requirements = ( + package.knowledge_organized and package.brief is not None and package.audience is not None + ) + + if not has_basic_requirements: + return False + + if not package.is_intended_to_accomplish_outcomes: + return True + + return bool(package.learning_objectives) and any( + bool(obj.learning_outcomes) for obj in package.learning_objectives + ) + + @staticmethod + def is_actively_sharing(package: KnowledgePackage) -> bool: + return KnowledgeTransferManager.is_ready_for_transfer(package) and len(package.team_conversations) > 0 + + @staticmethod + def _is_transfer_complete(package: KnowledgePackage) -> bool: + """ + Check if knowledge transfer is complete (all outcomes achieved by at least one team member). + Returns: + True if all learning outcomes have been achieved by at least one team conversation + """ + if not package.is_intended_to_accomplish_outcomes: + return False + + achieved_outcomes, total_outcomes = LearningObjectivesManager.get_overall_completion(package) + return total_outcomes > 0 and achieved_outcomes == total_outcomes diff --git a/assistants/knowledge-transfer-assistant/assistant/domain/learning_objectives_manager.py b/assistants/knowledge-transfer-assistant/assistant/domain/learning_objectives_manager.py index 8eba31a42..17e4de299 100644 --- a/assistants/knowledge-transfer-assistant/assistant/domain/learning_objectives_manager.py +++ b/assistants/knowledge-transfer-assistant/assistant/domain/learning_objectives_manager.py @@ -8,7 +8,7 @@ from semantic_workbench_assistant.assistant_app import ConversationContext -from ..data import InspectorTab, KnowledgePackage, LearningObjective, LearningOutcome, LogEntryType +from ..data import InspectorTab, KnowledgePackage, LearningObjective, LearningOutcome, LogEntryType, LearningOutcomeAchievement from ..logging import logger from ..notifications import Notifications from ..utils import require_current_user @@ -437,3 +437,40 @@ async def delete_learning_outcome( await Notifications.notify_all_state_update(context, share_id, [InspectorTab.LEARNING, InspectorTab.BRIEF]) return True, f"Learning outcome deleted successfully from objective '{objective.name}': {deleted_description}" + + @staticmethod + def get_achievements_for_conversation( + package: KnowledgePackage, conversation_id: str + ) -> List[LearningOutcomeAchievement]: + team_conv = package.team_conversations.get(conversation_id) + return team_conv.outcome_achievements if team_conv else [] + + @staticmethod + def get_completion_for_conversation(package: KnowledgePackage, conversation_id: str) -> Tuple[int, int]: + achievements = LearningObjectivesManager.get_achievements_for_conversation(package, conversation_id) + achieved_outcome_ids = {a.outcome_id for a in achievements if a.achieved} + + total_outcomes = sum(len(obj.learning_outcomes) for obj in package.learning_objectives) + achieved_outcomes = len(achieved_outcome_ids) + + return achieved_outcomes, total_outcomes + + @staticmethod + def is_outcome_achieved_by_conversation(package: KnowledgePackage, outcome_id: str, conversation_id: str) -> bool: + achievements = LearningObjectivesManager.get_achievements_for_conversation(package, conversation_id) + return any(a.outcome_id == outcome_id and a.achieved for a in achievements) + + @staticmethod + def get_overall_completion(package: KnowledgePackage) -> Tuple[int, int]: + """ + Get overall completion across all team conversations. + Returns: + Tuple of (unique_achieved_outcomes, total_outcomes) across all team conversations + """ + all_achieved_outcomes = set() + for team_conv in package.team_conversations.values(): + achieved_ids = {a.outcome_id for a in team_conv.outcome_achievements if a.achieved} + all_achieved_outcomes.update(achieved_ids) + + total_outcomes = sum(len(obj.learning_outcomes) for obj in package.learning_objectives) + return len(all_achieved_outcomes), total_outcomes \ No newline at end of file diff --git a/assistants/knowledge-transfer-assistant/assistant/domain/share_manager.py b/assistants/knowledge-transfer-assistant/assistant/domain/share_manager.py index be45c8c30..3104556d8 100644 --- a/assistants/knowledge-transfer-assistant/assistant/domain/share_manager.py +++ b/assistants/knowledge-transfer-assistant/assistant/domain/share_manager.py @@ -15,7 +15,6 @@ ) from semantic_workbench_assistant.assistant_app import ConversationContext -from assistant.domain.knowledge_package_manager import KnowledgePackageManager from ..data import ( ConversationShareInfo, @@ -86,6 +85,28 @@ async def create_share(context: ConversationContext) -> str: return share_id + @staticmethod + async def set_conversation_role(context: ConversationContext, share_id: str, role: ConversationRole) -> None: + """ + Sets the role of a conversation in a knowledge transfer share. + """ + role_data = ConversationShareInfo(share_id=share_id, role=role, conversation_id=str(context.id)) + role_path = ShareStorageManager.get_conversation_role_file_path(context) + write_model(role_path, role_data) + + @staticmethod + async def get_conversation_role(context: ConversationContext) -> Optional[ConversationRole]: + """ + Gets the role of a conversation in a knowledge transfer. + """ + role_path = ShareStorageManager.get_conversation_role_file_path(context) + role_data = read_model(role_path, ConversationShareInfo) + + if role_data: + return role_data.role + + return None + @staticmethod async def create_shareable_team_conversation(context: ConversationContext, share_id: str) -> str: """ @@ -266,48 +287,31 @@ async def get_linked_conversations(context: ConversationContext) -> List[str]: Gets all conversations linked to this one through the same knowledge transfer share. """ try: - share_id = await ShareManager.get_share_id(context) - if not share_id: - return [] - - # Load the knowledge package - from ..storage import ShareStorage - - knowledge_package = ShareStorage.read_share(share_id) - if not knowledge_package: + share = await ShareManager.get_share(context) + if not share: return [] # Get all linked conversations, excluding current conversation conversation_id = str(context.id) - return KnowledgePackageManager.get_all_linked_conversations( - knowledge_package, exclude_current=conversation_id - ) - except Exception as e: - logger.error(f"Error getting linked conversations: {e}") - return [] + conversations = [] + # Add coordinator conversation + if share.coordinator_conversation_id: + conversations.append(share.coordinator_conversation_id) - @staticmethod - async def set_conversation_role(context: ConversationContext, share_id: str, role: ConversationRole) -> None: - """ - Sets the role of a conversation in a knowledge transfer share. - """ - role_data = ConversationShareInfo(share_id=share_id, role=role, conversation_id=str(context.id)) - role_path = ShareStorageManager.get_conversation_role_file_path(context) - write_model(role_path, role_data) + # Add shared template conversation (though usually excluded from notifications) + if share.shared_conversation_id and share.shared_conversation_id: + conversations.append(share.shared_conversation_id) - @staticmethod - async def get_conversation_role(context: ConversationContext) -> Optional[ConversationRole]: - """ - Gets the role of a conversation in a knowledge transfer. - """ - role_path = ShareStorageManager.get_conversation_role_file_path(context) - role_data = read_model(role_path, ConversationShareInfo) + # Add all team conversations + for conversation_id in share.team_conversations.keys(): + conversations.append(conversation_id) - if role_data: - return role_data.role + return [] - return None + except Exception as e: + logger.error(f"Error getting linked conversations: {e}") + return [] @staticmethod async def _capture_redeemer_info(context: ConversationContext, share_id: str) -> None: diff --git a/assistants/knowledge-transfer-assistant/assistant/respond.py b/assistants/knowledge-transfer-assistant/assistant/respond.py index 214661f67..085779362 100644 --- a/assistants/knowledge-transfer-assistant/assistant/respond.py +++ b/assistants/knowledge-transfer-assistant/assistant/respond.py @@ -26,14 +26,13 @@ ConversationContext, ) +from assistant.domain.learning_objectives_manager import LearningObjectivesManager from assistant.domain.share_manager import ShareManager from .agentic.analysis import detect_information_request_needs from .agentic.coordinator_support import CoordinatorSupport from .config import assistant_config from .data import ConversationRole, CoordinatorConversationMessage, RequestStatus -from .domain import KnowledgeTransferManager -from .domain.knowledge_package_manager import KnowledgePackageManager from .logging import logger from .string_utils import Context, ContextStrategy, Instructions, Prompt, TokenBudget from .tools import ShareTools @@ -118,9 +117,6 @@ async def respond_to_conversation( # Requirements role = await ShareManager.get_conversation_role(context) or ConversationRole.COORDINATOR metadata["debug"]["role"] = role - share_id = await KnowledgeTransferManager.get_share_id(context) - if not share_id: - raise ValueError("Project ID not found in context") token_budget = TokenBudget(config.request_config.max_tokens) @@ -232,14 +228,14 @@ async def respond_to_conversation( # Show progress based on role if role == ConversationRole.COORDINATOR: # Coordinator sees overall progress across all team members - achieved_overall, total_overall = KnowledgePackageManager.get_overall_completion(share) + achieved_overall, total_overall = LearningObjectivesManager.get_overall_completion(share) learning_objectives_text += ( f"Overall Progress: {achieved_overall}/{total_overall} outcomes achieved by team members\n\n" ) else: # Team member sees their personal progress if conversation_id in share.team_conversations: - achieved_personal, total_personal = KnowledgePackageManager.get_completion_for_conversation( + achieved_personal, total_personal = LearningObjectivesManager.get_completion_for_conversation( share, conversation_id ) progress_pct = int((achieved_personal / total_personal * 100)) if total_personal > 0 else 0 @@ -254,13 +250,13 @@ async def respond_to_conversation( if role == ConversationRole.COORDINATOR: # Show if achieved by any team member achieved_by_any = any( - KnowledgePackageManager.is_outcome_achieved_by_conversation(share, criterion.id, conv_id) + LearningObjectivesManager.is_outcome_achieved_by_conversation(share, criterion.id, conv_id) for conv_id in share.team_conversations.keys() ) check = "✅" if achieved_by_any else "⬜" else: # Show if achieved by this team member - achieved_by_me = KnowledgePackageManager.is_outcome_achieved_by_conversation( + achieved_by_me = LearningObjectivesManager.is_outcome_achieved_by_conversation( share, criterion.id, conversation_id ) check = "✅" if achieved_by_me else "⬜" diff --git a/assistants/knowledge-transfer-assistant/assistant/tools/information_requests.py b/assistants/knowledge-transfer-assistant/assistant/tools/information_requests.py index 461bfff7e..7dbb3a33e 100644 --- a/assistants/knowledge-transfer-assistant/assistant/tools/information_requests.py +++ b/assistants/knowledge-transfer-assistant/assistant/tools/information_requests.py @@ -7,8 +7,9 @@ from typing import Literal from assistant.data import RequestPriority +from assistant.domain.information_request_manager import InformationRequestManager +from assistant.domain.share_manager import ShareManager from assistant.logging import logger -from assistant.domain import KnowledgeTransferManager from assistant.data import ConversationRole from .base import ToolsBase @@ -46,8 +47,8 @@ async def create_information_request( if self.role is not ConversationRole.TEAM: return "Only Team members can create information requests." - share_id = await KnowledgeTransferManager.get_share_id(self.context) - if not share_id: + share = await ShareManager.get_share(self.context) + if not share: return "No knowledge package associated with this conversation. Unable to create information request." priority_map = { @@ -58,7 +59,7 @@ async def create_information_request( } priority_enum = priority_map.get(priority.lower(), RequestPriority.MEDIUM) - success, request = await KnowledgeTransferManager.create_information_request( + success, request = await InformationRequestManager.create_information_request( context=self.context, title=title, description=description, priority=priority_enum ) if success and request: @@ -93,11 +94,11 @@ async def resolve_information_request(self, request_id: str, resolution: str) -> logger.warning(f"Team member attempted to use resolve_information_request: {request_id}") return error_message - share_id = await KnowledgeTransferManager.get_share_id(self.context) - if not share_id: + share = await ShareManager.get_share(self.context) + if not share: return "No knowledge package associated with this conversation. Unable to resolve information request." - success, information_request = await KnowledgeTransferManager.resolve_information_request( + success, information_request = await InformationRequestManager.resolve_information_request( context=self.context, request_id=request_id, resolution=resolution ) if success and information_request: @@ -120,7 +121,7 @@ async def delete_information_request(self, request_id: str) -> str: if self.role is not ConversationRole.TEAM: return "This tool is only available to Team members." - success, message = await KnowledgeTransferManager.delete_information_request( + success, message = await InformationRequestManager.delete_information_request( context=self.context, request_id=request_id ) return message if message else ("Request deleted successfully." if success else "Failed to delete request.") diff --git a/assistants/knowledge-transfer-assistant/assistant/tools/progress_tracking.py b/assistants/knowledge-transfer-assistant/assistant/tools/progress_tracking.py index a9c6c3dc7..257e0c5cd 100644 --- a/assistants/knowledge-transfer-assistant/assistant/tools/progress_tracking.py +++ b/assistants/knowledge-transfer-assistant/assistant/tools/progress_tracking.py @@ -11,11 +11,11 @@ NewConversationMessage, ) +from assistant.domain.learning_objectives_manager import LearningObjectivesManager from assistant.domain.share_manager import ShareManager from ..data import InspectorTab, LearningOutcomeAchievement, LogEntryType -from assistant.domain import KnowledgeTransferManager -from assistant.domain.knowledge_package_manager import KnowledgePackageManager +from assistant.domain.knowledge_transfer_manager import KnowledgeTransferManager from assistant.notifications import Notifications from assistant.data import ConversationRole from .base import ToolsBase @@ -49,12 +49,12 @@ async def mark_learning_outcome_achieved(self, objective_id: str, outcome_id: st return "Only Team members can mark criteria as completed." # Get share ID - share_id = await KnowledgeTransferManager.get_share_id(self.context) - if not share_id: + share = await ShareManager.get_share(self.context) + if not share: return "No knowledge package associated with this conversation. Unable to mark outcome as achieved." # Get existing knowledge brief - brief = await KnowledgeTransferManager.get_knowledge_brief(self.context) + brief = share.brief if not brief: return "No knowledge brief found." @@ -86,7 +86,7 @@ async def mark_learning_outcome_achieved(self, objective_id: str, outcome_id: st conversation_id = str(self.context.id) # Check if already achieved by this conversation - if KnowledgePackageManager.is_outcome_achieved_by_conversation(knowledge_package, outcome.id, conversation_id): + if LearningObjectivesManager.is_outcome_achieved_by_conversation(knowledge_package, outcome.id, conversation_id): return f"Outcome '{outcome.description}' is already marked as achieved by this team member." # Get current user information @@ -139,17 +139,17 @@ async def mark_learning_outcome_achieved(self, objective_id: str, outcome_id: st # Notify linked conversations with a message await Notifications.notify_all( self.context, - share_id, + share.share_id, f"Learning outcome '{outcome.description}' for objective '{objective.name}' has been marked as achieved.", ) await Notifications.notify_all_state_update( - self.context, share_id, [InspectorTab.LEARNING, InspectorTab.BRIEF] + self.context, share.share_id, [InspectorTab.LEARNING, InspectorTab.BRIEF] ) # Check if all outcomes are achieved for transfer completion # Get the knowledge package to check completion status knowledge_package = await ShareManager.get_share(self.context) - if knowledge_package and KnowledgePackageManager._is_transfer_complete(knowledge_package): + if knowledge_package and KnowledgeTransferManager._is_transfer_complete(knowledge_package): await self.context.send_messages( NewConversationMessage( content="🎉 All learning outcomes have been achieved! The knowledge transfer has been automatically marked as complete.", @@ -187,16 +187,12 @@ async def report_transfer_completion(self) -> str: if self.role is not ConversationRole.TEAM: return "Only Team members can report knowledge transfer completion." - share_id = await KnowledgeTransferManager.get_share_id(self.context) - if not share_id: - return "No knowledge package associated with this conversation. Unable to report transfer completion." - - package = await ShareManager.get_share(self.context) - if not package: + share = await ShareManager.get_share(self.context) + if not share: return "No knowledge package found. Cannot complete transfer without package information." # Check if all outcomes are achieved - achieved_outcomes, total_outcomes = KnowledgePackageManager.get_overall_completion(package) + achieved_outcomes, total_outcomes = LearningObjectivesManager.get_overall_completion(share) if achieved_outcomes < total_outcomes: remaining = total_outcomes - achieved_outcomes return f"Cannot complete knowledge transfer - {remaining} learning outcomes are still pending achievement." @@ -213,10 +209,10 @@ async def report_transfer_completion(self) -> str: if not current_user_id: return "Could not identify current user." - package.updated_at = datetime.utcnow() - package.updated_by = current_user_id - package.version += 1 - await ShareManager.set_share(self.context, package) + share.updated_at = datetime.utcnow() + share.updated_by = current_user_id + share.version += 1 + await ShareManager.set_share(self.context, share) # Log the milestone transition await ShareManager.log_share_event( @@ -229,10 +225,10 @@ async def report_transfer_completion(self) -> str: # Notify linked conversations with a message await Notifications.notify_all( self.context, - share_id, + share.share_id, "🎉 **Knowledge Transfer Complete**: Team has reported that all learning objectives have been achieved. The knowledge transfer is now complete.", ) - await Notifications.notify_all_state_update(self.context, share_id, [InspectorTab.BRIEF]) + await Notifications.notify_all_state_update(self.context, share.share_id, [InspectorTab.BRIEF]) await self.context.send_messages( NewConversationMessage( diff --git a/assistants/knowledge-transfer-assistant/assistant/tools/share_setup.py b/assistants/knowledge-transfer-assistant/assistant/tools/share_setup.py index f778efe76..270b1d99f 100644 --- a/assistants/knowledge-transfer-assistant/assistant/tools/share_setup.py +++ b/assistants/knowledge-transfer-assistant/assistant/tools/share_setup.py @@ -6,7 +6,9 @@ from datetime import datetime -from assistant.domain import KnowledgeTransferManager + +from assistant.domain.audience_manager import AudienceManager +from assistant.domain.knowledge_brief_manager import KnowledgeBriefManager from assistant.domain.share_manager import ShareManager from assistant.data import ConversationRole from .base import ToolsBase @@ -28,7 +30,7 @@ async def update_audience(self, audience_description: str) -> str: if self.role is not ConversationRole.COORDINATOR: return "Only Coordinator can update the audience description." - success, message = await KnowledgeTransferManager.update_audience( + success, message = await AudienceManager.update_audience( context=self.context, audience_description=audience_description, ) @@ -86,7 +88,7 @@ async def update_brief(self, title: str, description: str) -> str: if self.role is not ConversationRole.COORDINATOR: return "Only Coordinator can create knowledge briefs." - brief = await KnowledgeTransferManager.update_knowledge_brief( + brief = await KnowledgeBriefManager.update_knowledge_brief( context=self.context, title=title, description=description, diff --git a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/brief.py b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/brief.py index 049f70749..cf5ae4792 100644 --- a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/brief.py +++ b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/brief.py @@ -10,7 +10,6 @@ ) from assistant.domain.share_manager import ShareManager -from assistant.domain import KnowledgeTransferManager from assistant.data import ConversationRole from .common import get_stage_label @@ -42,19 +41,19 @@ async def get(self, context: ConversationContext) -> AssistantConversationInspec conversation_role = await ShareManager.get_conversation_role(context) # Get share information - share_id = await ShareManager.get_share_id(context) - if not share_id: + share = await ShareManager.get_share(context) + if not share: return AssistantConversationInspectorStateDataModel( data={"content": "No active knowledge package. Start a conversation to create one."} ) - brief = await KnowledgeTransferManager.get_knowledge_brief(context) - share_info = await KnowledgeTransferManager.get_share(context) + brief = share.brief + share_info = await ShareManager.get_share(context) if conversation_role == ConversationRole.COORDINATOR: - markdown = await self._format_coordinator_brief(share_id, brief, share_info, context) + markdown = await self._format_coordinator_brief(share.share_id, brief, share_info, context) else: - markdown = await self._format_team_brief(share_id, brief, share_info, context) + markdown = await self._format_team_brief(share.share_id, brief, share_info, context) return AssistantConversationInspectorStateDataModel(data={"content": markdown}) diff --git a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/common.py b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/common.py index eb66c5a1d..c588a124a 100644 --- a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/common.py +++ b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/common.py @@ -3,7 +3,7 @@ """ from assistant.data import KnowledgePackage, RequestPriority, RequestStatus -from assistant.domain.knowledge_package_manager import KnowledgePackageManager +from assistant.domain.knowledge_transfer_manager import KnowledgeTransferManager def get_status_emoji(status: RequestStatus) -> str: @@ -53,11 +53,11 @@ def get_stage_label(package: KnowledgePackage, for_coordinator: bool = True) -> return "📝 Creating Brief" elif package.is_intended_to_accomplish_outcomes and not package.learning_objectives: return "📚 Adding Objectives" - elif not KnowledgePackageManager.is_ready_for_transfer(package): + elif not KnowledgeTransferManager.is_ready_for_transfer(package): return "📋 Finalizing Setup" - elif package.is_intended_to_accomplish_outcomes and KnowledgePackageManager._is_transfer_complete(package): + elif package.is_intended_to_accomplish_outcomes and KnowledgeTransferManager._is_transfer_complete(package): return "✅ Transfer Complete" - elif KnowledgePackageManager.is_actively_sharing(package): + elif KnowledgeTransferManager.is_actively_sharing(package): return "📤 Sharing in Progress" else: return "🚀 Ready for Transfer" @@ -65,7 +65,7 @@ def get_stage_label(package: KnowledgePackage, for_coordinator: bool = True) -> # Team perspective if package.archived: return "📦 Archived" - elif not KnowledgePackageManager.is_ready_for_transfer(package): + elif not KnowledgeTransferManager.is_ready_for_transfer(package): return "⏳ Knowledge Being Organized" elif not package.is_intended_to_accomplish_outcomes: return "🔍 Exploring Knowledge" diff --git a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/debug.py b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/debug.py index 8c3f80914..6dcaa11ea 100644 --- a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/debug.py +++ b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/debug.py @@ -9,9 +9,9 @@ ConversationContext, ) +from assistant.domain.knowledge_digest_manager import KnowledgeDigestManager from assistant.domain.share_manager import ShareManager -from assistant.domain import KnowledgeTransferManager -from assistant.domain.knowledge_package_manager import KnowledgePackageManager +from assistant.domain.knowledge_transfer_manager import KnowledgeTransferManager class DebugInspector: @@ -57,7 +57,13 @@ async def _format_debug_info(self, share_id: str, context: ConversationContext) # Get the knowledge digest try: - digest = await KnowledgeTransferManager.get_knowledge_digest(context) + digest = await KnowledgeDigestManager.get_knowledge_digest(context) + if not digest: + lines.append("### Knowledge Digest") + lines.append("") + lines.append("No knowledge digest has been generated yet. The assistant will create and update this") + lines.append("automatically as the conversation develops.") + lines.append("") lines.append("## Knowledge Digest") lines.append("") @@ -100,8 +106,8 @@ async def _format_debug_info(self, share_id: str, context: ConversationContext) f"- **Learning Objectives:** {len(share.learning_objectives) if share.learning_objectives else 0}" ) lines.append(f"- **Knowledge Organized:** {share.knowledge_organized}") - lines.append(f"- **Ready for Transfer:** {KnowledgePackageManager.is_ready_for_transfer(share)}") - lines.append(f"- **Actively Sharing:** {KnowledgePackageManager.is_actively_sharing(share)}") + lines.append(f"- **Ready for Transfer:** {KnowledgeTransferManager.is_ready_for_transfer(share)}") + lines.append(f"- **Actively Sharing:** {KnowledgeTransferManager.is_actively_sharing(share)}") if share.coordinator_conversation_id: lines.append(f"- **Conversation ID:** `{share.coordinator_conversation_id}`") lines.append("") diff --git a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/learning.py b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/learning.py index 66e5da05c..88ddef789 100644 --- a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/learning.py +++ b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/learning.py @@ -9,8 +9,8 @@ ConversationContext, ) +from assistant.domain.learning_objectives_manager import LearningObjectivesManager from assistant.domain.share_manager import ShareManager -from assistant.domain.knowledge_package_manager import KnowledgePackageManager from assistant.data import ConversationRole @@ -73,7 +73,7 @@ async def _format_coordinator_objectives(self, share: Any, context: Conversation total_outcomes = sum(len(obj.learning_outcomes) for obj in share.learning_objectives if obj.learning_outcomes) if total_outcomes > 0 and share.team_conversations: for conv_id, team_conv in share.team_conversations.items(): - achieved, total = KnowledgePackageManager.get_completion_for_conversation(share, conv_id) + achieved, total = LearningObjectivesManager.get_completion_for_conversation(share, conv_id) progress_pct = int((achieved / total * 100)) if total > 0 else 0 lines.append(f"- **{team_conv.redeemer_name}**: {achieved}/{total} outcomes ({progress_pct}%)") lines.append("") @@ -90,7 +90,7 @@ async def _format_coordinator_objectives(self, share: Any, context: Conversation for criterion in objective.learning_outcomes: # Check if any team conversation has achieved this outcome achieved_by_any = any( - KnowledgePackageManager.is_outcome_achieved_by_conversation(share, criterion.id, conv_id) + LearningObjectivesManager.is_outcome_achieved_by_conversation(share, criterion.id, conv_id) for conv_id in share.team_conversations.keys() ) status_emoji = "✅" if achieved_by_any else "⬜" @@ -100,7 +100,7 @@ async def _format_coordinator_objectives(self, share: Any, context: Conversation total_team_count = len(share.team_conversations) for conv_id in share.team_conversations.keys(): - if KnowledgePackageManager.is_outcome_achieved_by_conversation(share, criterion.id, conv_id): + if LearningObjectivesManager.is_outcome_achieved_by_conversation(share, criterion.id, conv_id): achieved_count += 1 achievement_info = "" @@ -131,7 +131,7 @@ async def _format_team_objectives(self, share: Any, context: ConversationContext # Show my personal progress conversation_id = str(context.id) - achieved_outcomes, total_outcomes = KnowledgePackageManager.get_completion_for_conversation( + achieved_outcomes, total_outcomes = LearningObjectivesManager.get_completion_for_conversation( share, conversation_id ) progress_pct = int((achieved_outcomes / total_outcomes * 100)) if total_outcomes > 0 else 0 @@ -147,7 +147,7 @@ async def _format_team_objectives(self, share: Any, context: ConversationContext lines.append("#### Learning Outcomes") for criterion in objective.learning_outcomes: # Check if I've achieved this outcome - achieved_by_me = KnowledgePackageManager.is_outcome_achieved_by_conversation( + achieved_by_me = LearningObjectivesManager.is_outcome_achieved_by_conversation( share, criterion.id, conversation_id ) status_emoji = "✅" if achieved_by_me else "⬜" @@ -155,7 +155,7 @@ async def _format_team_objectives(self, share: Any, context: ConversationContext completion_info = "" if achieved_by_me: # Find my achievement record - my_achievements = KnowledgePackageManager.get_achievements_for_conversation( + my_achievements = LearningObjectivesManager.get_achievements_for_conversation( share, conversation_id ) for achievement in my_achievements: diff --git a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/sharing.py b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/sharing.py index cce40ea7a..4db0b432d 100644 --- a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/sharing.py +++ b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/sharing.py @@ -9,10 +9,10 @@ ConversationContext, ) +from assistant.domain.information_request_manager import InformationRequestManager +from assistant.domain.learning_objectives_manager import LearningObjectivesManager from assistant.domain.share_manager import ShareManager from assistant.data import RequestStatus -from assistant.domain import KnowledgeTransferManager -from assistant.domain.knowledge_package_manager import KnowledgePackageManager from assistant.data import ConversationRole from .common import get_priority_emoji, get_status_emoji @@ -46,7 +46,7 @@ async def get(self, context: ConversationContext) -> AssistantConversationInspec data={"content": "No active knowledge package. Start a conversation to create one."} ) - requests = await KnowledgeTransferManager.get_information_requests(context) + requests = await InformationRequestManager.get_information_requests(context) if conversation_role == ConversationRole.COORDINATOR: markdown = await self._format_coordinator_requests(requests, context) @@ -61,7 +61,7 @@ async def _format_coordinator_requests(self, requests: List[Any], context: Conve lines: List[str] = [] # Share URL section at the top - share = await KnowledgeTransferManager.get_share(context) + share = await ShareManager.get_share(context) share_url = share.share_url if share else None if share_url: lines.append("## Share Link") @@ -114,7 +114,7 @@ async def _format_coordinator_requests(self, requests: List[Any], context: Conve lines.append("") for conv_id, team_conv in share.team_conversations.items(): - achieved, total = KnowledgePackageManager.get_completion_for_conversation(share, conv_id) + achieved, total = LearningObjectivesManager.get_completion_for_conversation(share, conv_id) progress_pct = int((achieved / total * 100)) if total > 0 else 0 lines.append(f"- **{team_conv.redeemer_name}**: {achieved}/{total} outcomes ({progress_pct}%)") lines.append(f" Joined: {team_conv.joined_at.strftime('%Y-%m-%d %H:%M')}") diff --git a/assistants/knowledge-transfer-assistant/tests/test_artifact_loading.py b/assistants/knowledge-transfer-assistant/tests/test_artifact_loading.py index 6173f8dd0..48003490a 100644 --- a/assistants/knowledge-transfer-assistant/tests/test_artifact_loading.py +++ b/assistants/knowledge-transfer-assistant/tests/test_artifact_loading.py @@ -10,9 +10,8 @@ import uuid from typing import Any, TypeVar -from assistant.domain.share_manager import ShareManager +from assistant.domain import ShareManager, KnowledgeBriefManager from assistant.data import KnowledgeBrief, KnowledgePackage, LearningObjective, LearningOutcome -from assistant.domain import KnowledgeTransferManager from assistant.storage import ShareStorage, ShareStorageManager from assistant.data import ConversationRole from semantic_workbench_assistant import settings @@ -127,20 +126,17 @@ def create_test_brief(self): # Write the project to storage using ShareStorage to ensure proper consolidated format ShareStorage.write_share(self.share_id, project) - # Brief is now stored as part of the consolidated project data async def test_get_project_brief(self) -> None: """Test that get_project_brief correctly loads the brief from storage""" # Mock the KnowledgeTransferManager to use our test context - with unittest.mock.patch.object(KnowledgeTransferManager, "get_share_id", return_value=self.share_id): + with unittest.mock.patch.object(ShareManager, "get_share_id", return_value=self.share_id): # Using Any here to satisfy type checker with our mock context: Any = self.context - # Get the brief using the KnowledgeTransferManager - brief = await KnowledgeTransferManager.get_knowledge_brief(context) + brief = await KnowledgeBriefManager.get_knowledge_brief(context) project = ShareStorage.read_share(self.share_id) - # Verify the brief was loaded correctly self.assertIsNotNone(brief, "Should load the brief") if brief: # Type checking guard self.assertEqual(brief.title, self.title) diff --git a/assistants/knowledge-transfer-assistant/tests/test_share_manager.py b/assistants/knowledge-transfer-assistant/tests/test_share_manager.py index 2e388a3e3..61fa11164 100644 --- a/assistants/knowledge-transfer-assistant/tests/test_share_manager.py +++ b/assistants/knowledge-transfer-assistant/tests/test_share_manager.py @@ -9,8 +9,9 @@ KnowledgePackage, LearningObjective, LearningOutcome, + ) -from assistant.domain import KnowledgeTransferManager +from assistant.domain import LearningObjectivesManager from semantic_workbench_assistant.assistant_app import ConversationContext @@ -163,7 +164,7 @@ async def mock_refresh_all_project_uis(*args, **kwargs): ) # Call the method being tested - success, goal_name_result = await KnowledgeTransferManager.delete_learning_objective(context, objective_index) + success, goal_name_result = await LearningObjectivesManager.delete_learning_objective(context, objective_index) # Verify the result assert success is True @@ -224,7 +225,7 @@ def mock_read_project(proj_id): ) # Call the method being tested with an invalid index - success, error_message = await KnowledgeTransferManager.delete_learning_objective(context, objective_index) + success, error_message = await LearningObjectivesManager.delete_learning_objective(context, objective_index) # Verify the result indicates failure with appropriate error message assert success is False @@ -246,7 +247,7 @@ async def mock_get_project_id(*args, **kwargs): ) # Call the method being tested - success, error_message = await KnowledgeTransferManager.delete_learning_objective(context, "test-objective-id") + success, error_message = await LearningObjectivesManager.delete_learning_objective(context, "test-objective-id") # Verify the result indicates failure with appropriate error message assert success is False diff --git a/assistants/knowledge-transfer-assistant/tests/test_share_storage.py b/assistants/knowledge-transfer-assistant/tests/test_share_storage.py index 97169ba90..cbbd692e4 100644 --- a/assistants/knowledge-transfer-assistant/tests/test_share_storage.py +++ b/assistants/knowledge-transfer-assistant/tests/test_share_storage.py @@ -24,7 +24,6 @@ RequestPriority, RequestStatus, ) -from assistant.domain.knowledge_package_manager import KnowledgePackageManager from assistant.notifications import Notifications from assistant.storage import ShareStorage, ShareStorageManager from assistant.data import ( @@ -416,12 +415,9 @@ async def test_conversation_tracking_in_json(self): self.assertIsInstance(package.team_conversations, dict) # Verify helper methods work - linked_conversations = KnowledgePackageManager.get_all_linked_conversations(package) + linked_conversations = await ShareManager.get_linked_conversations(self.context) self.assertIsInstance(linked_conversations, list) - notification_conversations = KnowledgePackageManager.get_notification_conversations(package) - self.assertIsInstance(notification_conversations, list) - async def test_conversation_association(self): """Test conversation role setting.""" # Mock ShareManager.set_conversation_role From b5f92883ddbf6629793a3c285534a5025478763d Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Tue, 29 Jul 2025 10:36:41 -0700 Subject: [PATCH 07/21] Fix imports (de-Claude) --- .../assistant/agentic/analysis.py | 36 +++- .../assistant/agentic/coordinator_support.py | 33 ++-- .../assistant/agentic/team_welcome.py | 25 ++- .../assistant/assistant.py | 106 ++++++++--- .../assistant/common.py | 2 +- .../assistant/config.py | 14 +- .../assistant/conversation_clients.py | 14 +- .../assistant/data.py | 8 +- .../assistant/domain/__init__.py | 6 +- .../assistant/domain/audience_manager.py | 14 +- .../domain/information_request_manager.py | 102 +++++++--- .../domain/knowledge_brief_manager.py | 27 ++- .../domain/knowledge_digest_manager.py | 50 +++-- .../domain/learning_objectives_manager.py | 178 +++++++++++++----- .../assistant/domain/share_manager.py | 95 +++++++--- ...ransfer_manager.py => transfer_manager.py} | 20 +- .../assistant/files.py | 149 +++++++++++---- .../assistant/logging.py | 18 +- .../assistant/notifications.py | 47 +++-- .../assistant/respond.py | 149 ++++++++++----- .../assistant/storage.py | 23 ++- .../assistant/string_utils.py | 8 +- .../assistant/tools/information_requests.py | 52 +++-- .../assistant/tools/learning_objectives.py | 19 +- .../assistant/tools/learning_outcomes.py | 29 ++- .../assistant/tools/progress_tracking.py | 55 ++++-- .../assistant/tools/share_setup.py | 14 +- .../assistant/ui_tabs/__init__.py | 2 +- .../assistant/ui_tabs/brief.py | 28 ++- .../assistant/ui_tabs/common.py | 18 +- .../assistant/ui_tabs/debug.py | 60 ++++-- .../assistant/ui_tabs/learning.py | 82 +++++--- .../assistant/ui_tabs/sharing.py | 62 ++++-- .../assistant/utils.py | 8 +- .../pyproject.toml | 2 +- .../tests/test_artifact_loading.py | 35 +++- .../tests/test_inspector.py | 20 +- .../tests/test_share_manager.py | 89 +++++++-- .../tests/test_share_storage.py | 83 +++++--- .../tests/test_share_tools.py | 53 ++++-- .../tests/test_team_mode.py | 58 ++++-- 41 files changed, 1384 insertions(+), 509 deletions(-) rename assistants/knowledge-transfer-assistant/assistant/domain/{knowledge_transfer_manager.py => transfer_manager.py} (70%) diff --git a/assistants/knowledge-transfer-assistant/assistant/agentic/analysis.py b/assistants/knowledge-transfer-assistant/assistant/agentic/analysis.py index c110cf3f4..f2e942d95 100644 --- a/assistants/knowledge-transfer-assistant/assistant/agentic/analysis.py +++ b/assistants/knowledge-transfer-assistant/assistant/agentic/analysis.py @@ -12,11 +12,13 @@ from openai.types.chat import ChatCompletionMessageParam from semantic_workbench_assistant.assistant_app import ConversationContext -from ..config import assistant_config -from ..logging import logger +from assistant.config import assistant_config +from assistant.logging import logger -async def detect_information_request_needs(context: ConversationContext, message: str) -> Dict[str, Any]: +async def detect_information_request_needs( + context: ConversationContext, message: str +) -> Dict[str, Any]: """ Analyze a user message in context of recent chat history to detect potential information request needs. Uses an LLM for sophisticated detection. @@ -73,7 +75,9 @@ async def detect_information_request_needs(context: ConversationContext, message # Add to chat history role = "user" if sender_name == "Team Member" else "assistant" - chat_history.append({"role": role, "content": f"{sender_name}: {msg.content}"}) + chat_history.append( + {"role": role, "content": f"{sender_name}: {msg.content}"} + ) # Reverse to get chronological order chat_history.reverse() @@ -86,16 +90,26 @@ async def detect_information_request_needs(context: ConversationContext, message async with openai_client.create_client(config.service_config) as client: # Prepare messages array with system prompt and chat history messages: List[ChatCompletionMessageParam] = [ - {"role": "system", "content": config.prompt_config.share_information_request_detection} + { + "role": "system", + "content": config.prompt_config.share_information_request_detection, + } ] # Add chat history if available if chat_history: for history_msg in chat_history: - messages.append({"role": history_msg["role"], "content": history_msg["content"]}) + messages.append( + {"role": history_msg["role"], "content": history_msg["content"]} + ) # Add the current message for analysis - explicitly mark as the latest message - messages.append({"role": "user", "content": f"Latest message from Team Member: {message}"}) + messages.append( + { + "role": "user", + "content": f"Latest message from Team Member: {message}", + } + ) completion_args = { "model": "gpt-3.5-turbo", @@ -104,7 +118,9 @@ async def detect_information_request_needs(context: ConversationContext, message "max_tokens": 500, "temperature": 0.2, # Low temperature for more consistent analysis } - debug["completion_args"] = openai_client.make_completion_args_serializable(completion_args) + debug["completion_args"] = openai_client.make_completion_args_serializable( + completion_args + ) # Make the API call response = await client.chat.completions.create( @@ -120,7 +136,9 @@ async def detect_information_request_needs(context: ConversationContext, message result["original_message"] = message return result except json.JSONDecodeError: - logger.warning(f"Failed to parse JSON from LLM response: {response.choices[0].message.content}") + logger.warning( + f"Failed to parse JSON from LLM response: {response.choices[0].message.content}" + ) return { "is_information_request": False, "reason": "Failed to parse LLM response", diff --git a/assistants/knowledge-transfer-assistant/assistant/agentic/coordinator_support.py b/assistants/knowledge-transfer-assistant/assistant/agentic/coordinator_support.py index 1075b480e..35ceefa0f 100644 --- a/assistants/knowledge-transfer-assistant/assistant/agentic/coordinator_support.py +++ b/assistants/knowledge-transfer-assistant/assistant/agentic/coordinator_support.py @@ -8,17 +8,18 @@ from semantic_workbench_assistant.assistant_app import ConversationContext -from ..data import RequestStatus -from ..domain.knowledge_transfer_manager import KnowledgeTransferManager -from ..logging import logger -from ..domain.share_manager import ShareManager +from assistant.data import RequestStatus +from assistant.domain import ShareManager, TransferManager +from assistant.logging import logger class CoordinatorSupport: """Provides support utilities for coordinators.""" @staticmethod - async def get_coordinator_next_action_suggestion(context: ConversationContext) -> Optional[str]: + async def get_coordinator_next_action_suggestion( + context: ConversationContext, + ) -> Optional[str]: """ Generate next action suggestions for the coordinator based on the knowledge transfer state. @@ -60,7 +61,10 @@ async def get_coordinator_next_action_suggestion(context: ConversationContext) - return "Your package needs a short introduction that will orient your team. Let's write a knowledge brief next. The knowledge brief helps your team understand the purpose of this knowledge transfer and will be visible to all team members in their side panel." # 5. If intended to have outcomes but none defined yet - if package.is_intended_to_accomplish_outcomes and not package.learning_objectives: + if ( + package.is_intended_to_accomplish_outcomes + and not package.learning_objectives + ): return ( "Would you like your team to achieve any specific outcomes? If so, let's define some learning objectives. " "If not, you can mark this package as 'exploratory' instead." @@ -68,21 +72,26 @@ async def get_coordinator_next_action_suggestion(context: ConversationContext) - # 6. Objectives exist, but missing outcomes if package.is_intended_to_accomplish_outcomes: - incomplete_objectives = [obj for obj in package.learning_objectives if not obj.learning_outcomes] + incomplete_objectives = [ + obj + for obj in package.learning_objectives + if not obj.learning_outcomes + ] if incomplete_objectives: name = incomplete_objectives[0].name return f"The learning objective '{name}' doesn't have any outcomes yet. Let's define what your team should accomplish to meet it." # 7. Ready for transfer but not yet shared - if KnowledgeTransferManager.is_ready_for_transfer( + if TransferManager.is_ready_for_transfer( package - ) and not KnowledgeTransferManager.is_actively_sharing(package): + ) and not TransferManager.is_actively_sharing(package): return "Your knowledge package is ready to share. Would you like to create a message and generate the invitation link?" # 8. Actively sharing - monitor and support ongoing transfer - if KnowledgeTransferManager.is_actively_sharing(package): - if package.is_intended_to_accomplish_outcomes and not KnowledgeTransferManager._is_transfer_complete( - package + if TransferManager.is_actively_sharing(package): + if ( + package.is_intended_to_accomplish_outcomes + and not TransferManager._is_transfer_complete(package) ): team_count = len(package.team_conversations) return f"Great! Your knowledge is being shared with {team_count} team member{'s' if team_count != 1 else ''}. You can continue improving the package or respond to information requests as they come in." diff --git a/assistants/knowledge-transfer-assistant/assistant/agentic/team_welcome.py b/assistants/knowledge-transfer-assistant/assistant/agentic/team_welcome.py index 13b772542..349102806 100644 --- a/assistants/knowledge-transfer-assistant/assistant/agentic/team_welcome.py +++ b/assistants/knowledge-transfer-assistant/assistant/agentic/team_welcome.py @@ -12,13 +12,14 @@ from openai.types.chat import ChatCompletionMessageParam from semantic_workbench_assistant.assistant_app import ConversationContext +from assistant.config import assistant_config from assistant.domain.share_manager import ShareManager +from assistant.logging import logger -from ..config import assistant_config -from ..logging import logger - -async def generate_team_welcome_message(context: ConversationContext) -> tuple[str, dict[str, Any]]: +async def generate_team_welcome_message( + context: ConversationContext, +) -> tuple[str, dict[str, Any]]: """ Generates a welcome message for the team based on the knowledge transfer information. """ @@ -28,7 +29,9 @@ async def generate_team_welcome_message(context: ConversationContext) -> tuple[s share = await ShareManager.get_share(context) if not share: - logger.warning("No active knowledge package found for welcome message generation") + logger.warning( + "No active knowledge package found for welcome message generation" + ) return config.team_config.default_welcome_message, debug share_id = share.share_id @@ -91,9 +94,13 @@ async def generate_team_welcome_message(context: ConversationContext) -> tuple[s try: # Chat completion async with openai_client.create_client(config.service_config) as client: - share_info = "\n\n## KNOWLEDGE SHARE INFORMATION\n\n" + "\n".join(share_data.values()) + share_info = "\n\n## KNOWLEDGE SHARE INFORMATION\n\n" + "\n".join( + share_data.values() + ) - instructions = f"{config.prompt_config.welcome_message_generation}\n\n{share_info}" + instructions = ( + f"{config.prompt_config.welcome_message_generation}\n\n{share_info}" + ) messages: List[ChatCompletionMessageParam] = [ {"role": "system", "content": instructions}, ] @@ -104,7 +111,9 @@ async def generate_team_welcome_message(context: ConversationContext) -> tuple[s "max_tokens": 500, "temperature": 0.7, # Low temperature for more consistent analysis } - debug["completion_args"] = openai_client.make_completion_args_serializable(completion_args) + debug["completion_args"] = openai_client.make_completion_args_serializable( + completion_args + ) # LLM call response = await client.chat.completions.create( diff --git a/assistants/knowledge-transfer-assistant/assistant/assistant.py b/assistants/knowledge-transfer-assistant/assistant/assistant.py index da7644caf..53743a0c5 100644 --- a/assistants/knowledge-transfer-assistant/assistant/assistant.py +++ b/assistants/knowledge-transfer-assistant/assistant/assistant.py @@ -30,15 +30,13 @@ from .agentic.team_welcome import generate_team_welcome_message from .config import assistant_config -from .domain.share_manager import ShareManager -from .data import InspectorTab, LogEntryType +from .data import ConversationRole, InspectorTab, LogEntryType +from .domain import ShareManager from .files import ShareFilesManager from .logging import logger -from .domain.share_manager import ShareManager from .notifications import Notifications from .respond import respond_to_conversation -from .ui_tabs import BriefInspector, LearningInspector, SharingInspector, DebugInspector -from .data import ConversationRole +from .ui_tabs import BriefInspector, DebugInspector, LearningInspector, SharingInspector from .utils import ( DEFAULT_TEMPLATE_ID, load_text_include, @@ -46,7 +44,9 @@ service_id = "knowledge-transfer-assistant.made-exploration" service_name = "Knowledge Transfer Assistant" -service_description = "A mediator assistant that facilitates sharing knowledge between parties." +service_description = ( + "A mediator assistant that facilitates sharing knowledge between parties." +) async def content_evaluator_factory( @@ -78,7 +78,10 @@ async def content_evaluator_factory( template_id=DEFAULT_TEMPLATE_ID, background_color="rgb(198, 177, 222)", icon=dashboard_card.image_to_url( - pathlib.Path(__file__).parent / "assets" / "icon-knowledge-transfer.svg", "image/svg+xml" + pathlib.Path(__file__).parent + / "assets" + / "icon-knowledge-transfer.svg", + "image/svg+xml", ), card_content=dashboard_card.CardContent( content_type="text/markdown", @@ -86,9 +89,11 @@ async def content_evaluator_factory( ), ), ), - **navigator.metadata_for_assistant_navigator({ - "default": load_text_include("assistant_info.md"), - }), + **navigator.metadata_for_assistant_navigator( + { + "default": load_text_include("assistant_info.md"), + } + ), }, ) @@ -119,7 +124,9 @@ async def on_conversation_created(context: ConversationContext) -> None: if not share_id: logger.error("No share ID found for shareable team conversation.") return - await ShareManager.set_conversation_role(context, share_id, ConversationRole.SHAREABLE_TEMPLATE) + await ShareManager.set_conversation_role( + context, share_id, ConversationRole.SHAREABLE_TEMPLATE + ) return case ConversationRole.TEAM: @@ -135,8 +142,12 @@ async def on_conversation_created(context: ConversationContext) -> None: ) ) - await ShareManager.set_conversation_role(context, share_id, ConversationRole.TEAM) - await ShareFilesManager.synchronize_files_to_team_conversation(context=context, share_id=share_id) + await ShareManager.set_conversation_role( + context, share_id, ConversationRole.TEAM + ) + await ShareFilesManager.synchronize_files_to_team_conversation( + context=context, share_id=share_id + ) welcome_message, debug = await generate_team_welcome_message(context) await context.send_messages( @@ -220,9 +231,13 @@ async def on_conversation_updated(context: ConversationContext) -> None: 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}'") + 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}") + 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}") @@ -250,7 +265,10 @@ async def on_message_created( # If this is a Coordinator conversation, store the message for Team access async with context.set_status("jotting..."): role = await ShareManager.get_conversation_role(context) - if role == ConversationRole.COORDINATOR and message.message_type == MessageType.chat: + if ( + role == ConversationRole.COORDINATOR + and message.message_type == MessageType.chat + ): try: # Get the sender's name sender_name = "Coordinator" @@ -267,7 +285,8 @@ async def on_message_created( message_id=str(message.id), content=message.content, sender_name=sender_name, - is_assistant=message.sender.participant_role == ParticipantRole.assistant, + is_assistant=message.sender.participant_role + == ParticipantRole.assistant, timestamp=message.timestamp, ) @@ -283,7 +302,9 @@ async def on_message_created( ) except Exception as e: # Don't fail message handling if storage fails - logger.exception(f"Error storing Coordinator message for Team access: {e}") + logger.exception( + f"Error storing Coordinator message for Team access: {e}" + ) async with context.set_status("pondering..."): await respond_to_conversation( @@ -294,8 +315,13 @@ async def on_message_created( ) # If the message is from a Coordinator, update the whiteboard in the background - if role == ConversationRole.COORDINATOR and message.message_type == MessageType.chat: - asyncio.create_task(KnowledgeDigestManager.auto_update_knowledge_digest(context)) + if ( + role == ConversationRole.COORDINATOR + and message.message_type == MessageType.chat + ): + asyncio.create_task( + KnowledgeDigestManager.auto_update_knowledge_digest(context) + ) except Exception as e: logger.exception(f"Error handling message: {e}") @@ -317,9 +343,13 @@ async def on_command_created( if message.message_type != MessageType.command: return - await context.update_participant_me(UpdateParticipant(status="processing command...")) + await context.update_participant_me( + UpdateParticipant(status="processing command...") + ) try: - metadata = {"debug": {"content_safety": event.data.get(content_safety.metadata_key, {})}} + metadata = { + "debug": {"content_safety": event.data.get(content_safety.metadata_key, {})} + } # Respond to the conversation await respond_to_conversation( @@ -352,7 +382,9 @@ async def on_file_created( try: share = await ShareManager.get_share(context) if not share or not file.filename: - logger.warning(f"No share found or missing filename. filename={file.filename}") + logger.warning( + f"No share found or missing filename. filename={file.filename}" + ) return role = await ShareManager.get_conversation_role(context) @@ -375,7 +407,9 @@ async def on_file_created( # 2. Synchronize to all Team conversations # Get all Team conversations - team_conversations = await ShareFilesManager.get_team_conversations(context, share.share_id) + team_conversations = await ShareFilesManager.get_team_conversations( + context, share.share_id + ) if team_conversations: for team_conv_id in team_conversations: @@ -387,7 +421,9 @@ async def on_file_created( ) # 3. Update all UIs but don't send notifications to reduce noise - await Notifications.notify_all_state_update(context, share.share_id, [InspectorTab.DEBUG]) + await Notifications.notify_all_state_update( + context, share.share_id, [InspectorTab.DEBUG] + ) # Team files don't need special handling as they're already in the conversation # Log file creation to knowledge transfer log for all files @@ -433,7 +469,9 @@ async def on_file_updated( logger.error(f"Failed to update file in share storage: {file.filename}") return - team_conversations = await ShareFilesManager.get_team_conversations(context, share.share_id) + team_conversations = await ShareFilesManager.get_team_conversations( + context, share.share_id + ) for team_conv_id in team_conversations: await ShareFilesManager.copy_file_to_conversation( context=context, @@ -443,7 +481,9 @@ async def on_file_updated( ) # 3. Update all UIs but don't send notifications to reduce noise - await Notifications.notify_all_state_update(context, share.share_id, [InspectorTab.DEBUG]) + await Notifications.notify_all_state_update( + context, share.share_id, [InspectorTab.DEBUG] + ) await ShareManager.log_share_event( context=context, @@ -481,10 +521,14 @@ async def on_file_deleted( ) if not success: - logger.error(f"Failed to delete file from share storage: {file.filename}") + logger.error( + f"Failed to delete file from share storage: {file.filename}" + ) # 2. Update all UIs about the deletion but don't send notifications to reduce noise - await Notifications.notify_all_state_update(context, share.share_id, [InspectorTab.DEBUG]) + await Notifications.notify_all_state_update( + context, share.share_id, [InspectorTab.DEBUG] + ) # Team files don't need special handling await ShareManager.log_share_event( @@ -529,7 +573,9 @@ async def on_participant_joined( if not share_id: return - await ShareFilesManager.synchronize_files_to_team_conversation(context=context, share_id=share_id) + await ShareFilesManager.synchronize_files_to_team_conversation( + context=context, share_id=share_id + ) await ShareManager.log_share_event( context=context, diff --git a/assistants/knowledge-transfer-assistant/assistant/common.py b/assistants/knowledge-transfer-assistant/assistant/common.py index e84ec503c..f711c82d1 100644 --- a/assistants/knowledge-transfer-assistant/assistant/common.py +++ b/assistants/knowledge-transfer-assistant/assistant/common.py @@ -9,8 +9,8 @@ from semantic_workbench_assistant.assistant_app import ConversationContext -from .domain.share_manager import ShareManager from .data import LogEntryType +from .domain.share_manager import ShareManager async def log_transfer_action( diff --git a/assistants/knowledge-transfer-assistant/assistant/config.py b/assistants/knowledge-transfer-assistant/assistant/config.py index 764cae241..9cb254742 100644 --- a/assistants/knowledge-transfer-assistant/assistant/config.py +++ b/assistants/knowledge-transfer-assistant/assistant/config.py @@ -52,7 +52,10 @@ class RequestConfig(BaseModel): openai_model: Annotated[ str, - Field(title="OpenAI Model", description="The OpenAI model to use for generating responses."), + Field( + title="OpenAI Model", + description="The OpenAI model to use for generating responses.", + ), ] = "gpt-4.1" @@ -118,7 +121,10 @@ class PromptConfig(BaseModel): knowledge_digest_prompt: Annotated[ str, - Field(title="Knowledge Digest Prompt", description="The prompt used to generate knowledge digest content."), + Field( + title="Knowledge Digest Prompt", + description="The prompt used to generate knowledge digest content.", + ), UISchema(widget="textarea"), ] = load_text_include("knowledge_digest_prompt.txt") @@ -166,7 +172,9 @@ class CoordinatorConfig(BaseModel): int, Field( title="Maximum Digest Tokens", - description=("The number of tokens to use for the knowledge digest. Default: 4096"), + description=( + "The number of tokens to use for the knowledge digest. Default: 4096" + ), ), ] = 4_096 diff --git a/assistants/knowledge-transfer-assistant/assistant/conversation_clients.py b/assistants/knowledge-transfer-assistant/assistant/conversation_clients.py index a5e77573e..40d9e47bb 100644 --- a/assistants/knowledge-transfer-assistant/assistant/conversation_clients.py +++ b/assistants/knowledge-transfer-assistant/assistant/conversation_clients.py @@ -12,9 +12,9 @@ from semantic_workbench_assistant.assistant_app import ConversationContext from semantic_workbench_assistant.storage import read_model +from .data import ConversationRole, ConversationShareInfo from .logging import logger from .storage import ShareStorageManager -from .data import ConversationRole, ConversationShareInfo class ConversationClientManager: @@ -26,7 +26,9 @@ class ConversationClientManager: """ @staticmethod - def get_conversation_client(context: ConversationContext, conversation_id: str) -> ConversationAPIClient: + def get_conversation_client( + context: ConversationContext, conversation_id: str + ) -> ConversationAPIClient: """ Gets a client for accessing another conversation. """ @@ -40,7 +42,9 @@ async def get_coordinator_client_for_share( Gets a client for accessing the Coordinator conversation for a knowledge transfer. """ # Look for the Coordinator conversation directory - coordinator_dir = ShareStorageManager.get_share_dir(share_id) / ConversationRole.COORDINATOR + coordinator_dir = ( + ShareStorageManager.get_share_dir(share_id) / ConversationRole.COORDINATOR + ) if not coordinator_dir.exists(): return None, None @@ -64,7 +68,9 @@ async def get_coordinator_client_for_share( return None, coordinator_conversation_id # Create a client for the Coordinator conversation - client = ConversationClientManager.get_conversation_client(context, coordinator_conversation_id) + client = ConversationClientManager.get_conversation_client( + context, coordinator_conversation_id + ) return client, coordinator_conversation_id @staticmethod diff --git a/assistants/knowledge-transfer-assistant/assistant/data.py b/assistants/knowledge-transfer-assistant/assistant/data.py index e1cd1e847..3452cd9d0 100644 --- a/assistants/knowledge-transfer-assistant/assistant/data.py +++ b/assistants/knowledge-transfer-assistant/assistant/data.py @@ -161,7 +161,9 @@ class LogEntry(BaseModel): class KnowledgePackageLog(BaseModel): - entries: List[LogEntry] = Field(default_factory=list) # Chronological list of log entries + entries: List[LogEntry] = Field( + default_factory=list + ) # Chronological list of log entries class KnowledgePackage(BaseModel): @@ -193,7 +195,9 @@ class KnowledgePackage(BaseModel): archived: bool = False requests: List[InformationRequest] = Field(default_factory=list) - log: Optional[KnowledgePackageLog] = Field(default_factory=lambda: KnowledgePackageLog()) + log: Optional[KnowledgePackageLog] = Field( + default_factory=lambda: KnowledgePackageLog() + ) class CoordinatorConversationMessage(BaseModel): diff --git a/assistants/knowledge-transfer-assistant/assistant/domain/__init__.py b/assistants/knowledge-transfer-assistant/assistant/domain/__init__.py index 021539407..a9e4ce8ad 100644 --- a/assistants/knowledge-transfer-assistant/assistant/domain/__init__.py +++ b/assistants/knowledge-transfer-assistant/assistant/domain/__init__.py @@ -4,16 +4,16 @@ This module provides the main KnowledgeTransferManager class for project management. """ +from .audience_manager import AudienceManager from .information_request_manager import InformationRequestManager from .knowledge_brief_manager import KnowledgeBriefManager from .knowledge_digest_manager import KnowledgeDigestManager from .learning_objectives_manager import LearningObjectivesManager -from .audience_manager import AudienceManager from .share_manager import ShareManager -from .knowledge_transfer_manager import KnowledgeTransferManager +from .transfer_manager import TransferManager __all__ = [ - "KnowledgeTransferManager", + "TransferManager", "InformationRequestManager", "KnowledgeBriefManager", "KnowledgeDigestManager", diff --git a/assistants/knowledge-transfer-assistant/assistant/domain/audience_manager.py b/assistants/knowledge-transfer-assistant/assistant/domain/audience_manager.py index 1badd0b05..bc701e906 100644 --- a/assistants/knowledge-transfer-assistant/assistant/domain/audience_manager.py +++ b/assistants/knowledge-transfer-assistant/assistant/domain/audience_manager.py @@ -9,13 +9,12 @@ from semantic_workbench_assistant.assistant_app import ConversationContext +from assistant.data import InspectorTab, LogEntryType +from assistant.logging import logger from assistant.notifications import Notifications from .share_manager import ShareManager -from ..data import InspectorTab, LogEntryType -from ..logging import logger - class AudienceManager: """Manages knowledge transfer lifecycle and state operations.""" @@ -41,7 +40,10 @@ async def update_audience( # Get existing knowledge package share = await ShareManager.get_share(context) if not share: - return False, "No knowledge package found. Please create a knowledge brief first." + return ( + False, + "No knowledge package found. Please create a knowledge brief first.", + ) # Update the audience share.audience = audience_description.strip() @@ -61,7 +63,9 @@ async def update_audience( ) await Notifications.notify(context, "Audience updated.") - await Notifications.notify_all_state_update(context, share.share_id, [InspectorTab.DEBUG]) + await Notifications.notify_all_state_update( + context, share.share_id, [InspectorTab.DEBUG] + ) return True, f"Target audience updated successfully: {audience_description}" diff --git a/assistants/knowledge-transfer-assistant/assistant/domain/information_request_manager.py b/assistants/knowledge-transfer-assistant/assistant/domain/information_request_manager.py index 6ed046b1a..b3b304c9f 100644 --- a/assistants/knowledge-transfer-assistant/assistant/domain/information_request_manager.py +++ b/assistants/knowledge-transfer-assistant/assistant/domain/information_request_manager.py @@ -9,11 +9,18 @@ from semantic_workbench_assistant.assistant_app import ConversationContext -from ..data import InformationRequest, InspectorTab, LogEntryType, RequestPriority, RequestStatus -from ..logging import logger -from ..notifications import Notifications -from ..storage import ShareStorage -from ..utils import require_current_user +from assistant.data import ( + InformationRequest, + InspectorTab, + LogEntryType, + RequestPriority, + RequestStatus, +) +from assistant.logging import logger +from assistant.notifications import Notifications +from assistant.storage import ShareStorage +from assistant.utils import require_current_user + from .share_manager import ShareManager @@ -43,10 +50,14 @@ async def create_information_request( try: share_id = await ShareManager.get_share_id(context) if not share_id: - logger.error("Cannot create information request: no share associated with this conversation") + logger.error( + "Cannot create information request: no share associated with this conversation" + ) return False, None - current_user_id = await require_current_user(context, "create information request") + current_user_id = await require_current_user( + context, "create information request" + ) if not current_user_id: return False, None @@ -73,8 +84,12 @@ async def create_information_request( }, ) - await Notifications.notify_self_and_other(context, share_id, f"Information request '{title}' was created") - await Notifications.notify_all_state_update(context, share_id, [InspectorTab.SHARING]) + await Notifications.notify_self_and_other( + context, share_id, f"Information request '{title}' was created" + ) + await Notifications.notify_all_state_update( + context, share_id, [InspectorTab.SHARING] + ) return True, information_request @@ -91,15 +106,21 @@ async def resolve_information_request( try: share_id = await ShareManager.get_share_id(context) if not share_id: - logger.error("Cannot resolve information request: no share associated with this conversation") + logger.error( + "Cannot resolve information request: no share associated with this conversation" + ) return False, None - current_user_id = await require_current_user(context, "resolve information request") + current_user_id = await require_current_user( + context, "resolve information request" + ) if not current_user_id: return False, None # Get the information request - information_request = ShareStorage.read_information_request(share_id, request_id) + information_request = ShareStorage.read_information_request( + share_id, request_id + ) if not information_request: # Try to find it in all requests all_requests = ShareStorage.get_all_information_requests(share_id) @@ -124,12 +145,14 @@ async def resolve_information_request( information_request.resolved_by = current_user_id # Add to history - information_request.updates.append({ - "timestamp": datetime.utcnow().isoformat(), - "user_id": current_user_id, - "message": f"Request resolved: {resolution}", - "status": RequestStatus.RESOLVED.value, - }) + information_request.updates.append( + { + "timestamp": datetime.utcnow().isoformat(), + "user_id": current_user_id, + "message": f"Request resolved: {resolution}", + "status": RequestStatus.RESOLVED.value, + } + ) # Update metadata information_request.updated_at = datetime.utcnow() @@ -154,12 +177,16 @@ async def resolve_information_request( }, ) - await Notifications.notify_all_state_update(context, share_id, [InspectorTab.SHARING]) + await Notifications.notify_all_state_update( + context, share_id, [InspectorTab.SHARING] + ) await Notifications.notify_self_and_other( context, share_id, f"Information request '{information_request.title}' has been resolved: {resolution}", - information_request.conversation_id if information_request.conversation_id != str(context.id) else None, + information_request.conversation_id + if information_request.conversation_id != str(context.id) + else None, ) return True, information_request @@ -186,22 +213,31 @@ async def delete_information_request( try: share_id = await ShareManager.get_share_id(context) if not share_id: - logger.error("Cannot delete information request: no share associated with this conversation") + logger.error( + "Cannot delete information request: no share associated with this conversation" + ) return False, "No knowledge package associated with this conversation." - current_user_id = await require_current_user(context, "delete information request") + current_user_id = await require_current_user( + context, "delete information request" + ) if not current_user_id: return False, "Could not identify current user." # Get information request by ID cleaned_request_id = request_id.strip().replace('"', "").replace("'", "") - information_request = ShareStorage.read_information_request(share_id, cleaned_request_id) + information_request = ShareStorage.read_information_request( + share_id, cleaned_request_id + ) if not information_request: return False, f"Information request with ID '{request_id}' not found." # Check ownership - only allow deletion by the creator if information_request.conversation_id != str(context.id): - return False, "You can only delete information requests that you created." + return ( + False, + "You can only delete information requests that you created.", + ) # Get user info for logging participants = await context.get_participants() @@ -230,7 +266,9 @@ async def delete_information_request( # Delete the information request from the main share data share = await ShareManager.get_share(context) if share and share.requests: - share.requests = [req for req in share.requests if req.request_id != actual_request_id] + share.requests = [ + req for req in share.requests if req.request_id != actual_request_id + ] await ShareManager.set_share(context, share) # Notify about the deletion @@ -239,10 +277,18 @@ async def delete_information_request( share_id, f"Information request '{request_title}' has been deleted.", ) - await Notifications.notify_all_state_update(context, share_id, [InspectorTab.SHARING]) + await Notifications.notify_all_state_update( + context, share_id, [InspectorTab.SHARING] + ) - return True, f"Information request '{request_title}' has been successfully deleted." + return ( + True, + f"Information request '{request_title}' has been successfully deleted.", + ) except Exception as e: logger.exception(f"Error deleting information request: {e}") - return False, f"Error deleting information request: {str(e)}. Please try again later." + return ( + False, + f"Error deleting information request: {str(e)}. Please try again later.", + ) diff --git a/assistants/knowledge-transfer-assistant/assistant/domain/knowledge_brief_manager.py b/assistants/knowledge-transfer-assistant/assistant/domain/knowledge_brief_manager.py index 276e62ced..d46c55714 100644 --- a/assistants/knowledge-transfer-assistant/assistant/domain/knowledge_brief_manager.py +++ b/assistants/knowledge-transfer-assistant/assistant/domain/knowledge_brief_manager.py @@ -8,11 +8,12 @@ from semantic_workbench_assistant.assistant_app import ConversationContext -from ..data import InspectorTab, KnowledgeBrief, LogEntryType -from ..logging import logger -from ..notifications import Notifications -from ..storage import ShareStorage -from ..utils import require_current_user +from assistant.data import InspectorTab, KnowledgeBrief, LogEntryType +from assistant.logging import logger +from assistant.notifications import Notifications +from assistant.storage import ShareStorage +from assistant.utils import require_current_user + from .share_manager import ShareManager @@ -20,7 +21,9 @@ class KnowledgeBriefManager: """Manages knowledge brief operations.""" @staticmethod - async def get_knowledge_brief(context: ConversationContext) -> Optional[KnowledgeBrief]: + async def get_knowledge_brief( + context: ConversationContext, + ) -> Optional[KnowledgeBrief]: share_id = await ShareManager.get_share_id(context) if not share_id: return None @@ -35,7 +38,9 @@ async def update_knowledge_brief( ) -> Optional[KnowledgeBrief]: share_id = await ShareManager.get_share_id(context) if not share_id: - logger.error("Cannot update brief: no share associated with this conversation") + logger.error( + "Cannot update brief: no share associated with this conversation" + ) return current_user_id = await require_current_user(context, "update brief") @@ -70,7 +75,11 @@ async def update_knowledge_brief( message=f"Created brief: {title}", ) - await Notifications.notify_all(context, share_id, "Knowledge brief has been updated") - await Notifications.notify_all_state_update(context, share_id, [InspectorTab.BRIEF]) + await Notifications.notify_all( + context, share_id, "Knowledge brief has been updated" + ) + await Notifications.notify_all_state_update( + context, share_id, [InspectorTab.BRIEF] + ) return brief diff --git a/assistants/knowledge-transfer-assistant/assistant/domain/knowledge_digest_manager.py b/assistants/knowledge-transfer-assistant/assistant/domain/knowledge_digest_manager.py index 5895e9342..4024056b0 100644 --- a/assistants/knowledge-transfer-assistant/assistant/domain/knowledge_digest_manager.py +++ b/assistants/knowledge-transfer-assistant/assistant/domain/knowledge_digest_manager.py @@ -12,12 +12,13 @@ from semantic_workbench_api_model.workbench_model import ParticipantRole from semantic_workbench_assistant.assistant_app import ConversationContext -from ..config import assistant_config -from ..data import InspectorTab, KnowledgeDigest, LogEntryType -from ..logging import logger -from ..notifications import Notifications -from ..storage import ShareStorage -from ..utils import require_current_user +from assistant.config import assistant_config +from assistant.data import InspectorTab, KnowledgeDigest, LogEntryType +from assistant.logging import logger +from assistant.notifications import Notifications +from assistant.storage import ShareStorage +from assistant.utils import require_current_user + from .share_manager import ShareManager @@ -42,10 +43,14 @@ async def update_knowledge_digest( try: share_id = await ShareManager.get_share_id(context) if not share_id: - logger.error("Cannot update knowledge digest: no share associated with this conversation") + logger.error( + "Cannot update knowledge digest: no share associated with this conversation" + ) return False, None - current_user_id = await require_current_user(context, "update knowledge digest") + current_user_id = await require_current_user( + context, "update knowledge digest" + ) if not current_user_id: return False, None @@ -71,7 +76,9 @@ async def update_knowledge_digest( # Log the update event_type = LogEntryType.KNOWLEDGE_DIGEST_UPDATE update_type = "auto-generated" if is_auto_generated else "manual" - message = f"{'Created' if is_new else 'Updated'} knowledge digest ({update_type})" + message = ( + f"{'Created' if is_new else 'Updated'} knowledge digest ({update_type})" + ) await ShareManager.log_share_event( context=context, @@ -104,10 +111,14 @@ async def auto_update_knowledge_digest( share_id = await ShareManager.get_share_id(context) if not share_id: - logger.error("Cannot auto-update knowledge digest: no share associated with this conversation") + logger.error( + "Cannot auto-update knowledge digest: no share associated with this conversation" + ) return False, None - current_user_id = await require_current_user(context, "auto-update knowledge digest") + current_user_id = await require_current_user( + context, "auto-update knowledge digest" + ) if not current_user_id: return False, None @@ -120,7 +131,10 @@ async def auto_update_knowledge_digest( chat_history_text = "" for msg in chat_history: sender_type = ( - "User" if msg.sender and msg.sender.participant_role == ParticipantRole.user else "Assistant" + "User" + if msg.sender + and msg.sender.participant_role == ParticipantRole.user + else "Assistant" ) chat_history_text += f"{sender_type}: {msg.content}\n\n" @@ -134,7 +148,9 @@ async def auto_update_knowledge_digest( """ - async with openai_client.create_client(config.service_config, api_version="2024-06-01") as client: + async with openai_client.create_client( + config.service_config, api_version="2024-06-01" + ) as client: completion = await client.chat.completions.create( model=config.request_config.openai_model, messages=[{"role": "user", "content": digest_prompt}], @@ -143,14 +159,18 @@ async def auto_update_knowledge_digest( content = completion.choices[0].message.content or "" digest_content = "" - match = re.search(r"(.*?)", content, re.DOTALL) + match = re.search( + r"(.*?)", content, re.DOTALL + ) if match: digest_content = match.group(1).strip() else: digest_content = content.strip() if not digest_content: - logger.warning("No content extracted from knowledge digest LLM analysis") + logger.warning( + "No content extracted from knowledge digest LLM analysis" + ) return False, None result = await KnowledgeDigestManager.update_knowledge_digest( diff --git a/assistants/knowledge-transfer-assistant/assistant/domain/learning_objectives_manager.py b/assistants/knowledge-transfer-assistant/assistant/domain/learning_objectives_manager.py index 17e4de299..e5f81b24f 100644 --- a/assistants/knowledge-transfer-assistant/assistant/domain/learning_objectives_manager.py +++ b/assistants/knowledge-transfer-assistant/assistant/domain/learning_objectives_manager.py @@ -8,10 +8,18 @@ from semantic_workbench_assistant.assistant_app import ConversationContext -from ..data import InspectorTab, KnowledgePackage, LearningObjective, LearningOutcome, LogEntryType, LearningOutcomeAchievement -from ..logging import logger -from ..notifications import Notifications -from ..utils import require_current_user +from assistant.data import ( + InspectorTab, + KnowledgePackage, + LearningObjective, + LearningOutcome, + LearningOutcomeAchievement, + LogEntryType, +) +from assistant.logging import logger +from assistant.notifications import Notifications +from assistant.utils import require_current_user + from .share_manager import ShareManager @@ -28,7 +36,9 @@ async def add_learning_objective( ) -> Optional[LearningObjective]: share_id = await ShareManager.get_share_id(context) if not share_id: - logger.error("Cannot add learning objective: no share associated with this conversation") + logger.error( + "Cannot add learning objective: no share associated with this conversation" + ) return None current_user_id = await require_current_user(context, "add learning objective") @@ -69,8 +79,12 @@ async def add_learning_objective( message=f"Added learning objective: {objective_name}", ) - await Notifications.notify_all(context, share_id, f"Learning objective '{objective_name}' was added") - await Notifications.notify_all_state_update(context, share_id, [InspectorTab.LEARNING, InspectorTab.BRIEF]) + await Notifications.notify_all( + context, share_id, f"Learning objective '{objective_name}' was added" + ) + await Notifications.notify_all_state_update( + context, share_id, [InspectorTab.LEARNING, InspectorTab.BRIEF] + ) return new_learning_objective @@ -84,10 +98,14 @@ async def update_learning_objective( """Update an existing learning objective's name or description.""" share_id = await ShareManager.get_share_id(context) if not share_id: - logger.error("Cannot update learning objective: no share associated with this conversation") + logger.error( + "Cannot update learning objective: no share associated with this conversation" + ) return False, "No share associated with this conversation." - current_user_id = await require_current_user(context, "update learning objective") + current_user_id = await require_current_user( + context, "update learning objective" + ) if not current_user_id: return False, "Could not identify current user." @@ -138,10 +156,17 @@ async def update_learning_objective( }, ) - await Notifications.notify_all(context, share_id, f"Learning objective '{objective.name}' has been updated") - await Notifications.notify_all_state_update(context, share_id, [InspectorTab.LEARNING, InspectorTab.BRIEF]) + await Notifications.notify_all( + context, share_id, f"Learning objective '{objective.name}' has been updated" + ) + await Notifications.notify_all_state_update( + context, share_id, [InspectorTab.LEARNING, InspectorTab.BRIEF] + ) - return True, f"Learning objective '{objective.name}' has been successfully updated: {changes_text}." + return ( + True, + f"Learning objective '{objective.name}' has been successfully updated: {changes_text}.", + ) @staticmethod async def delete_learning_objective( @@ -151,10 +176,14 @@ async def delete_learning_objective( """Delete a learning objective by ID.""" share_id = await ShareManager.get_share_id(context) if not share_id: - logger.error("Cannot delete learning objective: no share associated with this conversation") + logger.error( + "Cannot delete learning objective: no share associated with this conversation" + ) return False, "No share associated with this conversation." - current_user_id = await require_current_user(context, "delete learning objective") + current_user_id = await require_current_user( + context, "delete learning objective" + ) if not current_user_id: return False, "Could not identify current user." @@ -205,13 +234,22 @@ async def delete_learning_objective( }, ) - await Notifications.notify_all(context, share_id, f"Learning objective '{objective_name}' has been deleted") - await Notifications.notify_all_state_update(context, share_id, [InspectorTab.LEARNING, InspectorTab.BRIEF]) + await Notifications.notify_all( + context, share_id, f"Learning objective '{objective_name}' has been deleted" + ) + await Notifications.notify_all_state_update( + context, share_id, [InspectorTab.LEARNING, InspectorTab.BRIEF] + ) - return True, f"Learning objective '{objective_name}' has been successfully deleted from the knowledge package." + return ( + True, + f"Learning objective '{objective_name}' has been successfully deleted from the knowledge package.", + ) @staticmethod - async def get_learning_outcomes(context: ConversationContext) -> List[LearningOutcome]: + async def get_learning_outcomes( + context: ConversationContext, + ) -> List[LearningOutcome]: share_id = await ShareManager.get_share_id(context) if not share_id: return [] @@ -236,7 +274,9 @@ async def add_learning_outcome( """Add a new learning outcome to an existing learning objective.""" share_id = await ShareManager.get_share_id(context) if not share_id: - logger.error("Cannot add learning outcome: no share associated with this conversation") + logger.error( + "Cannot add learning outcome: no share associated with this conversation" + ) return False, "No knowledge package associated with this conversation." current_user_id = await require_current_user(context, "add learning outcome") @@ -245,7 +285,10 @@ async def add_learning_outcome( share = await ShareManager.get_share(context) if not share or not share.learning_objectives: - return False, "No learning objectives found. Please add objectives before adding outcomes." + return ( + False, + "No learning objectives found. Please add objectives before adding outcomes.", + ) # Find the objective by ID objective = None @@ -284,10 +327,19 @@ async def add_learning_outcome( ) # Notify linked conversations - await Notifications.notify_all(context, share_id, f"Learning outcome '{outcome_description}' has been added") - await Notifications.notify_all_state_update(context, share_id, [InspectorTab.LEARNING, InspectorTab.BRIEF]) + await Notifications.notify_all( + context, + share_id, + f"Learning outcome '{outcome_description}' has been added", + ) + await Notifications.notify_all_state_update( + context, share_id, [InspectorTab.LEARNING, InspectorTab.BRIEF] + ) - return True, f"Learning outcome added successfully to objective '{objective.name}': {outcome_description}" + return ( + True, + f"Learning outcome added successfully to objective '{objective.name}': {outcome_description}", + ) @staticmethod async def update_learning_outcome( @@ -298,7 +350,9 @@ async def update_learning_outcome( """Update the description of an existing learning outcome.""" share_id = await ShareManager.get_share_id(context) if not share_id: - logger.error("Cannot update learning outcome: no share associated with this conversation") + logger.error( + "Cannot update learning outcome: no share associated with this conversation" + ) return False, "No knowledge package associated with this conversation." current_user_id = await require_current_user(context, "update learning outcome") @@ -307,7 +361,10 @@ async def update_learning_outcome( share = await ShareManager.get_share(context) if not share or not share.learning_objectives: - return False, "No learning objectives found. Please add objectives before updating outcomes." + return ( + False, + "No learning objectives found. Please add objectives before updating outcomes.", + ) # Find the outcome by ID across all objectives objective = None @@ -355,10 +412,17 @@ async def update_learning_outcome( ) # Notify linked conversations - await Notifications.notify_all(context, share_id, f"Learning outcome '{new_description}' has been updated") - await Notifications.notify_all_state_update(context, share_id, [InspectorTab.LEARNING, InspectorTab.BRIEF]) + await Notifications.notify_all( + context, share_id, f"Learning outcome '{new_description}' has been updated" + ) + await Notifications.notify_all_state_update( + context, share_id, [InspectorTab.LEARNING, InspectorTab.BRIEF] + ) - return True, f"Learning outcome updated successfully in objective '{objective.name}': {new_description}" + return ( + True, + f"Learning outcome updated successfully in objective '{objective.name}': {new_description}", + ) @staticmethod async def delete_learning_outcome( @@ -368,7 +432,9 @@ async def delete_learning_outcome( """Delete a learning outcome from a learning objective.""" share_id = await ShareManager.get_share_id(context) if not share_id: - logger.error("Cannot delete learning outcome: no share associated with this conversation") + logger.error( + "Cannot delete learning outcome: no share associated with this conversation" + ) return False, "No knowledge package associated with this conversation." current_user_id = await require_current_user(context, "delete learning outcome") @@ -377,7 +443,10 @@ async def delete_learning_outcome( share = await ShareManager.get_share(context) if not share or not share.learning_objectives: - return False, "No learning objectives found. Please add objectives before deleting outcomes." + return ( + False, + "No learning objectives found. Please add objectives before deleting outcomes.", + ) # Find the outcome by ID across all objectives objective = None @@ -412,7 +481,9 @@ async def delete_learning_outcome( # Clean up any achievement records for this outcome across all team conversations for team_info in share.team_conversations.values(): team_info.outcome_achievements = [ - achievement for achievement in team_info.outcome_achievements if achievement.outcome_id != outcome_id + achievement + for achievement in team_info.outcome_achievements + if achievement.outcome_id != outcome_id ] # Save the updated knowledge package @@ -433,10 +504,19 @@ async def delete_learning_outcome( ) # Notify linked conversations - await Notifications.notify_all(context, share_id, f"Learning outcome '{deleted_description}' has been removed") - await Notifications.notify_all_state_update(context, share_id, [InspectorTab.LEARNING, InspectorTab.BRIEF]) + await Notifications.notify_all( + context, + share_id, + f"Learning outcome '{deleted_description}' has been removed", + ) + await Notifications.notify_all_state_update( + context, share_id, [InspectorTab.LEARNING, InspectorTab.BRIEF] + ) - return True, f"Learning outcome deleted successfully from objective '{objective.name}': {deleted_description}" + return ( + True, + f"Learning outcome deleted successfully from objective '{objective.name}': {deleted_description}", + ) @staticmethod def get_achievements_for_conversation( @@ -446,18 +526,28 @@ def get_achievements_for_conversation( return team_conv.outcome_achievements if team_conv else [] @staticmethod - def get_completion_for_conversation(package: KnowledgePackage, conversation_id: str) -> Tuple[int, int]: - achievements = LearningObjectivesManager.get_achievements_for_conversation(package, conversation_id) + def get_completion_for_conversation( + package: KnowledgePackage, conversation_id: str + ) -> Tuple[int, int]: + achievements = LearningObjectivesManager.get_achievements_for_conversation( + package, conversation_id + ) achieved_outcome_ids = {a.outcome_id for a in achievements if a.achieved} - total_outcomes = sum(len(obj.learning_outcomes) for obj in package.learning_objectives) + total_outcomes = sum( + len(obj.learning_outcomes) for obj in package.learning_objectives + ) achieved_outcomes = len(achieved_outcome_ids) return achieved_outcomes, total_outcomes @staticmethod - def is_outcome_achieved_by_conversation(package: KnowledgePackage, outcome_id: str, conversation_id: str) -> bool: - achievements = LearningObjectivesManager.get_achievements_for_conversation(package, conversation_id) + def is_outcome_achieved_by_conversation( + package: KnowledgePackage, outcome_id: str, conversation_id: str + ) -> bool: + achievements = LearningObjectivesManager.get_achievements_for_conversation( + package, conversation_id + ) return any(a.outcome_id == outcome_id and a.achieved for a in achievements) @staticmethod @@ -469,8 +559,12 @@ def get_overall_completion(package: KnowledgePackage) -> Tuple[int, int]: """ all_achieved_outcomes = set() for team_conv in package.team_conversations.values(): - achieved_ids = {a.outcome_id for a in team_conv.outcome_achievements if a.achieved} + achieved_ids = { + a.outcome_id for a in team_conv.outcome_achievements if a.achieved + } all_achieved_outcomes.update(achieved_ids) - total_outcomes = sum(len(obj.learning_outcomes) for obj in package.learning_objectives) - return len(all_achieved_outcomes), total_outcomes \ No newline at end of file + total_outcomes = sum( + len(obj.learning_outcomes) for obj in package.learning_objectives + ) + return len(all_achieved_outcomes), total_outcomes diff --git a/assistants/knowledge-transfer-assistant/assistant/domain/share_manager.py b/assistants/knowledge-transfer-assistant/assistant/domain/share_manager.py index 3104556d8..acc70f731 100644 --- a/assistants/knowledge-transfer-assistant/assistant/domain/share_manager.py +++ b/assistants/knowledge-transfer-assistant/assistant/domain/share_manager.py @@ -14,20 +14,19 @@ NewConversationShare, ) from semantic_workbench_assistant.assistant_app import ConversationContext +from semantic_workbench_assistant.storage import read_model, write_model - -from ..data import ( +from assistant.data import ( + ConversationRole, ConversationShareInfo, CoordinatorConversationMessages, KnowledgePackage, KnowledgePackageLog, - ConversationRole, TeamConversationInfo, ) -from ..logging import logger -from ..storage import ShareStorage, ShareStorageManager -from ..utils import get_current_user -from semantic_workbench_assistant.storage import read_model, write_model +from assistant.logging import logger +from assistant.storage import ShareStorage, ShareStorageManager +from assistant.utils import get_current_user class ShareManager: @@ -76,7 +75,9 @@ async def create_share(context: ConversationContext) -> str: # Associate the conversation with the share logger.debug(f"Associating conversation {context.id} with share {share_id}") - await ShareManager.set_conversation_role(context, share_id, ConversationRole.COORDINATOR) + await ShareManager.set_conversation_role( + context, share_id, ConversationRole.COORDINATOR + ) # No need to set conversation role in share storage, as we use metadata logger.debug(f"Conversation {context.id} is Coordinator for share {share_id}") @@ -86,16 +87,22 @@ async def create_share(context: ConversationContext) -> str: return share_id @staticmethod - async def set_conversation_role(context: ConversationContext, share_id: str, role: ConversationRole) -> None: + async def set_conversation_role( + context: ConversationContext, share_id: str, role: ConversationRole + ) -> None: """ Sets the role of a conversation in a knowledge transfer share. """ - role_data = ConversationShareInfo(share_id=share_id, role=role, conversation_id=str(context.id)) + role_data = ConversationShareInfo( + share_id=share_id, role=role, conversation_id=str(context.id) + ) role_path = ShareStorageManager.get_conversation_role_file_path(context) write_model(role_path, role_data) @staticmethod - async def get_conversation_role(context: ConversationContext) -> Optional[ConversationRole]: + async def get_conversation_role( + context: ConversationContext, + ) -> Optional[ConversationRole]: """ Gets the role of a conversation in a knowledge transfer. """ @@ -108,7 +115,9 @@ async def get_conversation_role(context: ConversationContext) -> Optional[Conver return None @staticmethod - async def create_shareable_team_conversation(context: ConversationContext, share_id: str) -> str: + async def create_shareable_team_conversation( + context: ConversationContext, share_id: str + ) -> str: """ Creates a new shareable team conversation template. @@ -144,7 +153,9 @@ async def create_shareable_team_conversation(context: ConversationContext, share }, ) client = context._conversations_client - conversation = await client.create_conversation_with_owner(new_conversation=new_conversation, owner_id=user_id) + conversation = await client.create_conversation_with_owner( + new_conversation=new_conversation, owner_id=user_id + ) if not conversation or not conversation.id: raise ValueError("Failed to create team conversation") @@ -160,8 +171,10 @@ async def create_shareable_team_conversation(context: ConversationContext, share "show_duplicate_action": True, }, ) - share = await context._conversations_client.create_conversation_share_with_owner( - new_conversation_share=new_share, owner_id=user_id + share = ( + await context._conversations_client.create_conversation_share_with_owner( + new_conversation_share=new_share, owner_id=user_id + ) ) share_url = f"/conversation-share/{share.id}/redeem" @@ -174,7 +187,9 @@ async def create_shareable_team_conversation(context: ConversationContext, share knowledge_package.updated_at = datetime.utcnow() ShareStorage.write_share(share_id, knowledge_package) else: - raise ValueError(f"KnowledgePackage info not found for share ID: {share_id}") + raise ValueError( + f"KnowledgePackage info not found for share ID: {share_id}" + ) return share_url @@ -273,7 +288,9 @@ async def set_share(context: ConversationContext, share: KnowledgePackage) -> No """ share_id = await ShareManager.get_share_id(context) if not share_id: - raise ValueError("Cannot set share: no share ID found for this conversation") + raise ValueError( + "Cannot set share: no share ID found for this conversation" + ) ShareStorage.write_share(share_id, share) @@ -314,7 +331,9 @@ async def get_linked_conversations(context: ConversationContext) -> List[str]: return [] @staticmethod - async def _capture_redeemer_info(context: ConversationContext, share_id: str) -> None: + async def _capture_redeemer_info( + context: ConversationContext, share_id: str + ) -> None: """ Captures the redeemer (first non-assistant participant) information and stores it in the knowledge package. Only captures info for actual team member conversations, not coordinator or shared conversations. @@ -322,19 +341,25 @@ async def _capture_redeemer_info(context: ConversationContext, share_id: str) -> try: knowledge_package = ShareStorage.read_share(share_id) if not knowledge_package: - logger.warning(f"Could not load knowledge package {share_id} to capture redeemer info") + logger.warning( + f"Could not load knowledge package {share_id} to capture redeemer info" + ) return conversation_id = str(context.id) # Skip if this is the coordinator conversation if conversation_id == knowledge_package.coordinator_conversation_id: - logger.debug(f"Skipping redeemer capture for coordinator conversation {conversation_id}") + logger.debug( + f"Skipping redeemer capture for coordinator conversation {conversation_id}" + ) return # Skip if this is the shared conversation template if conversation_id == knowledge_package.shared_conversation_id: - logger.debug(f"Skipping redeemer capture for shared conversation template {conversation_id}") + logger.debug( + f"Skipping redeemer capture for shared conversation template {conversation_id}" + ) return # If we get here, it's a team member conversation - capture redeemer info @@ -342,27 +367,37 @@ async def _capture_redeemer_info(context: ConversationContext, share_id: str) -> user_id, user_name = await get_current_user(context) if not user_id or not user_name: - logger.warning(f"Could not identify redeemer for conversation {conversation_id}") + logger.warning( + f"Could not identify redeemer for conversation {conversation_id}" + ) return # Create team conversation info team_conversation_info = TeamConversationInfo( - conversation_id=conversation_id, redeemer_user_id=user_id, redeemer_name=user_name + conversation_id=conversation_id, + redeemer_user_id=user_id, + redeemer_name=user_name, ) # Add to knowledge package - knowledge_package.team_conversations[conversation_id] = team_conversation_info + knowledge_package.team_conversations[conversation_id] = ( + team_conversation_info + ) # Save the updated knowledge package ShareStorage.write_share(share_id, knowledge_package) - logger.debug(f"Captured redeemer info for team conversation {conversation_id}: {user_name} ({user_id})") + logger.debug( + f"Captured redeemer info for team conversation {conversation_id}: {user_name} ({user_id})" + ) except Exception as e: logger.error(f"Error capturing redeemer info: {e}") # Don't re-raise - this is not critical for the association process @staticmethod - async def get_share_log(context: ConversationContext) -> Optional[KnowledgePackageLog]: + async def get_share_log( + context: ConversationContext, + ) -> Optional[KnowledgePackageLog]: """Gets the knowledge transfer log for the current conversation's share.""" share_id = await ShareManager.get_share_id(context) if not share_id: @@ -386,7 +421,9 @@ async def append_coordinator_message( """ share_id = await ShareManager.get_share_id(context) if not share_id: - raise ValueError("Cannot append message: no share ID found for this conversation") + raise ValueError( + "Cannot append message: no share ID found for this conversation" + ) ShareStorage.append_coordinator_message( share_id=share_id, @@ -398,7 +435,9 @@ async def append_coordinator_message( ) @staticmethod - async def get_coordinator_conversation(context: ConversationContext) -> Optional[CoordinatorConversationMessages]: + async def get_coordinator_conversation( + context: ConversationContext, + ) -> Optional[CoordinatorConversationMessages]: """ Gets the coordinator conversation. """ diff --git a/assistants/knowledge-transfer-assistant/assistant/domain/knowledge_transfer_manager.py b/assistants/knowledge-transfer-assistant/assistant/domain/transfer_manager.py similarity index 70% rename from assistants/knowledge-transfer-assistant/assistant/domain/knowledge_transfer_manager.py rename to assistants/knowledge-transfer-assistant/assistant/domain/transfer_manager.py index 87b851e04..01744b729 100644 --- a/assistants/knowledge-transfer-assistant/assistant/domain/knowledge_transfer_manager.py +++ b/assistants/knowledge-transfer-assistant/assistant/domain/transfer_manager.py @@ -5,19 +5,20 @@ presentation, achievement tracking, and conversation management. """ +from assistant.data import KnowledgePackage -from assistant.domain.learning_objectives_manager import LearningObjectivesManager +from .learning_objectives_manager import LearningObjectivesManager -from ..data import KnowledgePackage - -class KnowledgeTransferManager: +class TransferManager: """Manager class for KnowledgePackage business logic operations.""" @staticmethod def is_ready_for_transfer(package: KnowledgePackage) -> bool: has_basic_requirements = ( - package.knowledge_organized and package.brief is not None and package.audience is not None + package.knowledge_organized + and package.brief is not None + and package.audience is not None ) if not has_basic_requirements: @@ -32,7 +33,10 @@ def is_ready_for_transfer(package: KnowledgePackage) -> bool: @staticmethod def is_actively_sharing(package: KnowledgePackage) -> bool: - return KnowledgeTransferManager.is_ready_for_transfer(package) and len(package.team_conversations) > 0 + return ( + TransferManager.is_ready_for_transfer(package) + and len(package.team_conversations) > 0 + ) @staticmethod def _is_transfer_complete(package: KnowledgePackage) -> bool: @@ -44,5 +48,7 @@ def _is_transfer_complete(package: KnowledgePackage) -> bool: if not package.is_intended_to_accomplish_outcomes: return False - achieved_outcomes, total_outcomes = LearningObjectivesManager.get_overall_completion(package) + achieved_outcomes, total_outcomes = ( + LearningObjectivesManager.get_overall_completion(package) + ) return total_outcomes > 0 and achieved_outcomes == total_outcomes diff --git a/assistants/knowledge-transfer-assistant/assistant/files.py b/assistants/knowledge-transfer-assistant/assistant/files.py index 1947a54d9..00ef0a53b 100644 --- a/assistants/knowledge-transfer-assistant/assistant/files.py +++ b/assistants/knowledge-transfer-assistant/assistant/files.py @@ -13,15 +13,17 @@ from pydantic import BaseModel, Field from semantic_workbench_api_model import workbench_model -from semantic_workbench_api_model.workbench_model import MessageType, NewConversationMessage +from semantic_workbench_api_model.workbench_model import ( + MessageType, + NewConversationMessage, +) from semantic_workbench_assistant.assistant_app import ConversationContext from .conversation_clients import ConversationClientManager +from .data import ConversationRole, LogEntryType from .domain.share_manager import ShareManager -from .data import LogEntryType from .logging import logger from .storage import ShareStorageManager, read_model, write_model -from .data import ConversationRole # Define helper function for safe logging without 'filename' conflict @@ -136,13 +138,18 @@ async def copy_file_to_share_storage( try: # Verify file information if not file.filename: - logger.error("Missing filename in file metadata", extra=safe_extra(log_extra)) + logger.error( + "Missing filename in file metadata", extra=safe_extra(log_extra) + ) return False # Check if share storage directory exists files_dir = ShareFilesManager.get_share_files_dir(share_id) if not files_dir.exists(): - logger.debug(f"Creating knowledge transfer files directory: {files_dir}", extra=safe_extra(log_extra)) + logger.debug( + f"Creating knowledge transfer files directory: {files_dir}", + extra=safe_extra(log_extra), + ) files_dir.mkdir(parents=True, exist_ok=True) # Read the file from the conversation with error handling @@ -156,12 +163,16 @@ async def copy_file_to_share_storage( buffer_size = buffer.tell() if buffer_size == 0: logger.error( - "Failed to read file content from conversation - buffer is empty", extra=safe_extra(log_extra) + "Failed to read file content from conversation - buffer is empty", + extra=safe_extra(log_extra), ) return False except Exception as read_error: - logger.error(f"Error reading file from conversation: {read_error}", extra=safe_extra(log_extra)) + logger.error( + f"Error reading file from conversation: {read_error}", + extra=safe_extra(log_extra), + ) return False buffer.seek(0) @@ -181,7 +192,10 @@ async def copy_file_to_share_storage( return False except Exception as write_error: - logger.error(f"Error writing file to share storage: {write_error}", extra=safe_extra(log_extra)) + logger.error( + f"Error writing file to share storage: {write_error}", + extra=safe_extra(log_extra), + ) return False # Store file metadata @@ -200,7 +214,10 @@ async def copy_file_to_share_storage( # Add to metadata collection with error handling try: metadata_path = ShareFilesManager.get_file_metadata_path(share_id) - logger.debug(f"Reading metadata from {metadata_path}", extra=safe_extra(log_extra)) + logger.debug( + f"Reading metadata from {metadata_path}", + extra=safe_extra(log_extra), + ) metadata = read_model(metadata_path, ShareCollection) if not metadata: @@ -210,7 +227,14 @@ async def copy_file_to_share_storage( ) # Check if file already exists in collection - existing_idx = next((i for i, f in enumerate(metadata.files) if f.filename == file.filename), None) + existing_idx = next( + ( + i + for i, f in enumerate(metadata.files) + if f.filename == file.filename + ), + None, + ) if existing_idx is not None: metadata.files[existing_idx] = file_metadata else: @@ -221,30 +245,44 @@ async def copy_file_to_share_storage( # Verify metadata was written if not metadata_path.exists(): - logger.error(f"Failed to write metadata file {metadata_path}", extra=safe_extra(log_extra)) + logger.error( + f"Failed to write metadata file {metadata_path}", + extra=safe_extra(log_extra), + ) return False # Final check - verify file appears in metadata verification_metadata = read_model(metadata_path, ShareCollection) if not verification_metadata: - logger.error("Metadata file exists but can't be read", extra=safe_extra(log_extra)) + logger.error( + "Metadata file exists but can't be read", + extra=safe_extra(log_extra), + ) return False - file_exists_in_metadata = any(f.filename == file.filename for f in verification_metadata.files) + file_exists_in_metadata = any( + f.filename == file.filename for f in verification_metadata.files + ) if not file_exists_in_metadata: logger.error( - f"File metadata doesn't contain entry for {file.filename}", extra=safe_extra(log_extra) + f"File metadata doesn't contain entry for {file.filename}", + extra=safe_extra(log_extra), ) return False except Exception as metadata_error: - logger.error(f"Error updating metadata: {metadata_error}", extra=safe_extra(log_extra)) + logger.error( + f"Error updating metadata: {metadata_error}", + extra=safe_extra(log_extra), + ) return False return True except Exception as e: - logger.exception(f"Error copying file to share storage: {e}", extra=safe_extra(log_extra)) + logger.exception( + f"Error copying file to share storage: {e}", extra=safe_extra(log_extra) + ) return False @staticmethod @@ -295,13 +333,17 @@ async def notify_team_conversations_file_deleted( """ try: # Get Team conversations - team_conversations = await ShareFilesManager.get_team_conversations(context, share_id) + team_conversations = await ShareFilesManager.get_team_conversations( + context, share_id + ) if not team_conversations: return for conv_id in team_conversations: try: - client = ConversationClientManager.get_conversation_client(context, conv_id) + client = ConversationClientManager.get_conversation_client( + context, conv_id + ) # Check if file exists in the conversation conversation = await client.get_conversation() @@ -311,7 +353,9 @@ async def notify_team_conversations_file_deleted( if file_exists: # Delete the file await client.delete_file(filename) - logger.debug(f"Deleted file {filename} from Team conversation {conv_id}") + logger.debug( + f"Deleted file {filename} from Team conversation {conv_id}" + ) # Send notification await client.send_messages( @@ -321,10 +365,14 @@ async def notify_team_conversations_file_deleted( ) ) except Exception as e: - logger.warning(f"Failed to delete file {filename} from Team conversation {conv_id}: {e}") + logger.warning( + f"Failed to delete file {filename} from Team conversation {conv_id}: {e}" + ) except Exception as e: - logger.exception(f"Error notifying Team conversations about deleted file: {e}") + logger.exception( + f"Error notifying Team conversations about deleted file: {e}" + ) @staticmethod async def copy_file_to_conversation( @@ -350,15 +398,21 @@ async def copy_file_to_conversation( return False # Find the file metadata - file_meta = next((f for f in metadata.files if f.filename == filename), None) + file_meta = next( + (f for f in metadata.files if f.filename == filename), None + ) if not file_meta: logger.warning(f"No metadata found for file {filename}") return False # Create client for target conversation - target_client = ConversationClientManager.get_conversation_client(context, target_conversation_id) + target_client = ConversationClientManager.get_conversation_client( + context, target_conversation_id + ) if not target_client: - logger.warning(f"Could not create client for conversation {target_conversation_id}") + logger.warning( + f"Could not create client for conversation {target_conversation_id}" + ) return False # Read the file content @@ -367,7 +421,9 @@ async def copy_file_to_conversation( file_bytes = f.read() if not file_bytes: - logger.warning(f"Failed to read file content from {file_path} (empty file)") + logger.warning( + f"Failed to read file content from {file_path} (empty file)" + ) return False file_content = io.BytesIO(file_bytes) @@ -399,7 +455,11 @@ async def copy_file_to_conversation( # Upload the file try: file_content.seek(0) # Reset position to start of file - await target_client.write_file(filename=filename, file_content=file_content, content_type=content_type) + await target_client.write_file( + filename=filename, + file_content=file_content, + content_type=content_type, + ) logger.debug(f"Successfully uploaded file {filename}") return True except Exception as upload_error: @@ -411,7 +471,9 @@ async def copy_file_to_conversation( return False @staticmethod - async def get_team_conversations(context: ConversationContext, share_id: str) -> List[str]: + async def get_team_conversations( + context: ConversationContext, share_id: str + ) -> List[str]: """ Gets all Team conversation IDs for a share. """ @@ -423,7 +485,9 @@ async def get_team_conversations(context: ConversationContext, share_id: str) -> team_conversations = [] for conv_id in linked_conversations: # Check if this is a team conversation - temp_context = await ShareFilesManager.create_temporary_context(context, conv_id) + temp_context = await ShareFilesManager.create_temporary_context( + context, conv_id + ) if temp_context: role = await ShareManager.get_conversation_role(temp_context) if role == ConversationRole.TEAM: @@ -532,9 +596,7 @@ async def synchronize_files_to_team_conversation( ) # Log the synchronization event - sync_message = ( - f"Synchronized files to Team conversation: {len(successful_files)} new, {len(skipped_files)} existing" - ) + sync_message = f"Synchronized files to Team conversation: {len(successful_files)} new, {len(skipped_files)} existing" await ShareManager.log_share_event( context=context, @@ -548,7 +610,9 @@ async def synchronize_files_to_team_conversation( ) @staticmethod - async def get_shared_files(context: ConversationContext, share_id: str) -> Dict[str, ShareFile]: + async def get_shared_files( + context: ConversationContext, share_id: str + ) -> Dict[str, ShareFile]: """ Gets all shared files for a share with filename as key. """ @@ -578,21 +642,28 @@ async def process_share_update_notification( role = await ShareManager.get_conversation_role(context) if role != ConversationRole.TEAM: - logger.warning("Only Team conversations should process file update notifications") + logger.warning( + "Only Team conversations should process file update notifications" + ) return False # Process based on update type if update_type == "file_created" or update_type == "file_updated": # Synchronize the specific file from share storage success = await ShareFilesManager.copy_file_to_conversation( - context=context, share_id=share_id, filename=filename, target_conversation_id=str(context.id) + context=context, + share_id=share_id, + filename=filename, + target_conversation_id=str(context.id), ) action = "added" if update_type == "file_created" else "updated" if success: return True else: - logger.warning(f"Failed to {action} file {filename} in Team conversation {context.id}") + logger.warning( + f"Failed to {action} file {filename} in Team conversation {context.id}" + ) return False elif update_type == "file_deleted": @@ -606,14 +677,18 @@ async def process_share_update_notification( if file_exists: # Delete the file await context.delete_file(filename) - logger.debug(f"Deleted file {filename} from Team conversation {context.id}") + logger.debug( + f"Deleted file {filename} from Team conversation {context.id}" + ) return True else: # File doesn't exist, nothing to do return True except Exception as e: - logger.warning(f"Failed to delete file {filename} from Team conversation: {e}") + logger.warning( + f"Failed to delete file {filename} from Team conversation: {e}" + ) return False else: logger.warning(f"Unknown file update type: {update_type}") diff --git a/assistants/knowledge-transfer-assistant/assistant/logging.py b/assistants/knowledge-transfer-assistant/assistant/logging.py index 6a50e1156..0a942f96f 100644 --- a/assistants/knowledge-transfer-assistant/assistant/logging.py +++ b/assistants/knowledge-transfer-assistant/assistant/logging.py @@ -161,7 +161,9 @@ def setup_file_logging(log_dir: Optional[str] = None) -> Path: # Also set up a regular text file handler for easier debugging text_file_handler = logging.FileHandler(line_log_file) text_file_handler.setLevel(logging.DEBUG) - text_file_handler.setFormatter(logging.Formatter("%(asctime)s - %(name)s - [%(levelname)s] %(message)s")) + text_file_handler.setFormatter( + logging.Formatter("%(asctime)s - %(name)s - [%(levelname)s] %(message)s") + ) logger.addHandler(text_file_handler) logger.debug( @@ -183,8 +185,12 @@ def setup_file_logging(log_dir: Optional[str] = None) -> Path: try: import stat - os.chmod(log_file, stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IROTH) - os.chmod(line_log_file, stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IROTH) + os.chmod( + log_file, stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IROTH + ) + os.chmod( + line_log_file, stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IROTH + ) except Exception as e: logger.warning(f"Could not set log file permissions: {e}") print(f"Permission error: {e}") @@ -210,7 +216,11 @@ def setup_file_logging(log_dir: Optional[str] = None) -> Path: text_file_handler = logging.FileHandler(line_log_file) text_file_handler.setLevel(logging.DEBUG) - text_file_handler.setFormatter(logging.Formatter("%(asctime)s - %(name)s - [%(levelname)s] %(message)s")) + text_file_handler.setFormatter( + logging.Formatter( + "%(asctime)s - %(name)s - [%(levelname)s] %(message)s" + ) + ) logger.addHandler(text_file_handler) logger.warning(f"Using fallback log location: {log_file}") diff --git a/assistants/knowledge-transfer-assistant/assistant/notifications.py b/assistants/knowledge-transfer-assistant/assistant/notifications.py index 53391a394..ed3857ce5 100644 --- a/assistants/knowledge-transfer-assistant/assistant/notifications.py +++ b/assistants/knowledge-transfer-assistant/assistant/notifications.py @@ -1,13 +1,17 @@ from typing import List, Optional -from semantic_workbench_api_model.workbench_model import AssistantStateEvent, MessageType, NewConversationMessage +from semantic_workbench_api_model.workbench_model import ( + AssistantStateEvent, + MessageType, + NewConversationMessage, +) from semantic_workbench_assistant.assistant_app import ConversationContext from assistant.domain.share_manager import ShareManager +from .conversation_clients import ConversationClientManager from .data import InspectorTab from .logging import logger -from .conversation_clients import ConversationClientManager class Notifications: @@ -23,7 +27,10 @@ async def notify(context: ConversationContext, message: str) -> None: @staticmethod async def notify_self_and_other( - context: ConversationContext, share_id: str, message: str, other_conversation_id: Optional[str] = None + context: ConversationContext, + share_id: str, + message: str, + other_conversation_id: Optional[str] = None, ) -> None: """ Send text message notification to current conversation and one other. @@ -52,7 +59,9 @@ async def notify_self_and_other( return try: - client = ConversationClientManager.get_conversation_client(context, target_id) + client = ConversationClientManager.get_conversation_client( + context, target_id + ) await client.send_messages( NewConversationMessage( content=message, @@ -63,7 +72,9 @@ async def notify_self_and_other( logger.error(f"Failed to notify conversation {target_id}: {e}") @staticmethod - async def notify_all(context: ConversationContext, share_id: str, message: str) -> None: + async def notify_all( + context: ConversationContext, share_id: str, message: str + ) -> None: """Send text message notification to all knowledge transfer conversations.""" knowledge_package = await ShareManager.get_share(context) @@ -95,9 +106,14 @@ async def notify_all(context: ConversationContext, share_id: str, message: str) # Notify all team conversations for conv_id in knowledge_package.team_conversations.keys(): - if conv_id != current_id and conv_id != knowledge_package.coordinator_conversation_id: + if ( + conv_id != current_id + and conv_id != knowledge_package.coordinator_conversation_id + ): try: - client = ConversationClientManager.get_conversation_client(context, conv_id) + client = ConversationClientManager.get_conversation_client( + context, conv_id + ) await client.send_messages( NewConversationMessage( content=message, @@ -110,7 +126,9 @@ async def notify_all(context: ConversationContext, share_id: str, message: str) # State Update Notifications (UI refreshes) @staticmethod - async def notify_state_update(context: ConversationContext, tabs: List[InspectorTab]) -> None: + async def notify_state_update( + context: ConversationContext, tabs: List[InspectorTab] + ) -> None: """Send state update notifications to refresh UI in current conversation only.""" for tab in tabs: state_event = AssistantStateEvent( @@ -121,7 +139,9 @@ async def notify_state_update(context: ConversationContext, tabs: List[Inspector await context.send_conversation_state_event(state_event) @staticmethod - async def notify_all_state_update(context: ConversationContext, share_id: str, tabs: List[InspectorTab]) -> None: + async def notify_all_state_update( + context: ConversationContext, share_id: str, tabs: List[InspectorTab] + ) -> None: """Send state update notifications to refresh UI across all share conversations.""" # Refresh current conversation first @@ -160,9 +180,14 @@ async def notify_all_state_update(context: ConversationContext, share_id: str, t # Refresh all team conversations for conv_id in knowledge_package.team_conversations.keys(): - if conv_id != current_id and conv_id != knowledge_package.coordinator_conversation_id: + if ( + conv_id != current_id + and conv_id != knowledge_package.coordinator_conversation_id + ): try: - client = ConversationClientManager.get_conversation_client(context, conv_id) + client = ConversationClientManager.get_conversation_client( + context, conv_id + ) for tab in tabs: state_event = AssistantStateEvent( diff --git a/assistants/knowledge-transfer-assistant/assistant/respond.py b/assistants/knowledge-transfer-assistant/assistant/respond.py index 085779362..4d2d8ef0c 100644 --- a/assistants/knowledge-transfer-assistant/assistant/respond.py +++ b/assistants/knowledge-transfer-assistant/assistant/respond.py @@ -42,13 +42,21 @@ SILENCE_TOKEN = "{{SILENCE}}" -def format_message(participants: ConversationParticipantList, message: ConversationMessage) -> str: +def format_message( + participants: ConversationParticipantList, message: ConversationMessage +) -> str: """Consistent formatter that includes the participant name for multi-participant and name references""" conversation_participant = next( - (participant for participant in participants.participants if participant.id == message.sender.participant_id), + ( + participant + for participant in participants.participants + if participant.id == message.sender.participant_id + ), None, ) - participant_name = conversation_participant.name if conversation_participant else "unknown" + participant_name = ( + conversation_participant.name if conversation_participant else "unknown" + ) message_datetime = message.timestamp.strftime("%Y-%m-%d %H:%M:%S") return f"[{participant_name} - {message_datetime}]: {message.content}" @@ -115,7 +123,10 @@ async def respond_to_conversation( model = config.request_config.openai_model # Requirements - role = await ShareManager.get_conversation_role(context) or ConversationRole.COORDINATOR + role = ( + await ShareManager.get_conversation_role(context) + or ConversationRole.COORDINATOR + ) metadata["debug"]["role"] = role token_budget = TokenBudget(config.request_config.max_tokens) @@ -148,11 +159,13 @@ async def respond_to_conversation( "\n\n" f"There are {len(participants.participants)} participants in the conversation," " including you as the assistant and the following users:" - + ",".join([ - f' "{participant.name}"' - for participant in participants.participants - if participant.id != context.assistant.id - ]) + + ",".join( + [ + f' "{participant.name}"' + for participant in participants.participants + if participant.id != context.assistant.id + ] + ) + "\n\nYou do not need to respond to every message. Do not respond if the last thing said was a closing" " statement such as 'bye' or 'goodbye', or just a general acknowledgement like 'ok' or 'thanks'. Do not" f' respond as another user in the conversation, only as "{context.assistant.name}".' @@ -160,7 +173,11 @@ async def respond_to_conversation( f' be directed at you or the general audience, go ahead and respond.\n\nSay "{SILENCE_TOKEN}" to skip' " your turn." ) - instructions.add_subsection(Instructions(participant_text, "Multi-participant conversation instructions")) + instructions.add_subsection( + Instructions( + participant_text, "Multi-participant conversation instructions" + ) + ) prompt = Prompt( role=assistant_role, @@ -198,7 +215,9 @@ async def respond_to_conversation( # Brief if share and share.brief: brief_text = "" - brief_text = f"**Title:** {share.brief.title}\n**Description:** {share.brief.content}" + brief_text = ( + f"**Title:** {share.brief.title}\n**Description:** {share.brief.content}" + ) prompt.contexts.append( Context( "Knowledge Brief", @@ -228,29 +247,37 @@ async def respond_to_conversation( # Show progress based on role if role == ConversationRole.COORDINATOR: # Coordinator sees overall progress across all team members - achieved_overall, total_overall = LearningObjectivesManager.get_overall_completion(share) - learning_objectives_text += ( - f"Overall Progress: {achieved_overall}/{total_overall} outcomes achieved by team members\n\n" + achieved_overall, total_overall = ( + LearningObjectivesManager.get_overall_completion(share) ) + learning_objectives_text += f"Overall Progress: {achieved_overall}/{total_overall} outcomes achieved by team members\n\n" else: # Team member sees their personal progress if conversation_id in share.team_conversations: - achieved_personal, total_personal = LearningObjectivesManager.get_completion_for_conversation( - share, conversation_id + achieved_personal, total_personal = ( + LearningObjectivesManager.get_completion_for_conversation( + share, conversation_id + ) ) - progress_pct = int((achieved_personal / total_personal * 100)) if total_personal > 0 else 0 - learning_objectives_text += ( - f"My Progress: {achieved_personal}/{total_personal} outcomes achieved ({progress_pct}%)\n\n" + progress_pct = ( + int((achieved_personal / total_personal * 100)) + if total_personal > 0 + else 0 ) + learning_objectives_text += f"My Progress: {achieved_personal}/{total_personal} outcomes achieved ({progress_pct}%)\n\n" for i, objective in enumerate(share.learning_objectives): - learning_objectives_text += f"{i + 1}. **{objective.name}** - {objective.description}\n" + learning_objectives_text += ( + f"{i + 1}. **{objective.name}** - {objective.description}\n" + ) if objective.learning_outcomes: for criterion in objective.learning_outcomes: if role == ConversationRole.COORDINATOR: # Show if achieved by any team member achieved_by_any = any( - LearningObjectivesManager.is_outcome_achieved_by_conversation(share, criterion.id, conv_id) + LearningObjectivesManager.is_outcome_achieved_by_conversation( + share, criterion.id, conv_id + ) for conv_id in share.team_conversations.keys() ) check = "✅" if achieved_by_any else "⬜" @@ -272,27 +299,35 @@ async def respond_to_conversation( # Knowledge digest if share and share.digest and share.digest.content: prompt.contexts.append( - Context("Knowledge digest", share.digest.content, "The assistant-maintained knowledge digest.") + Context( + "Knowledge digest", + share.digest.content, + "The assistant-maintained knowledge digest.", + ) ) # Information requests if share: all_requests = share.requests if role == ConversationRole.COORDINATOR: - active_requests = [r for r in all_requests if r.status != RequestStatus.RESOLVED] + active_requests = [ + r for r in all_requests if r.status != RequestStatus.RESOLVED + ] if active_requests: - coordinator_requests = ( - "> 📋 **Use the request ID (not the title) with resolve_information_request()**\n\n" - ) + coordinator_requests = "> 📋 **Use the request ID (not the title) with resolve_information_request()**\n\n" for req in active_requests[:10]: # Limit to 10 for brevity priority_emoji = get_priority_emoji(req.priority) status_emoji = get_status_emoji(req.status) - coordinator_requests += f"{priority_emoji} **{req.title}** {status_emoji}\n" + coordinator_requests += ( + f"{priority_emoji} **{req.title}** {status_emoji}\n" + ) coordinator_requests += f" **Request ID:** `{req.request_id}`\n" coordinator_requests += f" **Description:** {req.description}\n\n" if len(active_requests) > 10: - coordinator_requests += f"*...and {len(active_requests) - 10} more requests.*\n" + coordinator_requests += ( + f"*...and {len(active_requests) - 10} more requests.*\n" + ) else: coordinator_requests = "No active information requests." prompt.contexts.append( @@ -307,15 +342,16 @@ async def respond_to_conversation( # Filter for requests from this conversation that aren't resolved. my_requests = [ - r for r in all_requests if r.conversation_id == str(context.id) and r.status != RequestStatus.RESOLVED + r + for r in all_requests + if r.conversation_id == str(context.id) + and r.status != RequestStatus.RESOLVED ] if my_requests: information_requests_info = "" for req in my_requests: - information_requests_info += ( - f"- **{req.title}** (ID: `{req.request_id}`, Priority: {req.priority})\n" - ) + information_requests_info += f"- **{req.title}** (ID: `{req.request_id}`, Priority: {req.priority})\n" else: information_requests_info = "No active information requests." @@ -328,7 +364,9 @@ async def respond_to_conversation( # Add next action suggestions for coordinator if role == ConversationRole.COORDINATOR: - next_action_suggestion = await CoordinatorSupport.get_coordinator_next_action_suggestion(context) + next_action_suggestion = ( + await CoordinatorSupport.get_coordinator_next_action_suggestion(context) + ) if next_action_suggestion: prompt.contexts.append( Context( @@ -356,9 +394,13 @@ async def respond_to_conversation( if coordinator_conversation: # Limit messages to the configured max token count. total_coordinator_conversation_tokens = 0 - selected_coordinator_conversation_messages: List[CoordinatorConversationMessage] = [] + selected_coordinator_conversation_messages: List[ + CoordinatorConversationMessage + ] = [] for msg in reversed(coordinator_conversation.messages): - tokens = openai_client.num_tokens_from_string(msg.model_dump_json(), model=model) + tokens = openai_client.num_tokens_from_string( + msg.model_dump_json(), model=model + ) if ( total_coordinator_conversation_tokens + tokens > config.request_config.coordinator_conversation_token_limit @@ -372,7 +414,9 @@ class CoordinatorMessageList(BaseModel): messages: List[CoordinatorConversationMessage] = Field(default_factory=list) selected_coordinator_conversation_messages.reverse() - coordinator_message_list = CoordinatorMessageList(messages=selected_coordinator_conversation_messages) + coordinator_message_list = CoordinatorMessageList( + messages=selected_coordinator_conversation_messages + ) coordinator_conversation_message = ChatCompletionSystemMessageParam( role="system", content=( @@ -396,10 +440,12 @@ class CoordinatorMessageList(BaseModel): # in the proper flow of the conversation rather than as . # Generate the attachment messages. - attachment_messages: List[ChatCompletionMessageParam] = openai_client.convert_from_completion_messages( - await attachments_extension.get_completion_messages_for_attachments( - context, - config=config.attachments_config, + attachment_messages: List[ChatCompletionMessageParam] = ( + openai_client.convert_from_completion_messages( + await attachments_extension.get_completion_messages_for_attachments( + context, + config=config.attachments_config, + ) ) ) @@ -524,9 +570,14 @@ class CoordinatorMessageList(BaseModel): # For team role, analyze message for possible information request needs. # Send a notification if we think it might be one. if role is ConversationRole.TEAM: - detection_result = await detect_information_request_needs(context, new_message.content) + detection_result = await detect_information_request_needs( + context, new_message.content + ) - if detection_result.get("is_information_request", False) and detection_result.get("confidence", 0) > 0.8: + if ( + detection_result.get("is_information_request", False) + and detection_result.get("confidence", 0) > 0.8 + ): suggested_title = detection_result.get("potential_title", "") suggested_priority = detection_result.get("suggested_priority", "medium") potential_description = detection_result.get("potential_description", "") @@ -560,7 +611,9 @@ class CoordinatorMessageList(BaseModel): "messages": completion_messages, "model": model, "max_tokens": config.request_config.response_tokens, - "response_format": CoordinatorOutput if role == ConversationRole.COORDINATOR else TeamOutput, + "response_format": CoordinatorOutput + if role == ConversationRole.COORDINATOR + else TeamOutput, } share_tools = ShareTools(context, role) @@ -576,7 +629,11 @@ class CoordinatorMessageList(BaseModel): # Add the token usage message to the footer items if completion_response: - response_tokens = completion_response.usage.completion_tokens if completion_response.usage else 0 + response_tokens = ( + completion_response.usage.completion_tokens + if completion_response.usage + else 0 + ) request_tokens = token_budget.used footer_items.append( get_token_usage_message( @@ -596,7 +653,9 @@ class CoordinatorMessageList(BaseModel): } ) - footer_items.append(get_response_duration_message(response_end_time - response_start_time)) + footer_items.append( + get_response_duration_message(response_end_time - response_start_time) + ) metadata["footer_items"] = footer_items content = message_content_from_completion(completion_response) diff --git a/assistants/knowledge-transfer-assistant/assistant/storage.py b/assistants/knowledge-transfer-assistant/assistant/storage.py index eb38fa1d5..4b3e87122 100644 --- a/assistants/knowledge-transfer-assistant/assistant/storage.py +++ b/assistants/knowledge-transfer-assistant/assistant/storage.py @@ -10,11 +10,15 @@ from semantic_workbench_assistant import settings from semantic_workbench_assistant.assistant_app import ConversationContext -from semantic_workbench_assistant.assistant_app.context import storage_directory_for_context +from semantic_workbench_assistant.assistant_app.context import ( + storage_directory_for_context, +) from semantic_workbench_assistant.storage import read_model, write_model # Import inside functions to avoid circular imports from .data import ( + CoordinatorConversationMessage, + CoordinatorConversationMessages, InformationRequest, KnowledgeBrief, KnowledgeDigest, @@ -23,7 +27,6 @@ LogEntry, LogEntryType, ) -from .data import CoordinatorConversationMessage, CoordinatorConversationMessages from .utils import get_current_user @@ -137,12 +140,16 @@ def read_knowledge_digest(share_id: str) -> Optional[KnowledgeDigest]: return package.digest if package else None @staticmethod - def read_coordinator_conversation(share_id: str) -> Optional[CoordinatorConversationMessages]: + def read_coordinator_conversation( + share_id: str, + ) -> Optional[CoordinatorConversationMessages]: path = ShareStorageManager.get_coordinator_conversation_path(share_id) return read_model(path, CoordinatorConversationMessages) @staticmethod - def write_coordinator_conversation(share_id: str, conversation: CoordinatorConversationMessages) -> pathlib.Path: + def write_coordinator_conversation( + share_id: str, conversation: CoordinatorConversationMessages + ) -> pathlib.Path: path = ShareStorageManager.get_coordinator_conversation_path(share_id) write_model(path, conversation) return path @@ -204,7 +211,9 @@ def write_knowledge_digest(share_id: str, digest: KnowledgeDigest) -> pathlib.Pa return ShareStorage.write_share(share_id, package) @staticmethod - def read_information_request(share_id: str, request_id: str) -> Optional[InformationRequest]: + def read_information_request( + share_id: str, request_id: str + ) -> Optional[InformationRequest]: """Reads an information request from the main share data.""" package = ShareStorage.read_share(share_id) if not package or not package.requests: @@ -217,7 +226,9 @@ def read_information_request(share_id: str, request_id: str) -> Optional[Informa return None @staticmethod - def write_information_request(share_id: str, request: InformationRequest) -> pathlib.Path: + def write_information_request( + share_id: str, request: InformationRequest + ) -> pathlib.Path: """Writes an information request to the main share data.""" # Information requests must have an ID if not request.request_id: diff --git a/assistants/knowledge-transfer-assistant/assistant/string_utils.py b/assistants/knowledge-transfer-assistant/assistant/string_utils.py index a9bcb7892..b783dc6ca 100644 --- a/assistants/knowledge-transfer-assistant/assistant/string_utils.py +++ b/assistants/knowledge-transfer-assistant/assistant/string_utils.py @@ -18,7 +18,9 @@ def render(template: str, vars: dict[str, Any]) -> str: return parsed -def create_system_message(content: str, delimiter: str | None = None) -> ChatCompletionMessageParam: +def create_system_message( + content: str, delimiter: str | None = None +) -> ChatCompletionMessageParam: if delimiter: content = f"<{delimiter}>\n{content}\n" @@ -58,7 +60,9 @@ def __str__(self) -> str: s += f"{hashes} {self.title}\n\n" s += self.content if self.subsections: - s += "\n\n" + "\n\n".join(str(subsection) for subsection in self.subsections) + s += "\n\n" + "\n\n".join( + str(subsection) for subsection in self.subsections + ) return s diff --git a/assistants/knowledge-transfer-assistant/assistant/tools/information_requests.py b/assistants/knowledge-transfer-assistant/assistant/tools/information_requests.py index 7dbb3a33e..f889ded95 100644 --- a/assistants/knowledge-transfer-assistant/assistant/tools/information_requests.py +++ b/assistants/knowledge-transfer-assistant/assistant/tools/information_requests.py @@ -6,11 +6,10 @@ from typing import Literal -from assistant.data import RequestPriority -from assistant.domain.information_request_manager import InformationRequestManager -from assistant.domain.share_manager import ShareManager +from assistant.data import ConversationRole, RequestPriority +from assistant.domain import InformationRequestManager, ShareManager from assistant.logging import logger -from assistant.data import ConversationRole + from .base import ToolsBase @@ -18,7 +17,10 @@ class InformationRequestTools(ToolsBase): """Tools for managing information requests.""" async def create_information_request( - self, title: str, description: str, priority: Literal["low", "medium", "high", "critical"] + self, + title: str, + description: str, + priority: Literal["low", "medium", "high", "critical"], ) -> str: """ Create an information request to send to the Coordinator for information that is unavailable to you or to report a blocker. @@ -60,14 +62,19 @@ async def create_information_request( priority_enum = priority_map.get(priority.lower(), RequestPriority.MEDIUM) success, request = await InformationRequestManager.create_information_request( - context=self.context, title=title, description=description, priority=priority_enum + context=self.context, + title=title, + description=description, + priority=priority_enum, ) if success and request: return f"Information request '{title}' created successfully. The Coordinator has been notified." else: return "Failed to create information request. Please try again." - async def resolve_information_request(self, request_id: str, resolution: str) -> str: + async def resolve_information_request( + self, request_id: str, resolution: str + ) -> str: """ Resolve an information request when you have the needed information to address it. Only use for active information requests. If there are no active information requests, this should never be called. @@ -91,21 +98,32 @@ async def resolve_information_request(self, request_id: str, resolution: str) -> "create_information_request to send requests to the Coordinator, not try to resolve them yourself. " "The Coordinator must use resolve_information_request to respond to your requests." ) - logger.warning(f"Team member attempted to use resolve_information_request: {request_id}") + logger.warning( + f"Team member attempted to use resolve_information_request: {request_id}" + ) return error_message share = await ShareManager.get_share(self.context) if not share: return "No knowledge package associated with this conversation. Unable to resolve information request." - success, information_request = await InformationRequestManager.resolve_information_request( + ( + success, + information_request, + ) = await InformationRequestManager.resolve_information_request( context=self.context, request_id=request_id, resolution=resolution ) if success and information_request: - return f"Information request '{information_request.title}' has been resolved." + return ( + f"Information request '{information_request.title}' has been resolved." + ) else: - logger.warning(f"Failed to resolve information request. Invalid ID provided: '{request_id}'") - return f'ERROR: Could not resolve information request with ID "{request_id}".' + logger.warning( + f"Failed to resolve information request. Invalid ID provided: '{request_id}'" + ) + return ( + f'ERROR: Could not resolve information request with ID "{request_id}".' + ) async def delete_information_request(self, request_id: str) -> str: """ @@ -124,4 +142,12 @@ async def delete_information_request(self, request_id: str) -> str: success, message = await InformationRequestManager.delete_information_request( context=self.context, request_id=request_id ) - return message if message else ("Request deleted successfully." if success else "Failed to delete request.") + return ( + message + if message + else ( + "Request deleted successfully." + if success + else "Failed to delete request." + ) + ) diff --git a/assistants/knowledge-transfer-assistant/assistant/tools/learning_objectives.py b/assistants/knowledge-transfer-assistant/assistant/tools/learning_objectives.py index 6a4844e48..bdce598ce 100644 --- a/assistants/knowledge-transfer-assistant/assistant/tools/learning_objectives.py +++ b/assistants/knowledge-transfer-assistant/assistant/tools/learning_objectives.py @@ -6,15 +6,18 @@ from typing import List +from assistant.data import ConversationRole from assistant.domain import LearningObjectivesManager -from ..data import ConversationRole + from .base import ToolsBase class LearningObjectiveTools(ToolsBase): """Tools for managing learning objectives and outcomes.""" - async def add_learning_objective(self, objective_name: str, description: str, learning_outcomes: List[str]) -> str: + async def add_learning_objective( + self, objective_name: str, description: str, learning_outcomes: List[str] + ) -> str: """ Add a learning objective with measurable learning outcomes. @@ -82,7 +85,11 @@ async def update_learning_objective( return ( message if message - else ("Learning objective updated successfully." if success else "Failed to update learning objective.") + else ( + "Learning objective updated successfully." + if success + else "Failed to update learning objective." + ) ) async def delete_learning_objective(self, objective_id: str) -> str: @@ -115,5 +122,9 @@ async def delete_learning_objective(self, objective_id: str) -> str: return ( message if message - else ("Learning objective deleted successfully." if success else "Failed to delete learning objective.") + else ( + "Learning objective deleted successfully." + if success + else "Failed to delete learning objective." + ) ) diff --git a/assistants/knowledge-transfer-assistant/assistant/tools/learning_outcomes.py b/assistants/knowledge-transfer-assistant/assistant/tools/learning_outcomes.py index 5be53312b..bc07014b0 100644 --- a/assistants/knowledge-transfer-assistant/assistant/tools/learning_outcomes.py +++ b/assistants/knowledge-transfer-assistant/assistant/tools/learning_outcomes.py @@ -4,15 +4,18 @@ Tools for managing individual learning outcomes within objectives. """ -from assistant.domain import LearningObjectivesManager from assistant.data import ConversationRole +from assistant.domain import LearningObjectivesManager + from .base import ToolsBase class LearningOutcomeTools(ToolsBase): """Tools for managing learning outcomes.""" - async def add_learning_outcome(self, objective_id: str, outcome_description: str) -> str: + async def add_learning_outcome( + self, objective_id: str, outcome_description: str + ) -> str: """ Add a new learning outcome to an existing learning objective. @@ -41,10 +44,16 @@ async def add_learning_outcome(self, objective_id: str, outcome_description: str return ( message if message - else ("Learning outcome added successfully." if success else "Failed to add learning outcome.") + else ( + "Learning outcome added successfully." + if success + else "Failed to add learning outcome." + ) ) - async def update_learning_outcome(self, outcome_id: str, new_description: str) -> str: + async def update_learning_outcome( + self, outcome_id: str, new_description: str + ) -> str: """ Update the description of an existing learning outcome. @@ -73,7 +82,11 @@ async def update_learning_outcome(self, outcome_id: str, new_description: str) - return ( message if message - else ("Learning outcome updated successfully." if success else "Failed to update learning outcome.") + else ( + "Learning outcome updated successfully." + if success + else "Failed to update learning outcome." + ) ) async def delete_learning_outcome(self, outcome_id: str) -> str: @@ -105,5 +118,9 @@ async def delete_learning_outcome(self, outcome_id: str) -> str: return ( message if message - else ("Learning outcome deleted successfully." if success else "Failed to delete learning outcome.") + else ( + "Learning outcome deleted successfully." + if success + else "Failed to delete learning outcome." + ) ) diff --git a/assistants/knowledge-transfer-assistant/assistant/tools/progress_tracking.py b/assistants/knowledge-transfer-assistant/assistant/tools/progress_tracking.py index 257e0c5cd..742af706c 100644 --- a/assistants/knowledge-transfer-assistant/assistant/tools/progress_tracking.py +++ b/assistants/knowledge-transfer-assistant/assistant/tools/progress_tracking.py @@ -11,20 +11,24 @@ NewConversationMessage, ) -from assistant.domain.learning_objectives_manager import LearningObjectivesManager -from assistant.domain.share_manager import ShareManager - -from ..data import InspectorTab, LearningOutcomeAchievement, LogEntryType -from assistant.domain.knowledge_transfer_manager import KnowledgeTransferManager +from assistant.data import ( + ConversationRole, + InspectorTab, + LearningOutcomeAchievement, + LogEntryType, +) +from assistant.domain import LearningObjectivesManager, ShareManager, TransferManager from assistant.notifications import Notifications -from assistant.data import ConversationRole + from .base import ToolsBase class ProgressTrackingTools(ToolsBase): """Tools for tracking learning progress.""" - async def mark_learning_outcome_achieved(self, objective_id: str, outcome_id: str) -> str: + async def mark_learning_outcome_achieved( + self, objective_id: str, outcome_id: str + ) -> str: """ Mark a learning outcome as achieved for tracking knowledge transfer progress. @@ -86,7 +90,9 @@ async def mark_learning_outcome_achieved(self, objective_id: str, outcome_id: st conversation_id = str(self.context.id) # Check if already achieved by this conversation - if LearningObjectivesManager.is_outcome_achieved_by_conversation(knowledge_package, outcome.id, conversation_id): + if LearningObjectivesManager.is_outcome_achieved_by_conversation( + knowledge_package, outcome.id, conversation_id + ): return f"Outcome '{outcome.description}' is already marked as achieved by this team member." # Get current user information @@ -106,13 +112,19 @@ async def mark_learning_outcome_achieved(self, objective_id: str, outcome_id: st return "Team conversation not properly registered. Please contact the coordinator." # Create achievement record - achievement = LearningOutcomeAchievement(outcome_id=outcome.id, achieved=True, achieved_at=datetime.utcnow()) + achievement = LearningOutcomeAchievement( + outcome_id=outcome.id, achieved=True, achieved_at=datetime.utcnow() + ) # Add achievement to team conversation's achievements - knowledge_package.team_conversations[conversation_id].outcome_achievements.append(achievement) + knowledge_package.team_conversations[ + conversation_id + ].outcome_achievements.append(achievement) # Update team conversation's last active timestamp - knowledge_package.team_conversations[conversation_id].last_active_at = datetime.utcnow() + knowledge_package.team_conversations[ + conversation_id + ].last_active_at = datetime.utcnow() # Save the updated knowledge package with the achieved outcome await ShareManager.set_share(self.context, knowledge_package) @@ -123,7 +135,10 @@ async def mark_learning_outcome_achieved(self, objective_id: str, outcome_id: st entry_type=LogEntryType.OUTCOME_ATTAINED.value, message=f"Learning outcome achieved: {outcome.description}", related_entity_id=None, - metadata={"objective_name": objective.name, "outcome_description": outcome.description}, + metadata={ + "objective_name": objective.name, + "outcome_description": outcome.description, + }, ) # Update knowledge package @@ -143,13 +158,17 @@ async def mark_learning_outcome_achieved(self, objective_id: str, outcome_id: st f"Learning outcome '{outcome.description}' for objective '{objective.name}' has been marked as achieved.", ) await Notifications.notify_all_state_update( - self.context, share.share_id, [InspectorTab.LEARNING, InspectorTab.BRIEF] + self.context, + share.share_id, + [InspectorTab.LEARNING, InspectorTab.BRIEF], ) # Check if all outcomes are achieved for transfer completion # Get the knowledge package to check completion status knowledge_package = await ShareManager.get_share(self.context) - if knowledge_package and KnowledgeTransferManager._is_transfer_complete(knowledge_package): + if knowledge_package and TransferManager._is_transfer_complete( + knowledge_package + ): await self.context.send_messages( NewConversationMessage( content="🎉 All learning outcomes have been achieved! The knowledge transfer has been automatically marked as complete.", @@ -192,7 +211,9 @@ async def report_transfer_completion(self) -> str: return "No knowledge package found. Cannot complete transfer without package information." # Check if all outcomes are achieved - achieved_outcomes, total_outcomes = LearningObjectivesManager.get_overall_completion(share) + achieved_outcomes, total_outcomes = ( + LearningObjectivesManager.get_overall_completion(share) + ) if achieved_outcomes < total_outcomes: remaining = total_outcomes - achieved_outcomes return f"Cannot complete knowledge transfer - {remaining} learning outcomes are still pending achievement." @@ -228,7 +249,9 @@ async def report_transfer_completion(self) -> str: share.share_id, "🎉 **Knowledge Transfer Complete**: Team has reported that all learning objectives have been achieved. The knowledge transfer is now complete.", ) - await Notifications.notify_all_state_update(self.context, share.share_id, [InspectorTab.BRIEF]) + await Notifications.notify_all_state_update( + self.context, share.share_id, [InspectorTab.BRIEF] + ) await self.context.send_messages( NewConversationMessage( diff --git a/assistants/knowledge-transfer-assistant/assistant/tools/share_setup.py b/assistants/knowledge-transfer-assistant/assistant/tools/share_setup.py index 270b1d99f..dd4bb261f 100644 --- a/assistants/knowledge-transfer-assistant/assistant/tools/share_setup.py +++ b/assistants/knowledge-transfer-assistant/assistant/tools/share_setup.py @@ -6,11 +6,11 @@ from datetime import datetime - +from assistant.data import ConversationRole from assistant.domain.audience_manager import AudienceManager from assistant.domain.knowledge_brief_manager import KnowledgeBriefManager from assistant.domain.share_manager import ShareManager -from assistant.data import ConversationRole + from .base import ToolsBase @@ -35,7 +35,15 @@ async def update_audience(self, audience_description: str) -> str: audience_description=audience_description, ) - return message if message else ("Audience updated successfully." if success else "Failed to update audience.") + return ( + message + if message + else ( + "Audience updated successfully." + if success + else "Failed to update audience." + ) + ) async def set_knowledge_organized(self, is_organized: bool) -> str: """ diff --git a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/__init__.py b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/__init__.py index 2696b6c08..dca47fda2 100644 --- a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/__init__.py +++ b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/__init__.py @@ -6,8 +6,8 @@ """ from .brief import BriefInspector +from .debug import DebugInspector from .learning import LearningInspector from .sharing import SharingInspector -from .debug import DebugInspector __all__ = ["BriefInspector", "LearningInspector", "SharingInspector", "DebugInspector"] diff --git a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/brief.py b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/brief.py index cf5ae4792..cfcf0fa35 100644 --- a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/brief.py +++ b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/brief.py @@ -9,10 +9,10 @@ ConversationContext, ) -from assistant.domain.share_manager import ShareManager from assistant.data import ConversationRole -from .common import get_stage_label +from assistant.domain.share_manager import ShareManager +from .common import get_stage_label # Default instructional text to show when no brief has been created DEFAULT_BRIEF_INSTRUCTION = "_This knowledge brief is displayed in the side panel of all of your team members' conversations, too. Before you share links to your team, ask your assistant to update the brief with whatever details you'd like here. What will help your teammates get off to a good start as they explore the knowledge you are sharing?_" @@ -35,7 +35,9 @@ def __init__(self, config_provider) -> None: async def is_enabled(self, context: ConversationContext) -> bool: return True - async def get(self, context: ConversationContext) -> AssistantConversationInspectorStateDataModel: + async def get( + self, context: ConversationContext + ) -> AssistantConversationInspectorStateDataModel: """Get brief and status information for display.""" conversation_role = await ShareManager.get_conversation_role(context) @@ -44,16 +46,22 @@ async def get(self, context: ConversationContext) -> AssistantConversationInspec share = await ShareManager.get_share(context) if not share: return AssistantConversationInspectorStateDataModel( - data={"content": "No active knowledge package. Start a conversation to create one."} + data={ + "content": "No active knowledge package. Start a conversation to create one." + } ) brief = share.brief share_info = await ShareManager.get_share(context) if conversation_role == ConversationRole.COORDINATOR: - markdown = await self._format_coordinator_brief(share.share_id, brief, share_info, context) + markdown = await self._format_coordinator_brief( + share.share_id, brief, share_info, context + ) else: - markdown = await self._format_team_brief(share.share_id, brief, share_info, context) + markdown = await self._format_team_brief( + share.share_id, brief, share_info, context + ) return AssistantConversationInspectorStateDataModel(data={"content": markdown}) @@ -95,7 +103,9 @@ async def _format_coordinator_brief( return "\n".join(lines) - async def _format_team_brief(self, share_id: str, brief: Any, share_info: Any, context: ConversationContext) -> str: + async def _format_team_brief( + self, share_id: str, brief: Any, share_info: Any, context: ConversationContext + ) -> str: """Format brief information for team members.""" lines: List[str] = [] @@ -126,7 +136,9 @@ async def _format_team_brief(self, share_id: str, brief: Any, share_info: Any, c else: lines.append("## Knowledge Brief") lines.append("") - lines.append("_The coordinator is still setting up the knowledge brief. Check back soon!_") + lines.append( + "_The coordinator is still setting up the knowledge brief. Check back soon!_" + ) lines.append("") return "\n".join(lines) diff --git a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/common.py b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/common.py index c588a124a..1c0c2ee5a 100644 --- a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/common.py +++ b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/common.py @@ -3,7 +3,7 @@ """ from assistant.data import KnowledgePackage, RequestPriority, RequestStatus -from assistant.domain.knowledge_transfer_manager import KnowledgeTransferManager +from assistant.domain import TransferManager def get_status_emoji(status: RequestStatus) -> str: @@ -51,13 +51,19 @@ def get_stage_label(package: KnowledgePackage, for_coordinator: bool = True) -> return "📋 Organizing Knowledge" elif not package.brief: return "📝 Creating Brief" - elif package.is_intended_to_accomplish_outcomes and not package.learning_objectives: + elif ( + package.is_intended_to_accomplish_outcomes + and not package.learning_objectives + ): return "📚 Adding Objectives" - elif not KnowledgeTransferManager.is_ready_for_transfer(package): + elif not TransferManager.is_ready_for_transfer(package): return "📋 Finalizing Setup" - elif package.is_intended_to_accomplish_outcomes and KnowledgeTransferManager._is_transfer_complete(package): + elif ( + package.is_intended_to_accomplish_outcomes + and TransferManager._is_transfer_complete(package) + ): return "✅ Transfer Complete" - elif KnowledgeTransferManager.is_actively_sharing(package): + elif TransferManager.is_actively_sharing(package): return "📤 Sharing in Progress" else: return "🚀 Ready for Transfer" @@ -65,7 +71,7 @@ def get_stage_label(package: KnowledgePackage, for_coordinator: bool = True) -> # Team perspective if package.archived: return "📦 Archived" - elif not KnowledgeTransferManager.is_ready_for_transfer(package): + elif not TransferManager.is_ready_for_transfer(package): return "⏳ Knowledge Being Organized" elif not package.is_intended_to_accomplish_outcomes: return "🔍 Exploring Knowledge" diff --git a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/debug.py b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/debug.py index 6dcaa11ea..caf1ca4cd 100644 --- a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/debug.py +++ b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/debug.py @@ -9,9 +9,7 @@ ConversationContext, ) -from assistant.domain.knowledge_digest_manager import KnowledgeDigestManager -from assistant.domain.share_manager import ShareManager -from assistant.domain.knowledge_transfer_manager import KnowledgeTransferManager +from assistant.domain import KnowledgeDigestManager, ShareManager, TransferManager class DebugInspector: @@ -31,28 +29,38 @@ def __init__(self, config_provider) -> None: async def is_enabled(self, context: ConversationContext) -> bool: return True - async def get(self, context: ConversationContext) -> AssistantConversationInspectorStateDataModel: + async def get( + self, context: ConversationContext + ) -> AssistantConversationInspectorStateDataModel: """Get debug information for display.""" # Get share information share_id = await ShareManager.get_share_id(context) if not share_id: return AssistantConversationInspectorStateDataModel( - data={"content": "No active knowledge package. Start a conversation to create one."} + data={ + "content": "No active knowledge package. Start a conversation to create one." + } ) markdown = await self._format_debug_info(share_id, context) return AssistantConversationInspectorStateDataModel(data={"content": markdown}) - async def _format_debug_info(self, share_id: str, context: ConversationContext) -> str: + async def _format_debug_info( + self, share_id: str, context: ConversationContext + ) -> str: """Format debug information including knowledge digest.""" lines: List[str] = [] lines.append("## Debug Information") lines.append("") - lines.append("This panel shows internal information maintained by the assistant. This data is automatically") - lines.append("generated and updated by the assistant and is not directly editable by users.") + lines.append( + "This panel shows internal information maintained by the assistant. This data is automatically" + ) + lines.append( + "generated and updated by the assistant and is not directly editable by users." + ) lines.append("") # Get the knowledge digest @@ -61,14 +69,20 @@ async def _format_debug_info(self, share_id: str, context: ConversationContext) if not digest: lines.append("### Knowledge Digest") lines.append("") - lines.append("No knowledge digest has been generated yet. The assistant will create and update this") + lines.append( + "No knowledge digest has been generated yet. The assistant will create and update this" + ) lines.append("automatically as the conversation develops.") lines.append("") lines.append("## Knowledge Digest") lines.append("") - lines.append("The knowledge digest is an internal summary of the conversation that the assistant") - lines.append("maintains to help understand the context and key information being shared. It is") + lines.append( + "The knowledge digest is an internal summary of the conversation that the assistant" + ) + lines.append( + "maintains to help understand the context and key information being shared. It is" + ) lines.append("automatically updated as the conversation progresses.") lines.append("") @@ -80,7 +94,9 @@ async def _format_debug_info(self, share_id: str, context: ConversationContext) lines.append("```") lines.append("") else: - lines.append("_No knowledge digest has been generated yet. The assistant will create and update_") + lines.append( + "_No knowledge digest has been generated yet. The assistant will create and update_" + ) lines.append("_this automatically as the conversation develops._") lines.append("") @@ -97,8 +113,12 @@ async def _format_debug_info(self, share_id: str, context: ConversationContext) lines.append("## Share Metadata") lines.append("") lines.append(f"- **Share ID:** `{share_id}`") - lines.append(f"- **Created:** {share.created_at.strftime('%Y-%m-%d %H:%M:%S')}") - lines.append(f"- **Last Updated:** {share.updated_at.strftime('%Y-%m-%d %H:%M:%S')}") + lines.append( + f"- **Created:** {share.created_at.strftime('%Y-%m-%d %H:%M:%S')}" + ) + lines.append( + f"- **Last Updated:** {share.updated_at.strftime('%Y-%m-%d %H:%M:%S')}" + ) lines.append( f"- **Team Conversations:** {len(share.team_conversations) if share.team_conversations else 0}" ) @@ -106,10 +126,16 @@ async def _format_debug_info(self, share_id: str, context: ConversationContext) f"- **Learning Objectives:** {len(share.learning_objectives) if share.learning_objectives else 0}" ) lines.append(f"- **Knowledge Organized:** {share.knowledge_organized}") - lines.append(f"- **Ready for Transfer:** {KnowledgeTransferManager.is_ready_for_transfer(share)}") - lines.append(f"- **Actively Sharing:** {KnowledgeTransferManager.is_actively_sharing(share)}") + lines.append( + f"- **Ready for Transfer:** {TransferManager.is_ready_for_transfer(share)}" + ) + lines.append( + f"- **Actively Sharing:** {TransferManager.is_actively_sharing(share)}" + ) if share.coordinator_conversation_id: - lines.append(f"- **Conversation ID:** `{share.coordinator_conversation_id}`") + lines.append( + f"- **Conversation ID:** `{share.coordinator_conversation_id}`" + ) lines.append("") except Exception as e: diff --git a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/learning.py b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/learning.py index 88ddef789..8639737d6 100644 --- a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/learning.py +++ b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/learning.py @@ -9,9 +9,8 @@ ConversationContext, ) -from assistant.domain.learning_objectives_manager import LearningObjectivesManager -from assistant.domain.share_manager import ShareManager from assistant.data import ConversationRole +from assistant.domain import LearningObjectivesManager, ShareManager class LearningInspector: @@ -31,7 +30,9 @@ def __init__(self, config_provider) -> None: async def is_enabled(self, context: ConversationContext) -> bool: return True - async def get(self, context: ConversationContext) -> AssistantConversationInspectorStateDataModel: + async def get( + self, context: ConversationContext + ) -> AssistantConversationInspectorStateDataModel: """Get learning objectives and progress information.""" conversation_role = await ShareManager.get_conversation_role(context) @@ -40,7 +41,9 @@ async def get(self, context: ConversationContext) -> AssistantConversationInspec share_id = await ShareManager.get_share_id(context) if not share_id: return AssistantConversationInspectorStateDataModel( - data={"content": "No active knowledge package. Start a conversation to create one."} + data={ + "content": "No active knowledge package. Start a conversation to create one." + } ) share = await ShareManager.get_share(context) @@ -52,7 +55,9 @@ async def get(self, context: ConversationContext) -> AssistantConversationInspec return AssistantConversationInspectorStateDataModel(data={"content": markdown}) - async def _format_coordinator_objectives(self, share: Any, context: ConversationContext) -> str: + async def _format_coordinator_objectives( + self, share: Any, context: ConversationContext + ) -> str: """Format learning objectives for coordinator.""" lines: List[str] = [] @@ -70,12 +75,22 @@ async def _format_coordinator_objectives(self, share: Any, context: Conversation lines.append("") # Overall progress summary - total_outcomes = sum(len(obj.learning_outcomes) for obj in share.learning_objectives if obj.learning_outcomes) + total_outcomes = sum( + len(obj.learning_outcomes) + for obj in share.learning_objectives + if obj.learning_outcomes + ) if total_outcomes > 0 and share.team_conversations: for conv_id, team_conv in share.team_conversations.items(): - achieved, total = LearningObjectivesManager.get_completion_for_conversation(share, conv_id) + achieved, total = ( + LearningObjectivesManager.get_completion_for_conversation( + share, conv_id + ) + ) progress_pct = int((achieved / total * 100)) if total > 0 else 0 - lines.append(f"- **{team_conv.redeemer_name}**: {achieved}/{total} outcomes ({progress_pct}%)") + lines.append( + f"- **{team_conv.redeemer_name}**: {achieved}/{total} outcomes ({progress_pct}%)" + ) lines.append("") # Detailed objectives @@ -90,7 +105,9 @@ async def _format_coordinator_objectives(self, share: Any, context: Conversation for criterion in objective.learning_outcomes: # Check if any team conversation has achieved this outcome achieved_by_any = any( - LearningObjectivesManager.is_outcome_achieved_by_conversation(share, criterion.id, conv_id) + LearningObjectivesManager.is_outcome_achieved_by_conversation( + share, criterion.id, conv_id + ) for conv_id in share.team_conversations.keys() ) status_emoji = "✅" if achieved_by_any else "⬜" @@ -100,19 +117,25 @@ async def _format_coordinator_objectives(self, share: Any, context: Conversation total_team_count = len(share.team_conversations) for conv_id in share.team_conversations.keys(): - if LearningObjectivesManager.is_outcome_achieved_by_conversation(share, criterion.id, conv_id): + if LearningObjectivesManager.is_outcome_achieved_by_conversation( + share, criterion.id, conv_id + ): achieved_count += 1 achievement_info = "" if total_team_count > 0: achievement_info = f" ({achieved_count}/{total_team_count})" - lines.append(f"- {status_emoji} {criterion.description}{achievement_info}") + lines.append( + f"- {status_emoji} {criterion.description}{achievement_info}" + ) lines.append("") return "\n".join(lines) - async def _format_team_objectives(self, share: Any, context: ConversationContext) -> str: + async def _format_team_objectives( + self, share: Any, context: ConversationContext + ) -> str: """Format learning objectives for team members.""" lines: List[str] = [] @@ -131,11 +154,17 @@ async def _format_team_objectives(self, share: Any, context: ConversationContext # Show my personal progress conversation_id = str(context.id) - achieved_outcomes, total_outcomes = LearningObjectivesManager.get_completion_for_conversation( - share, conversation_id + achieved_outcomes, total_outcomes = ( + LearningObjectivesManager.get_completion_for_conversation( + share, conversation_id + ) + ) + progress_pct = ( + int((achieved_outcomes / total_outcomes * 100)) if total_outcomes > 0 else 0 + ) + lines.append( + f"**My Progress:** {achieved_outcomes}/{total_outcomes} outcomes achieved ({progress_pct}%)" ) - progress_pct = int((achieved_outcomes / total_outcomes * 100)) if total_outcomes > 0 else 0 - lines.append(f"**My Progress:** {achieved_outcomes}/{total_outcomes} outcomes achieved ({progress_pct}%)") lines.append("") for objective in share.learning_objectives: @@ -147,23 +176,32 @@ async def _format_team_objectives(self, share: Any, context: ConversationContext lines.append("#### Learning Outcomes") for criterion in objective.learning_outcomes: # Check if I've achieved this outcome - achieved_by_me = LearningObjectivesManager.is_outcome_achieved_by_conversation( - share, criterion.id, conversation_id + achieved_by_me = ( + LearningObjectivesManager.is_outcome_achieved_by_conversation( + share, criterion.id, conversation_id + ) ) status_emoji = "✅" if achieved_by_me else "⬜" completion_info = "" if achieved_by_me: # Find my achievement record - my_achievements = LearningObjectivesManager.get_achievements_for_conversation( - share, conversation_id + my_achievements = ( + LearningObjectivesManager.get_achievements_for_conversation( + share, conversation_id + ) ) for achievement in my_achievements: - if achievement.outcome_id == criterion.id and achievement.achieved: + if ( + achievement.outcome_id == criterion.id + and achievement.achieved + ): completion_info = f" (achieved on {achievement.achieved_at.strftime('%Y-%m-%d')})" break - lines.append(f"- {status_emoji} {criterion.description}{completion_info}") + lines.append( + f"- {status_emoji} {criterion.description}{completion_info}" + ) lines.append("") return "\n".join(lines) diff --git a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/sharing.py b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/sharing.py index 4db0b432d..b32544c8b 100644 --- a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/sharing.py +++ b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/sharing.py @@ -9,11 +9,13 @@ ConversationContext, ) -from assistant.domain.information_request_manager import InformationRequestManager -from assistant.domain.learning_objectives_manager import LearningObjectivesManager -from assistant.domain.share_manager import ShareManager -from assistant.data import RequestStatus -from assistant.data import ConversationRole +from assistant.data import ConversationRole, RequestStatus +from assistant.domain import ( + InformationRequestManager, + LearningObjectivesManager, + ShareManager, +) + from .common import get_priority_emoji, get_status_emoji @@ -34,7 +36,9 @@ def __init__(self, config_provider) -> None: async def is_enabled(self, context: ConversationContext) -> bool: return True - async def get(self, context: ConversationContext) -> AssistantConversationInspectorStateDataModel: + async def get( + self, context: ConversationContext + ) -> AssistantConversationInspectorStateDataModel: """Get information requests for display.""" conversation_role = await ShareManager.get_conversation_role(context) @@ -43,7 +47,9 @@ async def get(self, context: ConversationContext) -> AssistantConversationInspec share_id = await ShareManager.get_share_id(context) if not share_id: return AssistantConversationInspectorStateDataModel( - data={"content": "No active knowledge package. Start a conversation to create one."} + data={ + "content": "No active knowledge package. Start a conversation to create one." + } ) requests = await InformationRequestManager.get_information_requests(context) @@ -55,7 +61,9 @@ async def get(self, context: ConversationContext) -> AssistantConversationInspec return AssistantConversationInspectorStateDataModel(data={"content": markdown}) - async def _format_coordinator_requests(self, requests: List[Any], context: ConversationContext) -> str: + async def _format_coordinator_requests( + self, requests: List[Any], context: ConversationContext + ) -> str: """Format sharing information and requests for coordinator.""" lines: List[str] = [] @@ -69,12 +77,18 @@ async def _format_coordinator_requests(self, requests: List[Any], context: Conve lines.append("**Share this link with your team members:**") lines.append(f"[Knowledge Transfer link]({share_url})") lines.append("") - lines.append("The link never expires and can be used by multiple team members.") + lines.append( + "The link never expires and can be used by multiple team members." + ) lines.append("") # Filter requests by status - pending_requests = [req for req in requests if req.status != RequestStatus.RESOLVED] - resolved_requests = [req for req in requests if req.status == RequestStatus.RESOLVED] + pending_requests = [ + req for req in requests if req.status != RequestStatus.RESOLVED + ] + resolved_requests = [ + req for req in requests if req.status == RequestStatus.RESOLVED + ] lines.append("## Information Requests") lines.append("") @@ -105,7 +119,9 @@ async def _format_coordinator_requests(self, requests: List[Any], context: Conve if not pending_requests and not resolved_requests: lines.append("No information requests yet.") lines.append("") - lines.append("_Team members can ask questions and create information requests that will appear here._") + lines.append( + "_Team members can ask questions and create information requests that will appear here._" + ) # Team summary if share and share.team_conversations: @@ -114,16 +130,28 @@ async def _format_coordinator_requests(self, requests: List[Any], context: Conve lines.append("") for conv_id, team_conv in share.team_conversations.items(): - achieved, total = LearningObjectivesManager.get_completion_for_conversation(share, conv_id) + achieved, total = ( + LearningObjectivesManager.get_completion_for_conversation( + share, conv_id + ) + ) progress_pct = int((achieved / total * 100)) if total > 0 else 0 - lines.append(f"- **{team_conv.redeemer_name}**: {achieved}/{total} outcomes ({progress_pct}%)") - lines.append(f" Joined: {team_conv.joined_at.strftime('%Y-%m-%d %H:%M')}") - lines.append(f" Last active: {team_conv.last_active_at.strftime('%Y-%m-%d %H:%M')}") + lines.append( + f"- **{team_conv.redeemer_name}**: {achieved}/{total} outcomes ({progress_pct}%)" + ) + lines.append( + f" Joined: {team_conv.joined_at.strftime('%Y-%m-%d %H:%M')}" + ) + lines.append( + f" Last active: {team_conv.last_active_at.strftime('%Y-%m-%d %H:%M')}" + ) lines.append("") return "\n".join(lines) - async def _format_team_requests(self, requests: List[Any], context: ConversationContext) -> str: + async def _format_team_requests( + self, requests: List[Any], context: ConversationContext + ) -> str: """Format sharing information and requests for team members.""" lines: List[str] = [] diff --git a/assistants/knowledge-transfer-assistant/assistant/utils.py b/assistants/knowledge-transfer-assistant/assistant/utils.py index 43ff48aa8..fa86bdf23 100644 --- a/assistants/knowledge-transfer-assistant/assistant/utils.py +++ b/assistants/knowledge-transfer-assistant/assistant/utils.py @@ -35,7 +35,9 @@ def load_text_include(filename) -> str: return file_path.read_text() -async def get_current_user(context: ConversationContext) -> Tuple[Optional[str], Optional[str]]: +async def get_current_user( + context: ConversationContext, +) -> Tuple[Optional[str], Optional[str]]: """ Extract the current user ID and name from the conversation context. @@ -72,7 +74,9 @@ async def get_current_user_id(context: ConversationContext) -> Optional[str]: return user_id -async def require_current_user(context: ConversationContext, operation_name: str) -> Optional[str]: +async def require_current_user( + context: ConversationContext, operation_name: str +) -> Optional[str]: """ Extract the current user ID and log an error if none is found. diff --git a/assistants/knowledge-transfer-assistant/pyproject.toml b/assistants/knowledge-transfer-assistant/pyproject.toml index c8a3d47f9..967017c3a 100644 --- a/assistants/knowledge-transfer-assistant/pyproject.toml +++ b/assistants/knowledge-transfer-assistant/pyproject.toml @@ -2,7 +2,7 @@ name = "assistant" version = "0.1.0" description = "A file-sharing mediator assistant for collaborative projects." -authors = [{ name = "Semantic Workbench Team" }] +authors = [{ name = "MADE: Explore" }] readme = "README.md" requires-python = ">=3.11" dependencies = [ diff --git a/assistants/knowledge-transfer-assistant/tests/test_artifact_loading.py b/assistants/knowledge-transfer-assistant/tests/test_artifact_loading.py index 48003490a..27dd271d8 100644 --- a/assistants/knowledge-transfer-assistant/tests/test_artifact_loading.py +++ b/assistants/knowledge-transfer-assistant/tests/test_artifact_loading.py @@ -10,12 +10,18 @@ import uuid from typing import Any, TypeVar -from assistant.domain import ShareManager, KnowledgeBriefManager -from assistant.data import KnowledgeBrief, KnowledgePackage, LearningObjective, LearningOutcome -from assistant.storage import ShareStorage, ShareStorageManager -from assistant.data import ConversationRole from semantic_workbench_assistant import settings +from assistant.data import ( + ConversationRole, + KnowledgeBrief, + KnowledgePackage, + LearningObjective, + LearningOutcome, +) +from assistant.domain import KnowledgeBriefManager, ShareManager +from assistant.storage import ShareStorage, ShareStorageManager + # Type variable for better type annotations T = TypeVar("T") @@ -25,7 +31,9 @@ class TestShareStorage(unittest.IsolatedAsyncioTestCase): async def asyncSetUp(self): # Create a test storage path - self.test_dir = pathlib.Path(__file__).parent.parent / ".data" / "test_project_storage" + self.test_dir = ( + pathlib.Path(__file__).parent.parent / ".data" / "test_project_storage" + ) self.test_dir.mkdir(exist_ok=True, parents=True) # Mock settings to use our test directory @@ -68,7 +76,9 @@ def mock_storage_directory_for_context(context, *args, **kwargs): async def mock_get_share_id(context): return self.share_id - patch2 = unittest.mock.patch.object(ShareManager, "get_share_id", side_effect=mock_get_share_id) + patch2 = unittest.mock.patch.object( + ShareManager, "get_share_id", side_effect=mock_get_share_id + ) self.mock_get_project = patch2.start() self.patches.append(patch2) @@ -77,7 +87,9 @@ async def mock_get_conversation_role(context): return ConversationRole.COORDINATOR patch3 = unittest.mock.patch.object( - ShareManager, "get_conversation_role", side_effect=mock_get_conversation_role + ShareManager, + "get_conversation_role", + side_effect=mock_get_conversation_role, ) self.mock_get_role = patch3.start() self.patches.append(patch3) @@ -126,11 +138,12 @@ def create_test_brief(self): # Write the project to storage using ShareStorage to ensure proper consolidated format ShareStorage.write_share(self.share_id, project) - async def test_get_project_brief(self) -> None: """Test that get_project_brief correctly loads the brief from storage""" # Mock the KnowledgeTransferManager to use our test context - with unittest.mock.patch.object(ShareManager, "get_share_id", return_value=self.share_id): + with unittest.mock.patch.object( + ShareManager, "get_share_id", return_value=self.share_id + ): # Using Any here to satisfy type checker with our mock context: Any = self.context @@ -145,7 +158,9 @@ async def test_get_project_brief(self) -> None: # Verify the project goals were loaded correctly self.assertIsNotNone(project, "Should load the project") if project: # Type checking guard - self.assertEqual(len(project.learning_objectives), 1, "Should have one goal") + self.assertEqual( + len(project.learning_objectives), 1, "Should have one goal" + ) self.assertEqual(project.learning_objectives[0].name, "Test Goal") async def test_direct_storage_access(self) -> None: diff --git a/assistants/knowledge-transfer-assistant/tests/test_inspector.py b/assistants/knowledge-transfer-assistant/tests/test_inspector.py index 6490279f1..0f9434e7b 100644 --- a/assistants/knowledge-transfer-assistant/tests/test_inspector.py +++ b/assistants/knowledge-transfer-assistant/tests/test_inspector.py @@ -6,10 +6,11 @@ import logging from unittest.mock import AsyncMock, MagicMock -from assistant.assistant import assistant from semantic_workbench_api_model.workbench_model import AssistantStateEvent from semantic_workbench_assistant.assistant_app import ConversationContext +from assistant.assistant import assistant + # Set up logging logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) @@ -25,17 +26,28 @@ async def test_inspector(): # Mock conversation conversation = MagicMock() - conversation.metadata = {"setup_complete": True, "assistant_mode": "coordinator", "share_role": "coordinator"} + conversation.metadata = { + "setup_complete": True, + "assistant_mode": "coordinator", + "share_role": "coordinator", + } context.get_conversation.return_value = conversation # Test all four tabbed inspectors - inspector_ids = ["brief", "objectives", "requests", "debug"] # Note: "requests" tab now shows as "Sharing" + inspector_ids = [ + "brief", + "objectives", + "requests", + "debug", + ] # Note: "requests" tab now shows as "Sharing" for inspector_id in inspector_ids: logger.info(f"Testing {inspector_id} inspector...") # Create state event - state_event = AssistantStateEvent(state_id=inspector_id, event="focus", state=None) + state_event = AssistantStateEvent( + state_id=inspector_id, event="focus", state=None + ) # Send event logger.info("Sending state event...") diff --git a/assistants/knowledge-transfer-assistant/tests/test_share_manager.py b/assistants/knowledge-transfer-assistant/tests/test_share_manager.py index 61fa11164..99fcc2469 100644 --- a/assistants/knowledge-transfer-assistant/tests/test_share_manager.py +++ b/assistants/knowledge-transfer-assistant/tests/test_share_manager.py @@ -5,14 +5,14 @@ from unittest.mock import AsyncMock, MagicMock import pytest +from semantic_workbench_assistant.assistant_app import ConversationContext + from assistant.data import ( KnowledgePackage, LearningObjective, LearningOutcome, - ) from assistant.domain import LearningObjectivesManager -from semantic_workbench_assistant.assistant_app import ConversationContext class TestKnowledgeTransferManager: @@ -41,7 +41,12 @@ async def disabled_test_delete_project_goal(self, context, monkeypatch): share_id=project_id, brief=None, learning_objectives=[ - LearningObjective(name="Goal 1", description="Description 1", priority=1, learning_outcomes=[]), + LearningObjective( + name="Goal 1", + description="Description 1", + priority=1, + learning_outcomes=[], + ), LearningObjective( name=goal_name, description=goal_description, @@ -51,7 +56,12 @@ async def disabled_test_delete_project_goal(self, context, monkeypatch): LearningOutcome(description="Criterion 2"), ], ), - LearningObjective(name="Goal 3", description="Description 3", priority=3, learning_outcomes=[]), + LearningObjective( + name="Goal 3", + description="Description 3", + priority=3, + learning_outcomes=[], + ), ], digest=None, requests=[], @@ -69,21 +79,28 @@ async def mock_get_project_id(*args, **kwargs): return project_id monkeypatch.setattr( - "assistant.manager.KnowledgeTransferManager.get_project_id", AsyncMock(side_effect=mock_get_project_id) + "assistant.manager.KnowledgeTransferManager.get_project_id", + AsyncMock(side_effect=mock_get_project_id), ) # Mock require_current_user async def mock_require_current_user(*args, **kwargs): return "test-user-id" - monkeypatch.setattr("assistant.manager.require_current_user", AsyncMock(side_effect=mock_require_current_user)) + monkeypatch.setattr( + "assistant.manager.require_current_user", + AsyncMock(side_effect=mock_require_current_user), + ) # Mock read_project def mock_read_project(proj_id): assert proj_id == project_id return test_project - monkeypatch.setattr("assistant.storage.ShareStorage.read_project", MagicMock(side_effect=mock_read_project)) + monkeypatch.setattr( + "assistant.storage.ShareStorage.read_project", + MagicMock(side_effect=mock_read_project), + ) # Mock read_share_info (now returns the same project) def mock_read_share_info(proj_id): @@ -91,7 +108,8 @@ def mock_read_share_info(proj_id): return test_project monkeypatch.setattr( - "assistant.storage.ShareStorage.read_share_info", MagicMock(side_effect=mock_read_share_info) + "assistant.storage.ShareStorage.read_share_info", + MagicMock(side_effect=mock_read_share_info), ) # Track if write_project and write_project_info were called with correct arguments @@ -109,7 +127,8 @@ def mock_write_project(proj_id, project): write_project_called = True monkeypatch.setattr( - "assistant.project_manager.ShareStorage.write_project", MagicMock(side_effect=mock_write_project) + "assistant.project_manager.ShareStorage.write_project", + MagicMock(side_effect=mock_write_project), ) # Mock write_share_info (now same as write_share) @@ -117,7 +136,9 @@ def mock_write_share_info(proj_id, package): nonlocal write_project_info_called assert proj_id == project_id # Verify package was updated - assert package.achieved_outcomes == 0 # Completed criterion was in the deleted goal + assert ( + package.achieved_outcomes == 0 + ) # Completed criterion was in the deleted goal assert package.total_outcomes == 0 # All criteria were in the deleted goal # Note: completion_percentage removed from model assert package.version == 2 # Incremented @@ -136,7 +157,8 @@ async def mock_log_project_event(*args, **kwargs): log_event_called = True monkeypatch.setattr( - "assistant.project_manager.ShareStorage.log_project_event", AsyncMock(side_effect=mock_log_project_event) + "assistant.project_manager.ShareStorage.log_project_event", + AsyncMock(side_effect=mock_log_project_event), ) # Mock notify_project_update @@ -164,7 +186,12 @@ async def mock_refresh_all_project_uis(*args, **kwargs): ) # Call the method being tested - success, goal_name_result = await LearningObjectivesManager.delete_learning_objective(context, objective_index) + ( + success, + goal_name_result, + ) = await LearningObjectivesManager.delete_learning_objective( + context, objective_index + ) # Verify the result assert success is True @@ -179,7 +206,9 @@ async def mock_refresh_all_project_uis(*args, **kwargs): # DISABLED: delete_project_goal functionality has been removed from the codebase # @pytest.mark.asyncio - async def disabled_test_delete_project_goal_invalid_index(self, context, monkeypatch): + async def disabled_test_delete_project_goal_invalid_index( + self, context, monkeypatch + ): """Test deleting a goal with an invalid index.""" # Setup project_id = "test-project-id" @@ -190,8 +219,18 @@ async def disabled_test_delete_project_goal_invalid_index(self, context, monkeyp share_id=project_id, brief=None, learning_objectives=[ - LearningObjective(name="Goal 1", description="Description 1", priority=1, learning_outcomes=[]), - LearningObjective(name="Goal 2", description="Description 2", priority=2, learning_outcomes=[]), + LearningObjective( + name="Goal 1", + description="Description 1", + priority=1, + learning_outcomes=[], + ), + LearningObjective( + name="Goal 2", + description="Description 2", + priority=2, + learning_outcomes=[], + ), ], digest=None, requests=[], @@ -212,7 +251,8 @@ async def mock_require_current_user(*args, **kwargs): return "test-user-id" monkeypatch.setattr( - "assistant.project_manager.require_current_user", AsyncMock(side_effect=mock_require_current_user) + "assistant.project_manager.require_current_user", + AsyncMock(side_effect=mock_require_current_user), ) # Mock read_project @@ -221,11 +261,17 @@ def mock_read_project(proj_id): return test_project monkeypatch.setattr( - "assistant.project_manager.ShareStorage.read_project", MagicMock(side_effect=mock_read_project) + "assistant.project_manager.ShareStorage.read_project", + MagicMock(side_effect=mock_read_project), ) # Call the method being tested with an invalid index - success, error_message = await LearningObjectivesManager.delete_learning_objective(context, objective_index) + ( + success, + error_message, + ) = await LearningObjectivesManager.delete_learning_objective( + context, objective_index + ) # Verify the result indicates failure with appropriate error message assert success is False @@ -247,7 +293,12 @@ async def mock_get_project_id(*args, **kwargs): ) # Call the method being tested - success, error_message = await LearningObjectivesManager.delete_learning_objective(context, "test-objective-id") + ( + success, + error_message, + ) = await LearningObjectivesManager.delete_learning_objective( + context, "test-objective-id" + ) # Verify the result indicates failure with appropriate error message assert success is False diff --git a/assistants/knowledge-transfer-assistant/tests/test_share_storage.py b/assistants/knowledge-transfer-assistant/tests/test_share_storage.py index cbbd692e4..80fe0aef9 100644 --- a/assistants/knowledge-transfer-assistant/tests/test_share_storage.py +++ b/assistants/knowledge-transfer-assistant/tests/test_share_storage.py @@ -9,8 +9,13 @@ import uuid from datetime import datetime -from assistant.domain.share_manager import ShareManager +from semantic_workbench_api_model.workbench_model import AssistantStateEvent +from semantic_workbench_assistant import settings + from assistant.data import ( + ConversationRole, + CoordinatorConversationMessage, + CoordinatorConversationMessages, InformationRequest, InspectorTab, KnowledgeBrief, @@ -24,15 +29,9 @@ RequestPriority, RequestStatus, ) +from assistant.domain.share_manager import ShareManager from assistant.notifications import Notifications from assistant.storage import ShareStorage, ShareStorageManager -from assistant.data import ( - ConversationRole, - CoordinatorConversationMessage, - CoordinatorConversationMessages, -) -from semantic_workbench_api_model.workbench_model import AssistantStateEvent -from semantic_workbench_assistant import settings class TestShareStorage(unittest.IsolatedAsyncioTestCase): @@ -41,7 +40,9 @@ class TestShareStorage(unittest.IsolatedAsyncioTestCase): async def asyncSetUp(self): """Set up test environment.""" # Create a test directory - self.test_dir = pathlib.Path(__file__).parent.parent / ".data" / "test_project_storage" + self.test_dir = ( + pathlib.Path(__file__).parent.parent / ".data" / "test_project_storage" + ) self.test_dir.mkdir(parents=True, exist_ok=True) # Mock settings to use our test directory @@ -81,14 +82,17 @@ async def asyncSetUp(self): # Mock get_participants with the correct structure participants_mock = unittest.mock.MagicMock() participants_mock.participants = [] - self.context.get_participants = unittest.mock.AsyncMock(return_value=participants_mock) + self.context.get_participants = unittest.mock.AsyncMock( + return_value=participants_mock + ) # Patch storage_directory_for_context def mock_storage_directory_for_context(context, *args, **kwargs): return self.test_dir / f"context_{context.id}" patch1 = unittest.mock.patch( - "assistant.storage.storage_directory_for_context", side_effect=mock_storage_directory_for_context + "assistant.storage.storage_directory_for_context", + side_effect=mock_storage_directory_for_context, ) self.mock_storage_directory = patch1.start() self.patches.append(patch1) @@ -220,16 +224,22 @@ async def test_write_project_log(self): self.assertIsNotNone(log, "Should load the log") if log: # Type checking guard self.assertEqual(len(log.entries), 1) - self.assertEqual(log.entries[0].entry_type, LogEntryType.SHARE_INFORMATION_UPDATE) + self.assertEqual( + log.entries[0].entry_type, LogEntryType.SHARE_INFORMATION_UPDATE + ) self.assertEqual(log.entries[0].message, "Test log entry") async def test_project_directory_structure(self): """Test the project directory structure.""" # Verify project directory exists - self.assertTrue(self.project_dir.exists(), "KnowledgePackage directory should exist") + self.assertTrue( + self.project_dir.exists(), "KnowledgePackage directory should exist" + ) # Verify Coordinator directory exists - self.assertTrue(self.coordinator_dir.exists(), "Coordinator directory should exist") + self.assertTrue( + self.coordinator_dir.exists(), "Coordinator directory should exist" + ) # Verify team directory exists self.assertTrue(self.team_dir.exists(), "Team directory should exist") @@ -296,7 +306,9 @@ async def test_append_coordinator_message(self): storage = ShareStorage.read_coordinator_conversation(self.share_id) # Verify messages were added - self.assertIsNotNone(storage, "Should create and load the coordinator conversation") + self.assertIsNotNone( + storage, "Should create and load the coordinator conversation" + ) if storage: self.assertEqual(len(storage.messages), 2) self.assertEqual(storage.messages[0].content, "First message") @@ -347,14 +359,23 @@ async def test_knowledge_digest(self): # Verify knowledge digest was saved correctly self.assertIsNotNone(read_digest, "Should load the knowledge digest") if read_digest: - self.assertEqual(read_digest.content, "# Test Knowledge Digest\n\nThis is a test knowledge digest.") + self.assertEqual( + read_digest.content, + "# Test Knowledge Digest\n\nThis is a test knowledge digest.", + ) self.assertTrue(read_digest.is_auto_generated) async def test_refresh_current_ui(self): """Test refreshing the current UI inspector.""" # Call refresh_current_ui await Notifications.notify_state_update( - self.context, [InspectorTab.BRIEF, InspectorTab.LEARNING, InspectorTab.SHARING, InspectorTab.DEBUG] + self.context, + [ + InspectorTab.BRIEF, + InspectorTab.LEARNING, + InspectorTab.SHARING, + InspectorTab.DEBUG, + ], ) # Verify that send_conversation_state_event was called 4 times (once per inspector tab) @@ -398,11 +419,15 @@ async def test_knowledge_package_info(self): updated_package = ShareStorage.read_share(self.share_id) # Verify updates were saved - self.assertIsNotNone(updated_package, "Should load updated knowledge package") + self.assertIsNotNone( + updated_package, "Should load updated knowledge package" + ) if updated_package: self.assertEqual(updated_package.transfer_notes, "Test status message") # Note: completion_percentage removed from model - self.assertEqual(updated_package.next_learning_actions, ["Action 1", "Action 2"]) + self.assertEqual( + updated_package.next_learning_actions, ["Action 1", "Action 2"] + ) async def test_conversation_tracking_in_json(self): """Test that conversations are tracked in JSON instead of file system.""" @@ -415,18 +440,26 @@ async def test_conversation_tracking_in_json(self): self.assertIsInstance(package.team_conversations, dict) # Verify helper methods work - linked_conversations = await ShareManager.get_linked_conversations(self.context) + linked_conversations = await ShareManager.get_linked_conversations( + self.context + ) self.assertIsInstance(linked_conversations, list) async def test_conversation_association(self): """Test conversation role setting.""" # Mock ShareManager.set_conversation_role - with unittest.mock.patch("assistant.domain.share_manager.write_model") as mock_write_model: + with unittest.mock.patch( + "assistant.domain.share_manager.write_model" + ) as mock_write_model: # Mock conversation role path - conversation_role_file = ShareStorageManager.get_conversation_role_file_path(self.context) + conversation_role_file = ( + ShareStorageManager.get_conversation_role_file_path(self.context) + ) # Call set_conversation_role - await ShareManager.set_conversation_role(self.context, self.share_id, ConversationRole.COORDINATOR) + await ShareManager.set_conversation_role( + self.context, self.share_id, ConversationRole.COORDINATOR + ) # Verify write_model was called mock_write_model.assert_called_once() @@ -467,7 +500,9 @@ async def test_log_project_event(self): for entry in read_log.entries: if entry.message == "Test direct log entry": found_entry = True - self.assertEqual(entry.entry_type, LogEntryType.SHARE_INFORMATION_UPDATE) + self.assertEqual( + entry.entry_type, LogEntryType.SHARE_INFORMATION_UPDATE + ) self.assertEqual(entry.user_id, self.user_id) self.assertEqual(entry.user_name, "Test User") self.assertEqual(entry.related_entity_id, "test-entity-id") diff --git a/assistants/knowledge-transfer-assistant/tests/test_share_tools.py b/assistants/knowledge-transfer-assistant/tests/test_share_tools.py index f322d1ee9..feea3311c 100644 --- a/assistants/knowledge-transfer-assistant/tests/test_share_tools.py +++ b/assistants/knowledge-transfer-assistant/tests/test_share_tools.py @@ -7,10 +7,11 @@ import openai_client import pytest +from semantic_workbench_assistant.assistant_app import ConversationContext + from assistant.agentic.analysis import detect_information_request_needs from assistant.data import ConversationRole from assistant.tools import ShareTools -from semantic_workbench_assistant.assistant_app import ConversationContext # Use pytest for all tests for consistency @@ -40,11 +41,20 @@ def test_initialization(self, context): # Verify Coordinator-specific functions are registered assert "update_brief" in coordinator_tools.tool_functions.function_map - assert "resolve_information_request" in coordinator_tools.tool_functions.function_map + assert ( + "resolve_information_request" + in coordinator_tools.tool_functions.function_map + ) # Verify Team-specific functions are NOT registered - assert "create_information_request" not in coordinator_tools.tool_functions.function_map - assert "delete_information_request" not in coordinator_tools.tool_functions.function_map + assert ( + "create_information_request" + not in coordinator_tools.tool_functions.function_map + ) + assert ( + "delete_information_request" + not in coordinator_tools.tool_functions.function_map + ) # Test Team role team_tools = ShareTools(context, ConversationRole.TEAM) @@ -57,10 +67,15 @@ def test_initialization(self, context): # Verify Coordinator-specific functions are NOT registered assert "update_brief" not in team_tools.tool_functions.function_map - assert "resolve_information_request" not in team_tools.tool_functions.function_map + assert ( + "resolve_information_request" not in team_tools.tool_functions.function_map + ) # detect_information_request_needs is not exposed as a tool function anymore - assert "detect_information_request_needs" not in team_tools.tool_functions.function_map + assert ( + "detect_information_request_needs" + not in team_tools.tool_functions.function_map + ) @pytest.mark.asyncio async def test_project_tools_with_config(self, context, monkeypatch): @@ -118,11 +133,15 @@ async def check_tools_with_config(context, role): return tools # Get the tools using our function that checks track_progress - project_tools = await check_tools_with_config(context, ConversationRole.COORDINATOR) + project_tools = await check_tools_with_config( + context, ConversationRole.COORDINATOR + ) # Verify basic tools are still available regardless of track_progress setting assert "update_brief" in project_tools.tool_functions.function_map - assert "resolve_information_request" in project_tools.tool_functions.function_map + assert ( + "resolve_information_request" in project_tools.tool_functions.function_map + ) # For team tools team_tools = await check_tools_with_config(context, ConversationRole.TEAM) @@ -143,7 +162,9 @@ async def test_detect_information_request_needs(self, context, monkeypatch): # Setup mock config to be returned from assistant_config.get mock_config = MagicMock() mock_config.track_progress = True - mock_config.service_config = None # Will cause the method to return early with error info + mock_config.service_config = ( + None # Will cause the method to return early with error info + ) async def mock_get_config(*args, **kwargs): return mock_config @@ -151,7 +172,9 @@ async def mock_get_config(*args, **kwargs): # Patch assistant_config.get mock_assistant_config = MagicMock() mock_assistant_config.get = AsyncMock(side_effect=mock_get_config) - monkeypatch.setattr("assistant.agentic.analysis.assistant_config", mock_assistant_config) + monkeypatch.setattr( + "assistant.agentic.analysis.assistant_config", mock_assistant_config + ) # Create a mock message for the message history mock_msg = MagicMock() @@ -218,7 +241,8 @@ async def mock_get_project_id(*args, **kwargs): return project_id monkeypatch.setattr( - "assistant.manager.KnowledgeTransferManager.get_project_id", AsyncMock(side_effect=mock_get_project_id) + "assistant.manager.KnowledgeTransferManager.get_project_id", + AsyncMock(side_effect=mock_get_project_id), ) # Mock require_current_user to return a user ID @@ -226,7 +250,8 @@ async def mock_require_current_user(*args, **kwargs): return "test-user-id" monkeypatch.setattr( - "assistant.project_manager.require_current_user", AsyncMock(side_effect=mock_require_current_user) + "assistant.project_manager.require_current_user", + AsyncMock(side_effect=mock_require_current_user), ) # Mock KnowledgeTransferManager.delete_project_goal to return success @@ -245,7 +270,9 @@ async def mock_delete_project_goal(*args, **kwargs): # assert f"Goal '{goal_name}' has been successfully deleted from the project." in result # Verify that context.send_messages was called with appropriate message - expected_message_content = f"Goal '{goal_name}' has been successfully deleted from the project." + expected_message_content = ( + f"Goal '{goal_name}' has been successfully deleted from the project." + ) context.send_messages.assert_called_once() # Get the first positional argument passed to send_messages call_args = context.send_messages.call_args[0][0] diff --git a/assistants/knowledge-transfer-assistant/tests/test_team_mode.py b/assistants/knowledge-transfer-assistant/tests/test_team_mode.py index c1d2f0b89..3ee7704b9 100644 --- a/assistants/knowledge-transfer-assistant/tests/test_team_mode.py +++ b/assistants/knowledge-transfer-assistant/tests/test_team_mode.py @@ -89,7 +89,9 @@ def __init__( class InformationRequest(BaseArtifact): - def __init__(self, title=None, description=None, priority=None, status=None, **kwargs): + def __init__( + self, title=None, description=None, priority=None, status=None, **kwargs + ): super().__init__(artifact_type=ArtifactType.INFORMATION_REQUEST, **kwargs) self.title = title or "Test Request" self.description = description or "Test Description" @@ -101,7 +103,14 @@ def __init__(self, title=None, description=None, priority=None, status=None, **k class ProjectDashboard(BaseArtifact): - def __init__(self, state=None, active_requests=None, completed_criteria=0, total_criteria=0, **kwargs): + def __init__( + self, + state=None, + active_requests=None, + completed_criteria=0, + total_criteria=0, + **kwargs, + ): super().__init__(artifact_type=ArtifactType.PROJECT_DASHBOARD, **kwargs) # transfer_state field removed - using derived state logic instead self.state = state or ProjectState.PLANNING @@ -122,7 +131,9 @@ def __init__(self, id=None, description=None, completed=False, completed_by=None class LearningObjective: - def __init__(self, id=None, name=None, description=None, priority=1, success_criteria=None): + def __init__( + self, id=None, name=None, description=None, priority=1, success_criteria=None + ): self.id = id or "test-goal-id" self.name = name or "Test Goal" self.description = description or "Test Goal Description" @@ -144,7 +155,9 @@ def __init__(self, context): self.context = context self.log_action = AsyncMock() - async def create_information_request(self, title, description, priority=RequestPriority.MEDIUM): + async def create_information_request( + self, title, description, priority=RequestPriority.MEDIUM + ): # Mock implementation request = InformationRequest( title=title, @@ -187,9 +200,14 @@ async def update_project_info(self, progress_percentage, status_message=None): dashboard.transfer_notes = status_message # Call mocked log_action for state change and progress update - await self.log_action(LogEntryType.MILESTONE_PASSED, "KnowledgePackage is now in progress") + await self.log_action( + LogEntryType.MILESTONE_PASSED, "KnowledgePackage is now in progress" + ) - await self.log_action(LogEntryType.STATUS_CHANGED, f"Updated project progress to {progress_percentage}%") + await self.log_action( + LogEntryType.STATUS_CHANGED, + f"Updated project progress to {progress_percentage}%", + ) # Send notification await self.context.send_messages( @@ -220,7 +238,10 @@ async def mark_criterion_completed(self, goal_id, criterion_id): ) # Call mocked log_action - await self.log_action(LogEntryType.CRITERION_COMPLETED, f"Completed criterion: {criterion.description}") + await self.log_action( + LogEntryType.CRITERION_COMPLETED, + f"Completed criterion: {criterion.description}", + ) # Send notification await self.context.send_messages( @@ -230,7 +251,11 @@ async def mark_criterion_completed(self, goal_id, criterion_id): ) ) - return True, f"Marked criterion '{criterion.description}' as completed.", dashboard + return ( + True, + f"Marked criterion '{criterion.description}' as completed.", + dashboard, + ) async def report_project_completion(self, completion_summary): # Mock implementation @@ -245,7 +270,9 @@ async def report_project_completion(self, completion_summary): dashboard.transfer_notes = completion_summary # Call mocked log_action - await self.log_action(LogEntryType.PROJECT_COMPLETED, "KnowledgePackage marked as completed") + await self.log_action( + LogEntryType.PROJECT_COMPLETED, "KnowledgePackage marked as completed" + ) # Send notification await self.context.send_messages( @@ -328,7 +355,9 @@ async def test_create_information_request(self, team_handler, mock_context): async def test_update_project_info(self, team_handler, mock_context): """Test updating the project information.""" # Call the method - success, message, dashboard = await team_handler.update_project_info(50, "Making progress in the team") + success, message, dashboard = await team_handler.update_project_info( + 50, "Making progress in the team" + ) # Assertions assert success is True @@ -348,7 +377,11 @@ async def test_update_project_info(self, team_handler, mock_context): async def test_mark_criterion_completed(self, team_handler, mock_context): """Test marking a success criterion as completed.""" # Call the method - success, message, updated_dashboard = await team_handler.mark_criterion_completed( + ( + success, + message, + updated_dashboard, + ) = await team_handler.mark_criterion_completed( "test-goal-id", "test-criterion-id" ) @@ -381,7 +414,8 @@ async def test_report_project_completion(self, team_handler, mock_context): assert dashboard.state == ProjectState.COMPLETED # Note: completion_percentage removed from model assert ( - dashboard.transfer_notes == "KnowledgePackage has been successfully completed with all objectives achieved." + dashboard.transfer_notes + == "KnowledgePackage has been successfully completed with all objectives achieved." ) # Verify that a notification was sent From dc49626fc18c3fd9d29c3e82eb01ea17911185d4 Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Tue, 29 Jul 2025 10:46:39 -0700 Subject: [PATCH 08/21] Format. --- .../assistant/agentic/analysis.py | 30 ++-- .../assistant/agentic/coordinator_support.py | 20 +-- .../assistant/agentic/team_welcome.py | 16 +- .../assistant/assistant.py | 101 ++++--------- .../assistant/config.py | 4 +- .../assistant/conversation_clients.py | 12 +- .../assistant/data.py | 8 +- .../assistant/domain/audience_manager.py | 4 +- .../domain/information_request_manager.py | 70 +++------ .../domain/knowledge_brief_manager.py | 12 +- .../domain/knowledge_digest_manager.py | 37 ++--- .../domain/learning_objectives_manager.py | 104 ++++--------- .../assistant/domain/share_manager.py | 66 +++----- .../assistant/domain/transfer_manager.py | 13 +- .../assistant/files.py | 86 +++-------- .../assistant/logging.py | 18 +-- .../assistant/notifications.py | 34 +---- .../assistant/respond.py | 143 ++++++------------ .../assistant/storage.py | 12 +- .../assistant/string_utils.py | 8 +- .../assistant/tools/information_requests.py | 30 +--- .../assistant/tools/learning_objectives.py | 16 +- .../assistant/tools/learning_outcomes.py | 26 +--- .../assistant/tools/progress_tracking.py | 28 +--- .../assistant/tools/share_setup.py | 10 +- .../assistant/ui_tabs/brief.py | 24 +-- .../assistant/ui_tabs/common.py | 10 +- .../assistant/ui_tabs/debug.py | 56 ++----- .../assistant/ui_tabs/learning.py | 79 +++------- .../assistant/ui_tabs/sharing.py | 50 ++---- .../assistant/utils.py | 4 +- .../tests/test_artifact_loading.py | 19 +-- .../tests/test_inspector.py | 7 +- .../tests/test_share_manager.py | 23 +-- .../tests/test_share_storage.py | 57 ++----- .../tests/test_share_tools.py | 47 ++---- .../tests/test_team_mode.py | 31 +--- 37 files changed, 336 insertions(+), 979 deletions(-) diff --git a/assistants/knowledge-transfer-assistant/assistant/agentic/analysis.py b/assistants/knowledge-transfer-assistant/assistant/agentic/analysis.py index f2e942d95..14125accb 100644 --- a/assistants/knowledge-transfer-assistant/assistant/agentic/analysis.py +++ b/assistants/knowledge-transfer-assistant/assistant/agentic/analysis.py @@ -16,9 +16,7 @@ from assistant.logging import logger -async def detect_information_request_needs( - context: ConversationContext, message: str -) -> Dict[str, Any]: +async def detect_information_request_needs(context: ConversationContext, message: str) -> Dict[str, Any]: """ Analyze a user message in context of recent chat history to detect potential information request needs. Uses an LLM for sophisticated detection. @@ -75,9 +73,7 @@ async def detect_information_request_needs( # Add to chat history role = "user" if sender_name == "Team Member" else "assistant" - chat_history.append( - {"role": role, "content": f"{sender_name}: {msg.content}"} - ) + chat_history.append({"role": role, "content": f"{sender_name}: {msg.content}"}) # Reverse to get chronological order chat_history.reverse() @@ -99,17 +95,13 @@ async def detect_information_request_needs( # Add chat history if available if chat_history: for history_msg in chat_history: - messages.append( - {"role": history_msg["role"], "content": history_msg["content"]} - ) + messages.append({"role": history_msg["role"], "content": history_msg["content"]}) # Add the current message for analysis - explicitly mark as the latest message - messages.append( - { - "role": "user", - "content": f"Latest message from Team Member: {message}", - } - ) + messages.append({ + "role": "user", + "content": f"Latest message from Team Member: {message}", + }) completion_args = { "model": "gpt-3.5-turbo", @@ -118,9 +110,7 @@ async def detect_information_request_needs( "max_tokens": 500, "temperature": 0.2, # Low temperature for more consistent analysis } - debug["completion_args"] = openai_client.make_completion_args_serializable( - completion_args - ) + debug["completion_args"] = openai_client.make_completion_args_serializable(completion_args) # Make the API call response = await client.chat.completions.create( @@ -136,9 +126,7 @@ async def detect_information_request_needs( result["original_message"] = message return result except json.JSONDecodeError: - logger.warning( - f"Failed to parse JSON from LLM response: {response.choices[0].message.content}" - ) + logger.warning(f"Failed to parse JSON from LLM response: {response.choices[0].message.content}") return { "is_information_request": False, "reason": "Failed to parse LLM response", diff --git a/assistants/knowledge-transfer-assistant/assistant/agentic/coordinator_support.py b/assistants/knowledge-transfer-assistant/assistant/agentic/coordinator_support.py index 35ceefa0f..d59a43063 100644 --- a/assistants/knowledge-transfer-assistant/assistant/agentic/coordinator_support.py +++ b/assistants/knowledge-transfer-assistant/assistant/agentic/coordinator_support.py @@ -61,10 +61,7 @@ async def get_coordinator_next_action_suggestion( return "Your package needs a short introduction that will orient your team. Let's write a knowledge brief next. The knowledge brief helps your team understand the purpose of this knowledge transfer and will be visible to all team members in their side panel." # 5. If intended to have outcomes but none defined yet - if ( - package.is_intended_to_accomplish_outcomes - and not package.learning_objectives - ): + if package.is_intended_to_accomplish_outcomes and not package.learning_objectives: return ( "Would you like your team to achieve any specific outcomes? If so, let's define some learning objectives. " "If not, you can mark this package as 'exploratory' instead." @@ -72,27 +69,18 @@ async def get_coordinator_next_action_suggestion( # 6. Objectives exist, but missing outcomes if package.is_intended_to_accomplish_outcomes: - incomplete_objectives = [ - obj - for obj in package.learning_objectives - if not obj.learning_outcomes - ] + incomplete_objectives = [obj for obj in package.learning_objectives if not obj.learning_outcomes] if incomplete_objectives: name = incomplete_objectives[0].name return f"The learning objective '{name}' doesn't have any outcomes yet. Let's define what your team should accomplish to meet it." # 7. Ready for transfer but not yet shared - if TransferManager.is_ready_for_transfer( - package - ) and not TransferManager.is_actively_sharing(package): + if TransferManager.is_ready_for_transfer(package) and not TransferManager.is_actively_sharing(package): return "Your knowledge package is ready to share. Would you like to create a message and generate the invitation link?" # 8. Actively sharing - monitor and support ongoing transfer if TransferManager.is_actively_sharing(package): - if ( - package.is_intended_to_accomplish_outcomes - and not TransferManager._is_transfer_complete(package) - ): + if package.is_intended_to_accomplish_outcomes and not TransferManager._is_transfer_complete(package): team_count = len(package.team_conversations) return f"Great! Your knowledge is being shared with {team_count} team member{'s' if team_count != 1 else ''}. You can continue improving the package or respond to information requests as they come in." else: diff --git a/assistants/knowledge-transfer-assistant/assistant/agentic/team_welcome.py b/assistants/knowledge-transfer-assistant/assistant/agentic/team_welcome.py index 349102806..d81e77c9b 100644 --- a/assistants/knowledge-transfer-assistant/assistant/agentic/team_welcome.py +++ b/assistants/knowledge-transfer-assistant/assistant/agentic/team_welcome.py @@ -29,9 +29,7 @@ async def generate_team_welcome_message( share = await ShareManager.get_share(context) if not share: - logger.warning( - "No active knowledge package found for welcome message generation" - ) + logger.warning("No active knowledge package found for welcome message generation") return config.team_config.default_welcome_message, debug share_id = share.share_id @@ -94,13 +92,9 @@ async def generate_team_welcome_message( try: # Chat completion async with openai_client.create_client(config.service_config) as client: - share_info = "\n\n## KNOWLEDGE SHARE INFORMATION\n\n" + "\n".join( - share_data.values() - ) + share_info = "\n\n## KNOWLEDGE SHARE INFORMATION\n\n" + "\n".join(share_data.values()) - instructions = ( - f"{config.prompt_config.welcome_message_generation}\n\n{share_info}" - ) + instructions = f"{config.prompt_config.welcome_message_generation}\n\n{share_info}" messages: List[ChatCompletionMessageParam] = [ {"role": "system", "content": instructions}, ] @@ -111,9 +105,7 @@ async def generate_team_welcome_message( "max_tokens": 500, "temperature": 0.7, # Low temperature for more consistent analysis } - debug["completion_args"] = openai_client.make_completion_args_serializable( - completion_args - ) + debug["completion_args"] = openai_client.make_completion_args_serializable(completion_args) # LLM call response = await client.chat.completions.create( diff --git a/assistants/knowledge-transfer-assistant/assistant/assistant.py b/assistants/knowledge-transfer-assistant/assistant/assistant.py index 53743a0c5..3449fc3c6 100644 --- a/assistants/knowledge-transfer-assistant/assistant/assistant.py +++ b/assistants/knowledge-transfer-assistant/assistant/assistant.py @@ -44,9 +44,7 @@ service_id = "knowledge-transfer-assistant.made-exploration" service_name = "Knowledge Transfer Assistant" -service_description = ( - "A mediator assistant that facilitates sharing knowledge between parties." -) +service_description = "A mediator assistant that facilitates sharing knowledge between parties." async def content_evaluator_factory( @@ -78,9 +76,7 @@ async def content_evaluator_factory( template_id=DEFAULT_TEMPLATE_ID, background_color="rgb(198, 177, 222)", icon=dashboard_card.image_to_url( - pathlib.Path(__file__).parent - / "assets" - / "icon-knowledge-transfer.svg", + pathlib.Path(__file__).parent / "assets" / "icon-knowledge-transfer.svg", "image/svg+xml", ), card_content=dashboard_card.CardContent( @@ -89,11 +85,9 @@ async def content_evaluator_factory( ), ), ), - **navigator.metadata_for_assistant_navigator( - { - "default": load_text_include("assistant_info.md"), - } - ), + **navigator.metadata_for_assistant_navigator({ + "default": load_text_include("assistant_info.md"), + }), }, ) @@ -124,9 +118,7 @@ async def on_conversation_created(context: ConversationContext) -> None: if not share_id: logger.error("No share ID found for shareable team conversation.") return - await ShareManager.set_conversation_role( - context, share_id, ConversationRole.SHAREABLE_TEMPLATE - ) + await ShareManager.set_conversation_role(context, share_id, ConversationRole.SHAREABLE_TEMPLATE) return case ConversationRole.TEAM: @@ -142,12 +134,8 @@ async def on_conversation_created(context: ConversationContext) -> None: ) ) - await ShareManager.set_conversation_role( - context, share_id, ConversationRole.TEAM - ) - await ShareFilesManager.synchronize_files_to_team_conversation( - context=context, share_id=share_id - ) + await ShareManager.set_conversation_role(context, share_id, ConversationRole.TEAM) + await ShareFilesManager.synchronize_files_to_team_conversation(context=context, share_id=share_id) welcome_message, debug = await generate_team_welcome_message(context) await context.send_messages( @@ -178,9 +166,7 @@ async def on_conversation_created(context: ConversationContext) -> None: share_id = await ShareManager.create_share(context) # And it was good. So we then created a sharable conversation that we use as a template. - share_url = await ShareManager.create_shareable_team_conversation( - context=context, share_id=share_id - ) + share_url = await ShareManager.create_shareable_team_conversation(context=context, share_id=share_id) welcome_message = config.coordinator_config.welcome_message.format( share_url=share_url or "" @@ -231,13 +217,9 @@ async def on_conversation_updated(context: ConversationContext) -> None: 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}'" - ) + 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}" - ) + 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}") @@ -265,10 +247,7 @@ async def on_message_created( # If this is a Coordinator conversation, store the message for Team access async with context.set_status("jotting..."): role = await ShareManager.get_conversation_role(context) - if ( - role == ConversationRole.COORDINATOR - and message.message_type == MessageType.chat - ): + if role == ConversationRole.COORDINATOR and message.message_type == MessageType.chat: try: # Get the sender's name sender_name = "Coordinator" @@ -285,8 +264,7 @@ async def on_message_created( message_id=str(message.id), content=message.content, sender_name=sender_name, - is_assistant=message.sender.participant_role - == ParticipantRole.assistant, + is_assistant=message.sender.participant_role == ParticipantRole.assistant, timestamp=message.timestamp, ) @@ -302,9 +280,7 @@ async def on_message_created( ) except Exception as e: # Don't fail message handling if storage fails - logger.exception( - f"Error storing Coordinator message for Team access: {e}" - ) + logger.exception(f"Error storing Coordinator message for Team access: {e}") async with context.set_status("pondering..."): await respond_to_conversation( @@ -315,13 +291,8 @@ async def on_message_created( ) # If the message is from a Coordinator, update the whiteboard in the background - if ( - role == ConversationRole.COORDINATOR - and message.message_type == MessageType.chat - ): - asyncio.create_task( - KnowledgeDigestManager.auto_update_knowledge_digest(context) - ) + if role == ConversationRole.COORDINATOR and message.message_type == MessageType.chat: + asyncio.create_task(KnowledgeDigestManager.auto_update_knowledge_digest(context)) except Exception as e: logger.exception(f"Error handling message: {e}") @@ -343,13 +314,9 @@ async def on_command_created( if message.message_type != MessageType.command: return - await context.update_participant_me( - UpdateParticipant(status="processing command...") - ) + await context.update_participant_me(UpdateParticipant(status="processing command...")) try: - metadata = { - "debug": {"content_safety": event.data.get(content_safety.metadata_key, {})} - } + metadata = {"debug": {"content_safety": event.data.get(content_safety.metadata_key, {})}} # Respond to the conversation await respond_to_conversation( @@ -382,9 +349,7 @@ async def on_file_created( try: share = await ShareManager.get_share(context) if not share or not file.filename: - logger.warning( - f"No share found or missing filename. filename={file.filename}" - ) + logger.warning(f"No share found or missing filename. filename={file.filename}") return role = await ShareManager.get_conversation_role(context) @@ -407,9 +372,7 @@ async def on_file_created( # 2. Synchronize to all Team conversations # Get all Team conversations - team_conversations = await ShareFilesManager.get_team_conversations( - context, share.share_id - ) + team_conversations = await ShareFilesManager.get_team_conversations(context, share.share_id) if team_conversations: for team_conv_id in team_conversations: @@ -421,9 +384,7 @@ async def on_file_created( ) # 3. Update all UIs but don't send notifications to reduce noise - await Notifications.notify_all_state_update( - context, share.share_id, [InspectorTab.DEBUG] - ) + await Notifications.notify_all_state_update(context, share.share_id, [InspectorTab.DEBUG]) # Team files don't need special handling as they're already in the conversation # Log file creation to knowledge transfer log for all files @@ -469,9 +430,7 @@ async def on_file_updated( logger.error(f"Failed to update file in share storage: {file.filename}") return - team_conversations = await ShareFilesManager.get_team_conversations( - context, share.share_id - ) + team_conversations = await ShareFilesManager.get_team_conversations(context, share.share_id) for team_conv_id in team_conversations: await ShareFilesManager.copy_file_to_conversation( context=context, @@ -481,9 +440,7 @@ async def on_file_updated( ) # 3. Update all UIs but don't send notifications to reduce noise - await Notifications.notify_all_state_update( - context, share.share_id, [InspectorTab.DEBUG] - ) + await Notifications.notify_all_state_update(context, share.share_id, [InspectorTab.DEBUG]) await ShareManager.log_share_event( context=context, @@ -521,14 +478,10 @@ async def on_file_deleted( ) if not success: - logger.error( - f"Failed to delete file from share storage: {file.filename}" - ) + logger.error(f"Failed to delete file from share storage: {file.filename}") # 2. Update all UIs about the deletion but don't send notifications to reduce noise - await Notifications.notify_all_state_update( - context, share.share_id, [InspectorTab.DEBUG] - ) + await Notifications.notify_all_state_update(context, share.share_id, [InspectorTab.DEBUG]) # Team files don't need special handling await ShareManager.log_share_event( @@ -573,9 +526,7 @@ async def on_participant_joined( if not share_id: return - await ShareFilesManager.synchronize_files_to_team_conversation( - context=context, share_id=share_id - ) + await ShareFilesManager.synchronize_files_to_team_conversation(context=context, share_id=share_id) await ShareManager.log_share_event( context=context, diff --git a/assistants/knowledge-transfer-assistant/assistant/config.py b/assistants/knowledge-transfer-assistant/assistant/config.py index 9cb254742..f84d51d1d 100644 --- a/assistants/knowledge-transfer-assistant/assistant/config.py +++ b/assistants/knowledge-transfer-assistant/assistant/config.py @@ -172,9 +172,7 @@ class CoordinatorConfig(BaseModel): int, Field( title="Maximum Digest Tokens", - description=( - "The number of tokens to use for the knowledge digest. Default: 4096" - ), + description=("The number of tokens to use for the knowledge digest. Default: 4096"), ), ] = 4_096 diff --git a/assistants/knowledge-transfer-assistant/assistant/conversation_clients.py b/assistants/knowledge-transfer-assistant/assistant/conversation_clients.py index 40d9e47bb..e390d1e7e 100644 --- a/assistants/knowledge-transfer-assistant/assistant/conversation_clients.py +++ b/assistants/knowledge-transfer-assistant/assistant/conversation_clients.py @@ -26,9 +26,7 @@ class ConversationClientManager: """ @staticmethod - def get_conversation_client( - context: ConversationContext, conversation_id: str - ) -> ConversationAPIClient: + def get_conversation_client(context: ConversationContext, conversation_id: str) -> ConversationAPIClient: """ Gets a client for accessing another conversation. """ @@ -42,9 +40,7 @@ async def get_coordinator_client_for_share( Gets a client for accessing the Coordinator conversation for a knowledge transfer. """ # Look for the Coordinator conversation directory - coordinator_dir = ( - ShareStorageManager.get_share_dir(share_id) / ConversationRole.COORDINATOR - ) + coordinator_dir = ShareStorageManager.get_share_dir(share_id) / ConversationRole.COORDINATOR if not coordinator_dir.exists(): return None, None @@ -68,9 +64,7 @@ async def get_coordinator_client_for_share( return None, coordinator_conversation_id # Create a client for the Coordinator conversation - client = ConversationClientManager.get_conversation_client( - context, coordinator_conversation_id - ) + client = ConversationClientManager.get_conversation_client(context, coordinator_conversation_id) return client, coordinator_conversation_id @staticmethod diff --git a/assistants/knowledge-transfer-assistant/assistant/data.py b/assistants/knowledge-transfer-assistant/assistant/data.py index 3452cd9d0..e1cd1e847 100644 --- a/assistants/knowledge-transfer-assistant/assistant/data.py +++ b/assistants/knowledge-transfer-assistant/assistant/data.py @@ -161,9 +161,7 @@ class LogEntry(BaseModel): class KnowledgePackageLog(BaseModel): - entries: List[LogEntry] = Field( - default_factory=list - ) # Chronological list of log entries + entries: List[LogEntry] = Field(default_factory=list) # Chronological list of log entries class KnowledgePackage(BaseModel): @@ -195,9 +193,7 @@ class KnowledgePackage(BaseModel): archived: bool = False requests: List[InformationRequest] = Field(default_factory=list) - log: Optional[KnowledgePackageLog] = Field( - default_factory=lambda: KnowledgePackageLog() - ) + log: Optional[KnowledgePackageLog] = Field(default_factory=lambda: KnowledgePackageLog()) class CoordinatorConversationMessage(BaseModel): diff --git a/assistants/knowledge-transfer-assistant/assistant/domain/audience_manager.py b/assistants/knowledge-transfer-assistant/assistant/domain/audience_manager.py index bc701e906..4f631c586 100644 --- a/assistants/knowledge-transfer-assistant/assistant/domain/audience_manager.py +++ b/assistants/knowledge-transfer-assistant/assistant/domain/audience_manager.py @@ -63,9 +63,7 @@ async def update_audience( ) await Notifications.notify(context, "Audience updated.") - await Notifications.notify_all_state_update( - context, share.share_id, [InspectorTab.DEBUG] - ) + await Notifications.notify_all_state_update(context, share.share_id, [InspectorTab.DEBUG]) return True, f"Target audience updated successfully: {audience_description}" diff --git a/assistants/knowledge-transfer-assistant/assistant/domain/information_request_manager.py b/assistants/knowledge-transfer-assistant/assistant/domain/information_request_manager.py index b3b304c9f..0768cb83e 100644 --- a/assistants/knowledge-transfer-assistant/assistant/domain/information_request_manager.py +++ b/assistants/knowledge-transfer-assistant/assistant/domain/information_request_manager.py @@ -50,14 +50,10 @@ async def create_information_request( try: share_id = await ShareManager.get_share_id(context) if not share_id: - logger.error( - "Cannot create information request: no share associated with this conversation" - ) + logger.error("Cannot create information request: no share associated with this conversation") return False, None - current_user_id = await require_current_user( - context, "create information request" - ) + current_user_id = await require_current_user(context, "create information request") if not current_user_id: return False, None @@ -84,12 +80,8 @@ async def create_information_request( }, ) - await Notifications.notify_self_and_other( - context, share_id, f"Information request '{title}' was created" - ) - await Notifications.notify_all_state_update( - context, share_id, [InspectorTab.SHARING] - ) + await Notifications.notify_self_and_other(context, share_id, f"Information request '{title}' was created") + await Notifications.notify_all_state_update(context, share_id, [InspectorTab.SHARING]) return True, information_request @@ -106,21 +98,15 @@ async def resolve_information_request( try: share_id = await ShareManager.get_share_id(context) if not share_id: - logger.error( - "Cannot resolve information request: no share associated with this conversation" - ) + logger.error("Cannot resolve information request: no share associated with this conversation") return False, None - current_user_id = await require_current_user( - context, "resolve information request" - ) + current_user_id = await require_current_user(context, "resolve information request") if not current_user_id: return False, None # Get the information request - information_request = ShareStorage.read_information_request( - share_id, request_id - ) + information_request = ShareStorage.read_information_request(share_id, request_id) if not information_request: # Try to find it in all requests all_requests = ShareStorage.get_all_information_requests(share_id) @@ -145,14 +131,12 @@ async def resolve_information_request( information_request.resolved_by = current_user_id # Add to history - information_request.updates.append( - { - "timestamp": datetime.utcnow().isoformat(), - "user_id": current_user_id, - "message": f"Request resolved: {resolution}", - "status": RequestStatus.RESOLVED.value, - } - ) + information_request.updates.append({ + "timestamp": datetime.utcnow().isoformat(), + "user_id": current_user_id, + "message": f"Request resolved: {resolution}", + "status": RequestStatus.RESOLVED.value, + }) # Update metadata information_request.updated_at = datetime.utcnow() @@ -177,16 +161,12 @@ async def resolve_information_request( }, ) - await Notifications.notify_all_state_update( - context, share_id, [InspectorTab.SHARING] - ) + await Notifications.notify_all_state_update(context, share_id, [InspectorTab.SHARING]) await Notifications.notify_self_and_other( context, share_id, f"Information request '{information_request.title}' has been resolved: {resolution}", - information_request.conversation_id - if information_request.conversation_id != str(context.id) - else None, + information_request.conversation_id if information_request.conversation_id != str(context.id) else None, ) return True, information_request @@ -213,22 +193,16 @@ async def delete_information_request( try: share_id = await ShareManager.get_share_id(context) if not share_id: - logger.error( - "Cannot delete information request: no share associated with this conversation" - ) + logger.error("Cannot delete information request: no share associated with this conversation") return False, "No knowledge package associated with this conversation." - current_user_id = await require_current_user( - context, "delete information request" - ) + current_user_id = await require_current_user(context, "delete information request") if not current_user_id: return False, "Could not identify current user." # Get information request by ID cleaned_request_id = request_id.strip().replace('"', "").replace("'", "") - information_request = ShareStorage.read_information_request( - share_id, cleaned_request_id - ) + information_request = ShareStorage.read_information_request(share_id, cleaned_request_id) if not information_request: return False, f"Information request with ID '{request_id}' not found." @@ -266,9 +240,7 @@ async def delete_information_request( # Delete the information request from the main share data share = await ShareManager.get_share(context) if share and share.requests: - share.requests = [ - req for req in share.requests if req.request_id != actual_request_id - ] + share.requests = [req for req in share.requests if req.request_id != actual_request_id] await ShareManager.set_share(context, share) # Notify about the deletion @@ -277,9 +249,7 @@ async def delete_information_request( share_id, f"Information request '{request_title}' has been deleted.", ) - await Notifications.notify_all_state_update( - context, share_id, [InspectorTab.SHARING] - ) + await Notifications.notify_all_state_update(context, share_id, [InspectorTab.SHARING]) return ( True, diff --git a/assistants/knowledge-transfer-assistant/assistant/domain/knowledge_brief_manager.py b/assistants/knowledge-transfer-assistant/assistant/domain/knowledge_brief_manager.py index d46c55714..fbe210cf7 100644 --- a/assistants/knowledge-transfer-assistant/assistant/domain/knowledge_brief_manager.py +++ b/assistants/knowledge-transfer-assistant/assistant/domain/knowledge_brief_manager.py @@ -38,9 +38,7 @@ async def update_knowledge_brief( ) -> Optional[KnowledgeBrief]: share_id = await ShareManager.get_share_id(context) if not share_id: - logger.error( - "Cannot update brief: no share associated with this conversation" - ) + logger.error("Cannot update brief: no share associated with this conversation") return current_user_id = await require_current_user(context, "update brief") @@ -75,11 +73,7 @@ async def update_knowledge_brief( message=f"Created brief: {title}", ) - await Notifications.notify_all( - context, share_id, "Knowledge brief has been updated" - ) - await Notifications.notify_all_state_update( - context, share_id, [InspectorTab.BRIEF] - ) + await Notifications.notify_all(context, share_id, "Knowledge brief has been updated") + await Notifications.notify_all_state_update(context, share_id, [InspectorTab.BRIEF]) return brief diff --git a/assistants/knowledge-transfer-assistant/assistant/domain/knowledge_digest_manager.py b/assistants/knowledge-transfer-assistant/assistant/domain/knowledge_digest_manager.py index 4024056b0..c7b3e3b00 100644 --- a/assistants/knowledge-transfer-assistant/assistant/domain/knowledge_digest_manager.py +++ b/assistants/knowledge-transfer-assistant/assistant/domain/knowledge_digest_manager.py @@ -43,14 +43,10 @@ async def update_knowledge_digest( try: share_id = await ShareManager.get_share_id(context) if not share_id: - logger.error( - "Cannot update knowledge digest: no share associated with this conversation" - ) + logger.error("Cannot update knowledge digest: no share associated with this conversation") return False, None - current_user_id = await require_current_user( - context, "update knowledge digest" - ) + current_user_id = await require_current_user(context, "update knowledge digest") if not current_user_id: return False, None @@ -76,9 +72,7 @@ async def update_knowledge_digest( # Log the update event_type = LogEntryType.KNOWLEDGE_DIGEST_UPDATE update_type = "auto-generated" if is_auto_generated else "manual" - message = ( - f"{'Created' if is_new else 'Updated'} knowledge digest ({update_type})" - ) + message = f"{'Created' if is_new else 'Updated'} knowledge digest ({update_type})" await ShareManager.log_share_event( context=context, @@ -111,14 +105,10 @@ async def auto_update_knowledge_digest( share_id = await ShareManager.get_share_id(context) if not share_id: - logger.error( - "Cannot auto-update knowledge digest: no share associated with this conversation" - ) + logger.error("Cannot auto-update knowledge digest: no share associated with this conversation") return False, None - current_user_id = await require_current_user( - context, "auto-update knowledge digest" - ) + current_user_id = await require_current_user(context, "auto-update knowledge digest") if not current_user_id: return False, None @@ -131,10 +121,7 @@ async def auto_update_knowledge_digest( chat_history_text = "" for msg in chat_history: sender_type = ( - "User" - if msg.sender - and msg.sender.participant_role == ParticipantRole.user - else "Assistant" + "User" if msg.sender and msg.sender.participant_role == ParticipantRole.user else "Assistant" ) chat_history_text += f"{sender_type}: {msg.content}\n\n" @@ -148,9 +135,7 @@ async def auto_update_knowledge_digest( """ - async with openai_client.create_client( - config.service_config, api_version="2024-06-01" - ) as client: + async with openai_client.create_client(config.service_config, api_version="2024-06-01") as client: completion = await client.chat.completions.create( model=config.request_config.openai_model, messages=[{"role": "user", "content": digest_prompt}], @@ -159,18 +144,14 @@ async def auto_update_knowledge_digest( content = completion.choices[0].message.content or "" digest_content = "" - match = re.search( - r"(.*?)", content, re.DOTALL - ) + match = re.search(r"(.*?)", content, re.DOTALL) if match: digest_content = match.group(1).strip() else: digest_content = content.strip() if not digest_content: - logger.warning( - "No content extracted from knowledge digest LLM analysis" - ) + logger.warning("No content extracted from knowledge digest LLM analysis") return False, None result = await KnowledgeDigestManager.update_knowledge_digest( diff --git a/assistants/knowledge-transfer-assistant/assistant/domain/learning_objectives_manager.py b/assistants/knowledge-transfer-assistant/assistant/domain/learning_objectives_manager.py index e5f81b24f..ed60df69b 100644 --- a/assistants/knowledge-transfer-assistant/assistant/domain/learning_objectives_manager.py +++ b/assistants/knowledge-transfer-assistant/assistant/domain/learning_objectives_manager.py @@ -36,9 +36,7 @@ async def add_learning_objective( ) -> Optional[LearningObjective]: share_id = await ShareManager.get_share_id(context) if not share_id: - logger.error( - "Cannot add learning objective: no share associated with this conversation" - ) + logger.error("Cannot add learning objective: no share associated with this conversation") return None current_user_id = await require_current_user(context, "add learning objective") @@ -79,12 +77,8 @@ async def add_learning_objective( message=f"Added learning objective: {objective_name}", ) - await Notifications.notify_all( - context, share_id, f"Learning objective '{objective_name}' was added" - ) - await Notifications.notify_all_state_update( - context, share_id, [InspectorTab.LEARNING, InspectorTab.BRIEF] - ) + await Notifications.notify_all(context, share_id, f"Learning objective '{objective_name}' was added") + await Notifications.notify_all_state_update(context, share_id, [InspectorTab.LEARNING, InspectorTab.BRIEF]) return new_learning_objective @@ -98,14 +92,10 @@ async def update_learning_objective( """Update an existing learning objective's name or description.""" share_id = await ShareManager.get_share_id(context) if not share_id: - logger.error( - "Cannot update learning objective: no share associated with this conversation" - ) + logger.error("Cannot update learning objective: no share associated with this conversation") return False, "No share associated with this conversation." - current_user_id = await require_current_user( - context, "update learning objective" - ) + current_user_id = await require_current_user(context, "update learning objective") if not current_user_id: return False, "Could not identify current user." @@ -156,12 +146,8 @@ async def update_learning_objective( }, ) - await Notifications.notify_all( - context, share_id, f"Learning objective '{objective.name}' has been updated" - ) - await Notifications.notify_all_state_update( - context, share_id, [InspectorTab.LEARNING, InspectorTab.BRIEF] - ) + await Notifications.notify_all(context, share_id, f"Learning objective '{objective.name}' has been updated") + await Notifications.notify_all_state_update(context, share_id, [InspectorTab.LEARNING, InspectorTab.BRIEF]) return ( True, @@ -176,14 +162,10 @@ async def delete_learning_objective( """Delete a learning objective by ID.""" share_id = await ShareManager.get_share_id(context) if not share_id: - logger.error( - "Cannot delete learning objective: no share associated with this conversation" - ) + logger.error("Cannot delete learning objective: no share associated with this conversation") return False, "No share associated with this conversation." - current_user_id = await require_current_user( - context, "delete learning objective" - ) + current_user_id = await require_current_user(context, "delete learning objective") if not current_user_id: return False, "Could not identify current user." @@ -234,12 +216,8 @@ async def delete_learning_objective( }, ) - await Notifications.notify_all( - context, share_id, f"Learning objective '{objective_name}' has been deleted" - ) - await Notifications.notify_all_state_update( - context, share_id, [InspectorTab.LEARNING, InspectorTab.BRIEF] - ) + await Notifications.notify_all(context, share_id, f"Learning objective '{objective_name}' has been deleted") + await Notifications.notify_all_state_update(context, share_id, [InspectorTab.LEARNING, InspectorTab.BRIEF]) return ( True, @@ -274,9 +252,7 @@ async def add_learning_outcome( """Add a new learning outcome to an existing learning objective.""" share_id = await ShareManager.get_share_id(context) if not share_id: - logger.error( - "Cannot add learning outcome: no share associated with this conversation" - ) + logger.error("Cannot add learning outcome: no share associated with this conversation") return False, "No knowledge package associated with this conversation." current_user_id = await require_current_user(context, "add learning outcome") @@ -332,9 +308,7 @@ async def add_learning_outcome( share_id, f"Learning outcome '{outcome_description}' has been added", ) - await Notifications.notify_all_state_update( - context, share_id, [InspectorTab.LEARNING, InspectorTab.BRIEF] - ) + await Notifications.notify_all_state_update(context, share_id, [InspectorTab.LEARNING, InspectorTab.BRIEF]) return ( True, @@ -350,9 +324,7 @@ async def update_learning_outcome( """Update the description of an existing learning outcome.""" share_id = await ShareManager.get_share_id(context) if not share_id: - logger.error( - "Cannot update learning outcome: no share associated with this conversation" - ) + logger.error("Cannot update learning outcome: no share associated with this conversation") return False, "No knowledge package associated with this conversation." current_user_id = await require_current_user(context, "update learning outcome") @@ -412,12 +384,8 @@ async def update_learning_outcome( ) # Notify linked conversations - await Notifications.notify_all( - context, share_id, f"Learning outcome '{new_description}' has been updated" - ) - await Notifications.notify_all_state_update( - context, share_id, [InspectorTab.LEARNING, InspectorTab.BRIEF] - ) + await Notifications.notify_all(context, share_id, f"Learning outcome '{new_description}' has been updated") + await Notifications.notify_all_state_update(context, share_id, [InspectorTab.LEARNING, InspectorTab.BRIEF]) return ( True, @@ -432,9 +400,7 @@ async def delete_learning_outcome( """Delete a learning outcome from a learning objective.""" share_id = await ShareManager.get_share_id(context) if not share_id: - logger.error( - "Cannot delete learning outcome: no share associated with this conversation" - ) + logger.error("Cannot delete learning outcome: no share associated with this conversation") return False, "No knowledge package associated with this conversation." current_user_id = await require_current_user(context, "delete learning outcome") @@ -481,9 +447,7 @@ async def delete_learning_outcome( # Clean up any achievement records for this outcome across all team conversations for team_info in share.team_conversations.values(): team_info.outcome_achievements = [ - achievement - for achievement in team_info.outcome_achievements - if achievement.outcome_id != outcome_id + achievement for achievement in team_info.outcome_achievements if achievement.outcome_id != outcome_id ] # Save the updated knowledge package @@ -509,9 +473,7 @@ async def delete_learning_outcome( share_id, f"Learning outcome '{deleted_description}' has been removed", ) - await Notifications.notify_all_state_update( - context, share_id, [InspectorTab.LEARNING, InspectorTab.BRIEF] - ) + await Notifications.notify_all_state_update(context, share_id, [InspectorTab.LEARNING, InspectorTab.BRIEF]) return ( True, @@ -526,28 +488,18 @@ def get_achievements_for_conversation( return team_conv.outcome_achievements if team_conv else [] @staticmethod - def get_completion_for_conversation( - package: KnowledgePackage, conversation_id: str - ) -> Tuple[int, int]: - achievements = LearningObjectivesManager.get_achievements_for_conversation( - package, conversation_id - ) + def get_completion_for_conversation(package: KnowledgePackage, conversation_id: str) -> Tuple[int, int]: + achievements = LearningObjectivesManager.get_achievements_for_conversation(package, conversation_id) achieved_outcome_ids = {a.outcome_id for a in achievements if a.achieved} - total_outcomes = sum( - len(obj.learning_outcomes) for obj in package.learning_objectives - ) + total_outcomes = sum(len(obj.learning_outcomes) for obj in package.learning_objectives) achieved_outcomes = len(achieved_outcome_ids) return achieved_outcomes, total_outcomes @staticmethod - def is_outcome_achieved_by_conversation( - package: KnowledgePackage, outcome_id: str, conversation_id: str - ) -> bool: - achievements = LearningObjectivesManager.get_achievements_for_conversation( - package, conversation_id - ) + def is_outcome_achieved_by_conversation(package: KnowledgePackage, outcome_id: str, conversation_id: str) -> bool: + achievements = LearningObjectivesManager.get_achievements_for_conversation(package, conversation_id) return any(a.outcome_id == outcome_id and a.achieved for a in achievements) @staticmethod @@ -559,12 +511,8 @@ def get_overall_completion(package: KnowledgePackage) -> Tuple[int, int]: """ all_achieved_outcomes = set() for team_conv in package.team_conversations.values(): - achieved_ids = { - a.outcome_id for a in team_conv.outcome_achievements if a.achieved - } + achieved_ids = {a.outcome_id for a in team_conv.outcome_achievements if a.achieved} all_achieved_outcomes.update(achieved_ids) - total_outcomes = sum( - len(obj.learning_outcomes) for obj in package.learning_objectives - ) + total_outcomes = sum(len(obj.learning_outcomes) for obj in package.learning_objectives) return len(all_achieved_outcomes), total_outcomes diff --git a/assistants/knowledge-transfer-assistant/assistant/domain/share_manager.py b/assistants/knowledge-transfer-assistant/assistant/domain/share_manager.py index acc70f731..390b73b50 100644 --- a/assistants/knowledge-transfer-assistant/assistant/domain/share_manager.py +++ b/assistants/knowledge-transfer-assistant/assistant/domain/share_manager.py @@ -75,9 +75,7 @@ async def create_share(context: ConversationContext) -> str: # Associate the conversation with the share logger.debug(f"Associating conversation {context.id} with share {share_id}") - await ShareManager.set_conversation_role( - context, share_id, ConversationRole.COORDINATOR - ) + await ShareManager.set_conversation_role(context, share_id, ConversationRole.COORDINATOR) # No need to set conversation role in share storage, as we use metadata logger.debug(f"Conversation {context.id} is Coordinator for share {share_id}") @@ -87,15 +85,11 @@ async def create_share(context: ConversationContext) -> str: return share_id @staticmethod - async def set_conversation_role( - context: ConversationContext, share_id: str, role: ConversationRole - ) -> None: + async def set_conversation_role(context: ConversationContext, share_id: str, role: ConversationRole) -> None: """ Sets the role of a conversation in a knowledge transfer share. """ - role_data = ConversationShareInfo( - share_id=share_id, role=role, conversation_id=str(context.id) - ) + role_data = ConversationShareInfo(share_id=share_id, role=role, conversation_id=str(context.id)) role_path = ShareStorageManager.get_conversation_role_file_path(context) write_model(role_path, role_data) @@ -115,9 +109,7 @@ async def get_conversation_role( return None @staticmethod - async def create_shareable_team_conversation( - context: ConversationContext, share_id: str - ) -> str: + async def create_shareable_team_conversation(context: ConversationContext, share_id: str) -> str: """ Creates a new shareable team conversation template. @@ -153,9 +145,7 @@ async def create_shareable_team_conversation( }, ) client = context._conversations_client - conversation = await client.create_conversation_with_owner( - new_conversation=new_conversation, owner_id=user_id - ) + conversation = await client.create_conversation_with_owner(new_conversation=new_conversation, owner_id=user_id) if not conversation or not conversation.id: raise ValueError("Failed to create team conversation") @@ -171,10 +161,8 @@ async def create_shareable_team_conversation( "show_duplicate_action": True, }, ) - share = ( - await context._conversations_client.create_conversation_share_with_owner( - new_conversation_share=new_share, owner_id=user_id - ) + share = await context._conversations_client.create_conversation_share_with_owner( + new_conversation_share=new_share, owner_id=user_id ) share_url = f"/conversation-share/{share.id}/redeem" @@ -187,9 +175,7 @@ async def create_shareable_team_conversation( knowledge_package.updated_at = datetime.utcnow() ShareStorage.write_share(share_id, knowledge_package) else: - raise ValueError( - f"KnowledgePackage info not found for share ID: {share_id}" - ) + raise ValueError(f"KnowledgePackage info not found for share ID: {share_id}") return share_url @@ -288,9 +274,7 @@ async def set_share(context: ConversationContext, share: KnowledgePackage) -> No """ share_id = await ShareManager.get_share_id(context) if not share_id: - raise ValueError( - "Cannot set share: no share ID found for this conversation" - ) + raise ValueError("Cannot set share: no share ID found for this conversation") ShareStorage.write_share(share_id, share) @@ -331,9 +315,7 @@ async def get_linked_conversations(context: ConversationContext) -> List[str]: return [] @staticmethod - async def _capture_redeemer_info( - context: ConversationContext, share_id: str - ) -> None: + async def _capture_redeemer_info(context: ConversationContext, share_id: str) -> None: """ Captures the redeemer (first non-assistant participant) information and stores it in the knowledge package. Only captures info for actual team member conversations, not coordinator or shared conversations. @@ -341,25 +323,19 @@ async def _capture_redeemer_info( try: knowledge_package = ShareStorage.read_share(share_id) if not knowledge_package: - logger.warning( - f"Could not load knowledge package {share_id} to capture redeemer info" - ) + logger.warning(f"Could not load knowledge package {share_id} to capture redeemer info") return conversation_id = str(context.id) # Skip if this is the coordinator conversation if conversation_id == knowledge_package.coordinator_conversation_id: - logger.debug( - f"Skipping redeemer capture for coordinator conversation {conversation_id}" - ) + logger.debug(f"Skipping redeemer capture for coordinator conversation {conversation_id}") return # Skip if this is the shared conversation template if conversation_id == knowledge_package.shared_conversation_id: - logger.debug( - f"Skipping redeemer capture for shared conversation template {conversation_id}" - ) + logger.debug(f"Skipping redeemer capture for shared conversation template {conversation_id}") return # If we get here, it's a team member conversation - capture redeemer info @@ -367,9 +343,7 @@ async def _capture_redeemer_info( user_id, user_name = await get_current_user(context) if not user_id or not user_name: - logger.warning( - f"Could not identify redeemer for conversation {conversation_id}" - ) + logger.warning(f"Could not identify redeemer for conversation {conversation_id}") return # Create team conversation info @@ -380,15 +354,11 @@ async def _capture_redeemer_info( ) # Add to knowledge package - knowledge_package.team_conversations[conversation_id] = ( - team_conversation_info - ) + knowledge_package.team_conversations[conversation_id] = team_conversation_info # Save the updated knowledge package ShareStorage.write_share(share_id, knowledge_package) - logger.debug( - f"Captured redeemer info for team conversation {conversation_id}: {user_name} ({user_id})" - ) + logger.debug(f"Captured redeemer info for team conversation {conversation_id}: {user_name} ({user_id})") except Exception as e: logger.error(f"Error capturing redeemer info: {e}") @@ -421,9 +391,7 @@ async def append_coordinator_message( """ share_id = await ShareManager.get_share_id(context) if not share_id: - raise ValueError( - "Cannot append message: no share ID found for this conversation" - ) + raise ValueError("Cannot append message: no share ID found for this conversation") ShareStorage.append_coordinator_message( share_id=share_id, diff --git a/assistants/knowledge-transfer-assistant/assistant/domain/transfer_manager.py b/assistants/knowledge-transfer-assistant/assistant/domain/transfer_manager.py index 01744b729..04cb9ba53 100644 --- a/assistants/knowledge-transfer-assistant/assistant/domain/transfer_manager.py +++ b/assistants/knowledge-transfer-assistant/assistant/domain/transfer_manager.py @@ -16,9 +16,7 @@ class TransferManager: @staticmethod def is_ready_for_transfer(package: KnowledgePackage) -> bool: has_basic_requirements = ( - package.knowledge_organized - and package.brief is not None - and package.audience is not None + package.knowledge_organized and package.brief is not None and package.audience is not None ) if not has_basic_requirements: @@ -33,10 +31,7 @@ def is_ready_for_transfer(package: KnowledgePackage) -> bool: @staticmethod def is_actively_sharing(package: KnowledgePackage) -> bool: - return ( - TransferManager.is_ready_for_transfer(package) - and len(package.team_conversations) > 0 - ) + return TransferManager.is_ready_for_transfer(package) and len(package.team_conversations) > 0 @staticmethod def _is_transfer_complete(package: KnowledgePackage) -> bool: @@ -48,7 +43,5 @@ def _is_transfer_complete(package: KnowledgePackage) -> bool: if not package.is_intended_to_accomplish_outcomes: return False - achieved_outcomes, total_outcomes = ( - LearningObjectivesManager.get_overall_completion(package) - ) + achieved_outcomes, total_outcomes = LearningObjectivesManager.get_overall_completion(package) return total_outcomes > 0 and achieved_outcomes == total_outcomes diff --git a/assistants/knowledge-transfer-assistant/assistant/files.py b/assistants/knowledge-transfer-assistant/assistant/files.py index 00ef0a53b..2f4bb246b 100644 --- a/assistants/knowledge-transfer-assistant/assistant/files.py +++ b/assistants/knowledge-transfer-assistant/assistant/files.py @@ -138,9 +138,7 @@ async def copy_file_to_share_storage( try: # Verify file information if not file.filename: - logger.error( - "Missing filename in file metadata", extra=safe_extra(log_extra) - ) + logger.error("Missing filename in file metadata", extra=safe_extra(log_extra)) return False # Check if share storage directory exists @@ -228,11 +226,7 @@ async def copy_file_to_share_storage( # Check if file already exists in collection existing_idx = next( - ( - i - for i, f in enumerate(metadata.files) - if f.filename == file.filename - ), + (i for i, f in enumerate(metadata.files) if f.filename == file.filename), None, ) if existing_idx is not None: @@ -260,9 +254,7 @@ async def copy_file_to_share_storage( ) return False - file_exists_in_metadata = any( - f.filename == file.filename for f in verification_metadata.files - ) + file_exists_in_metadata = any(f.filename == file.filename for f in verification_metadata.files) if not file_exists_in_metadata: logger.error( f"File metadata doesn't contain entry for {file.filename}", @@ -280,9 +272,7 @@ async def copy_file_to_share_storage( return True except Exception as e: - logger.exception( - f"Error copying file to share storage: {e}", extra=safe_extra(log_extra) - ) + logger.exception(f"Error copying file to share storage: {e}", extra=safe_extra(log_extra)) return False @staticmethod @@ -333,17 +323,13 @@ async def notify_team_conversations_file_deleted( """ try: # Get Team conversations - team_conversations = await ShareFilesManager.get_team_conversations( - context, share_id - ) + team_conversations = await ShareFilesManager.get_team_conversations(context, share_id) if not team_conversations: return for conv_id in team_conversations: try: - client = ConversationClientManager.get_conversation_client( - context, conv_id - ) + client = ConversationClientManager.get_conversation_client(context, conv_id) # Check if file exists in the conversation conversation = await client.get_conversation() @@ -353,9 +339,7 @@ async def notify_team_conversations_file_deleted( if file_exists: # Delete the file await client.delete_file(filename) - logger.debug( - f"Deleted file {filename} from Team conversation {conv_id}" - ) + logger.debug(f"Deleted file {filename} from Team conversation {conv_id}") # Send notification await client.send_messages( @@ -365,14 +349,10 @@ async def notify_team_conversations_file_deleted( ) ) except Exception as e: - logger.warning( - f"Failed to delete file {filename} from Team conversation {conv_id}: {e}" - ) + logger.warning(f"Failed to delete file {filename} from Team conversation {conv_id}: {e}") except Exception as e: - logger.exception( - f"Error notifying Team conversations about deleted file: {e}" - ) + logger.exception(f"Error notifying Team conversations about deleted file: {e}") @staticmethod async def copy_file_to_conversation( @@ -398,21 +378,15 @@ async def copy_file_to_conversation( return False # Find the file metadata - file_meta = next( - (f for f in metadata.files if f.filename == filename), None - ) + file_meta = next((f for f in metadata.files if f.filename == filename), None) if not file_meta: logger.warning(f"No metadata found for file {filename}") return False # Create client for target conversation - target_client = ConversationClientManager.get_conversation_client( - context, target_conversation_id - ) + target_client = ConversationClientManager.get_conversation_client(context, target_conversation_id) if not target_client: - logger.warning( - f"Could not create client for conversation {target_conversation_id}" - ) + logger.warning(f"Could not create client for conversation {target_conversation_id}") return False # Read the file content @@ -421,9 +395,7 @@ async def copy_file_to_conversation( file_bytes = f.read() if not file_bytes: - logger.warning( - f"Failed to read file content from {file_path} (empty file)" - ) + logger.warning(f"Failed to read file content from {file_path} (empty file)") return False file_content = io.BytesIO(file_bytes) @@ -471,9 +443,7 @@ async def copy_file_to_conversation( return False @staticmethod - async def get_team_conversations( - context: ConversationContext, share_id: str - ) -> List[str]: + async def get_team_conversations(context: ConversationContext, share_id: str) -> List[str]: """ Gets all Team conversation IDs for a share. """ @@ -485,9 +455,7 @@ async def get_team_conversations( team_conversations = [] for conv_id in linked_conversations: # Check if this is a team conversation - temp_context = await ShareFilesManager.create_temporary_context( - context, conv_id - ) + temp_context = await ShareFilesManager.create_temporary_context(context, conv_id) if temp_context: role = await ShareManager.get_conversation_role(temp_context) if role == ConversationRole.TEAM: @@ -596,7 +564,9 @@ async def synchronize_files_to_team_conversation( ) # Log the synchronization event - sync_message = f"Synchronized files to Team conversation: {len(successful_files)} new, {len(skipped_files)} existing" + sync_message = ( + f"Synchronized files to Team conversation: {len(successful_files)} new, {len(skipped_files)} existing" + ) await ShareManager.log_share_event( context=context, @@ -610,9 +580,7 @@ async def synchronize_files_to_team_conversation( ) @staticmethod - async def get_shared_files( - context: ConversationContext, share_id: str - ) -> Dict[str, ShareFile]: + async def get_shared_files(context: ConversationContext, share_id: str) -> Dict[str, ShareFile]: """ Gets all shared files for a share with filename as key. """ @@ -642,9 +610,7 @@ async def process_share_update_notification( role = await ShareManager.get_conversation_role(context) if role != ConversationRole.TEAM: - logger.warning( - "Only Team conversations should process file update notifications" - ) + logger.warning("Only Team conversations should process file update notifications") return False # Process based on update type @@ -661,9 +627,7 @@ async def process_share_update_notification( if success: return True else: - logger.warning( - f"Failed to {action} file {filename} in Team conversation {context.id}" - ) + logger.warning(f"Failed to {action} file {filename} in Team conversation {context.id}") return False elif update_type == "file_deleted": @@ -677,18 +641,14 @@ async def process_share_update_notification( if file_exists: # Delete the file await context.delete_file(filename) - logger.debug( - f"Deleted file {filename} from Team conversation {context.id}" - ) + logger.debug(f"Deleted file {filename} from Team conversation {context.id}") return True else: # File doesn't exist, nothing to do return True except Exception as e: - logger.warning( - f"Failed to delete file {filename} from Team conversation: {e}" - ) + logger.warning(f"Failed to delete file {filename} from Team conversation: {e}") return False else: logger.warning(f"Unknown file update type: {update_type}") diff --git a/assistants/knowledge-transfer-assistant/assistant/logging.py b/assistants/knowledge-transfer-assistant/assistant/logging.py index 0a942f96f..6a50e1156 100644 --- a/assistants/knowledge-transfer-assistant/assistant/logging.py +++ b/assistants/knowledge-transfer-assistant/assistant/logging.py @@ -161,9 +161,7 @@ def setup_file_logging(log_dir: Optional[str] = None) -> Path: # Also set up a regular text file handler for easier debugging text_file_handler = logging.FileHandler(line_log_file) text_file_handler.setLevel(logging.DEBUG) - text_file_handler.setFormatter( - logging.Formatter("%(asctime)s - %(name)s - [%(levelname)s] %(message)s") - ) + text_file_handler.setFormatter(logging.Formatter("%(asctime)s - %(name)s - [%(levelname)s] %(message)s")) logger.addHandler(text_file_handler) logger.debug( @@ -185,12 +183,8 @@ def setup_file_logging(log_dir: Optional[str] = None) -> Path: try: import stat - os.chmod( - log_file, stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IROTH - ) - os.chmod( - line_log_file, stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IROTH - ) + os.chmod(log_file, stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IROTH) + os.chmod(line_log_file, stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IROTH) except Exception as e: logger.warning(f"Could not set log file permissions: {e}") print(f"Permission error: {e}") @@ -216,11 +210,7 @@ def setup_file_logging(log_dir: Optional[str] = None) -> Path: text_file_handler = logging.FileHandler(line_log_file) text_file_handler.setLevel(logging.DEBUG) - text_file_handler.setFormatter( - logging.Formatter( - "%(asctime)s - %(name)s - [%(levelname)s] %(message)s" - ) - ) + text_file_handler.setFormatter(logging.Formatter("%(asctime)s - %(name)s - [%(levelname)s] %(message)s")) logger.addHandler(text_file_handler) logger.warning(f"Using fallback log location: {log_file}") diff --git a/assistants/knowledge-transfer-assistant/assistant/notifications.py b/assistants/knowledge-transfer-assistant/assistant/notifications.py index ed3857ce5..365c20afb 100644 --- a/assistants/knowledge-transfer-assistant/assistant/notifications.py +++ b/assistants/knowledge-transfer-assistant/assistant/notifications.py @@ -59,9 +59,7 @@ async def notify_self_and_other( return try: - client = ConversationClientManager.get_conversation_client( - context, target_id - ) + client = ConversationClientManager.get_conversation_client(context, target_id) await client.send_messages( NewConversationMessage( content=message, @@ -72,9 +70,7 @@ async def notify_self_and_other( logger.error(f"Failed to notify conversation {target_id}: {e}") @staticmethod - async def notify_all( - context: ConversationContext, share_id: str, message: str - ) -> None: + async def notify_all(context: ConversationContext, share_id: str, message: str) -> None: """Send text message notification to all knowledge transfer conversations.""" knowledge_package = await ShareManager.get_share(context) @@ -106,14 +102,9 @@ async def notify_all( # Notify all team conversations for conv_id in knowledge_package.team_conversations.keys(): - if ( - conv_id != current_id - and conv_id != knowledge_package.coordinator_conversation_id - ): + if conv_id != current_id and conv_id != knowledge_package.coordinator_conversation_id: try: - client = ConversationClientManager.get_conversation_client( - context, conv_id - ) + client = ConversationClientManager.get_conversation_client(context, conv_id) await client.send_messages( NewConversationMessage( content=message, @@ -126,9 +117,7 @@ async def notify_all( # State Update Notifications (UI refreshes) @staticmethod - async def notify_state_update( - context: ConversationContext, tabs: List[InspectorTab] - ) -> None: + async def notify_state_update(context: ConversationContext, tabs: List[InspectorTab]) -> None: """Send state update notifications to refresh UI in current conversation only.""" for tab in tabs: state_event = AssistantStateEvent( @@ -139,9 +128,7 @@ async def notify_state_update( await context.send_conversation_state_event(state_event) @staticmethod - async def notify_all_state_update( - context: ConversationContext, share_id: str, tabs: List[InspectorTab] - ) -> None: + async def notify_all_state_update(context: ConversationContext, share_id: str, tabs: List[InspectorTab]) -> None: """Send state update notifications to refresh UI across all share conversations.""" # Refresh current conversation first @@ -180,14 +167,9 @@ async def notify_all_state_update( # Refresh all team conversations for conv_id in knowledge_package.team_conversations.keys(): - if ( - conv_id != current_id - and conv_id != knowledge_package.coordinator_conversation_id - ): + if conv_id != current_id and conv_id != knowledge_package.coordinator_conversation_id: try: - client = ConversationClientManager.get_conversation_client( - context, conv_id - ) + client = ConversationClientManager.get_conversation_client(context, conv_id) for tab in tabs: state_event = AssistantStateEvent( diff --git a/assistants/knowledge-transfer-assistant/assistant/respond.py b/assistants/knowledge-transfer-assistant/assistant/respond.py index 4d2d8ef0c..6268539ad 100644 --- a/assistants/knowledge-transfer-assistant/assistant/respond.py +++ b/assistants/knowledge-transfer-assistant/assistant/respond.py @@ -42,21 +42,13 @@ SILENCE_TOKEN = "{{SILENCE}}" -def format_message( - participants: ConversationParticipantList, message: ConversationMessage -) -> str: +def format_message(participants: ConversationParticipantList, message: ConversationMessage) -> str: """Consistent formatter that includes the participant name for multi-participant and name references""" conversation_participant = next( - ( - participant - for participant in participants.participants - if participant.id == message.sender.participant_id - ), + (participant for participant in participants.participants if participant.id == message.sender.participant_id), None, ) - participant_name = ( - conversation_participant.name if conversation_participant else "unknown" - ) + participant_name = conversation_participant.name if conversation_participant else "unknown" message_datetime = message.timestamp.strftime("%Y-%m-%d %H:%M:%S") return f"[{participant_name} - {message_datetime}]: {message.content}" @@ -123,10 +115,7 @@ async def respond_to_conversation( model = config.request_config.openai_model # Requirements - role = ( - await ShareManager.get_conversation_role(context) - or ConversationRole.COORDINATOR - ) + role = await ShareManager.get_conversation_role(context) or ConversationRole.COORDINATOR metadata["debug"]["role"] = role token_budget = TokenBudget(config.request_config.max_tokens) @@ -159,13 +148,11 @@ async def respond_to_conversation( "\n\n" f"There are {len(participants.participants)} participants in the conversation," " including you as the assistant and the following users:" - + ",".join( - [ - f' "{participant.name}"' - for participant in participants.participants - if participant.id != context.assistant.id - ] - ) + + ",".join([ + f' "{participant.name}"' + for participant in participants.participants + if participant.id != context.assistant.id + ]) + "\n\nYou do not need to respond to every message. Do not respond if the last thing said was a closing" " statement such as 'bye' or 'goodbye', or just a general acknowledgement like 'ok' or 'thanks'. Do not" f' respond as another user in the conversation, only as "{context.assistant.name}".' @@ -173,11 +160,7 @@ async def respond_to_conversation( f' be directed at you or the general audience, go ahead and respond.\n\nSay "{SILENCE_TOKEN}" to skip' " your turn." ) - instructions.add_subsection( - Instructions( - participant_text, "Multi-participant conversation instructions" - ) - ) + instructions.add_subsection(Instructions(participant_text, "Multi-participant conversation instructions")) prompt = Prompt( role=assistant_role, @@ -215,9 +198,7 @@ async def respond_to_conversation( # Brief if share and share.brief: brief_text = "" - brief_text = ( - f"**Title:** {share.brief.title}\n**Description:** {share.brief.content}" - ) + brief_text = f"**Title:** {share.brief.title}\n**Description:** {share.brief.content}" prompt.contexts.append( Context( "Knowledge Brief", @@ -247,37 +228,29 @@ async def respond_to_conversation( # Show progress based on role if role == ConversationRole.COORDINATOR: # Coordinator sees overall progress across all team members - achieved_overall, total_overall = ( - LearningObjectivesManager.get_overall_completion(share) + achieved_overall, total_overall = LearningObjectivesManager.get_overall_completion(share) + learning_objectives_text += ( + f"Overall Progress: {achieved_overall}/{total_overall} outcomes achieved by team members\n\n" ) - learning_objectives_text += f"Overall Progress: {achieved_overall}/{total_overall} outcomes achieved by team members\n\n" else: # Team member sees their personal progress if conversation_id in share.team_conversations: - achieved_personal, total_personal = ( - LearningObjectivesManager.get_completion_for_conversation( - share, conversation_id - ) + achieved_personal, total_personal = LearningObjectivesManager.get_completion_for_conversation( + share, conversation_id ) - progress_pct = ( - int((achieved_personal / total_personal * 100)) - if total_personal > 0 - else 0 + progress_pct = int((achieved_personal / total_personal * 100)) if total_personal > 0 else 0 + learning_objectives_text += ( + f"My Progress: {achieved_personal}/{total_personal} outcomes achieved ({progress_pct}%)\n\n" ) - learning_objectives_text += f"My Progress: {achieved_personal}/{total_personal} outcomes achieved ({progress_pct}%)\n\n" for i, objective in enumerate(share.learning_objectives): - learning_objectives_text += ( - f"{i + 1}. **{objective.name}** - {objective.description}\n" - ) + learning_objectives_text += f"{i + 1}. **{objective.name}** - {objective.description}\n" if objective.learning_outcomes: for criterion in objective.learning_outcomes: if role == ConversationRole.COORDINATOR: # Show if achieved by any team member achieved_by_any = any( - LearningObjectivesManager.is_outcome_achieved_by_conversation( - share, criterion.id, conv_id - ) + LearningObjectivesManager.is_outcome_achieved_by_conversation(share, criterion.id, conv_id) for conv_id in share.team_conversations.keys() ) check = "✅" if achieved_by_any else "⬜" @@ -310,24 +283,20 @@ async def respond_to_conversation( if share: all_requests = share.requests if role == ConversationRole.COORDINATOR: - active_requests = [ - r for r in all_requests if r.status != RequestStatus.RESOLVED - ] + active_requests = [r for r in all_requests if r.status != RequestStatus.RESOLVED] if active_requests: - coordinator_requests = "> 📋 **Use the request ID (not the title) with resolve_information_request()**\n\n" + coordinator_requests = ( + "> 📋 **Use the request ID (not the title) with resolve_information_request()**\n\n" + ) for req in active_requests[:10]: # Limit to 10 for brevity priority_emoji = get_priority_emoji(req.priority) status_emoji = get_status_emoji(req.status) - coordinator_requests += ( - f"{priority_emoji} **{req.title}** {status_emoji}\n" - ) + coordinator_requests += f"{priority_emoji} **{req.title}** {status_emoji}\n" coordinator_requests += f" **Request ID:** `{req.request_id}`\n" coordinator_requests += f" **Description:** {req.description}\n\n" if len(active_requests) > 10: - coordinator_requests += ( - f"*...and {len(active_requests) - 10} more requests.*\n" - ) + coordinator_requests += f"*...and {len(active_requests) - 10} more requests.*\n" else: coordinator_requests = "No active information requests." prompt.contexts.append( @@ -342,16 +311,15 @@ async def respond_to_conversation( # Filter for requests from this conversation that aren't resolved. my_requests = [ - r - for r in all_requests - if r.conversation_id == str(context.id) - and r.status != RequestStatus.RESOLVED + r for r in all_requests if r.conversation_id == str(context.id) and r.status != RequestStatus.RESOLVED ] if my_requests: information_requests_info = "" for req in my_requests: - information_requests_info += f"- **{req.title}** (ID: `{req.request_id}`, Priority: {req.priority})\n" + information_requests_info += ( + f"- **{req.title}** (ID: `{req.request_id}`, Priority: {req.priority})\n" + ) else: information_requests_info = "No active information requests." @@ -364,9 +332,7 @@ async def respond_to_conversation( # Add next action suggestions for coordinator if role == ConversationRole.COORDINATOR: - next_action_suggestion = ( - await CoordinatorSupport.get_coordinator_next_action_suggestion(context) - ) + next_action_suggestion = await CoordinatorSupport.get_coordinator_next_action_suggestion(context) if next_action_suggestion: prompt.contexts.append( Context( @@ -394,13 +360,9 @@ async def respond_to_conversation( if coordinator_conversation: # Limit messages to the configured max token count. total_coordinator_conversation_tokens = 0 - selected_coordinator_conversation_messages: List[ - CoordinatorConversationMessage - ] = [] + selected_coordinator_conversation_messages: List[CoordinatorConversationMessage] = [] for msg in reversed(coordinator_conversation.messages): - tokens = openai_client.num_tokens_from_string( - msg.model_dump_json(), model=model - ) + tokens = openai_client.num_tokens_from_string(msg.model_dump_json(), model=model) if ( total_coordinator_conversation_tokens + tokens > config.request_config.coordinator_conversation_token_limit @@ -414,9 +376,7 @@ class CoordinatorMessageList(BaseModel): messages: List[CoordinatorConversationMessage] = Field(default_factory=list) selected_coordinator_conversation_messages.reverse() - coordinator_message_list = CoordinatorMessageList( - messages=selected_coordinator_conversation_messages - ) + coordinator_message_list = CoordinatorMessageList(messages=selected_coordinator_conversation_messages) coordinator_conversation_message = ChatCompletionSystemMessageParam( role="system", content=( @@ -440,12 +400,10 @@ class CoordinatorMessageList(BaseModel): # in the proper flow of the conversation rather than as . # Generate the attachment messages. - attachment_messages: List[ChatCompletionMessageParam] = ( - openai_client.convert_from_completion_messages( - await attachments_extension.get_completion_messages_for_attachments( - context, - config=config.attachments_config, - ) + attachment_messages: List[ChatCompletionMessageParam] = openai_client.convert_from_completion_messages( + await attachments_extension.get_completion_messages_for_attachments( + context, + config=config.attachments_config, ) ) @@ -570,14 +528,9 @@ class CoordinatorMessageList(BaseModel): # For team role, analyze message for possible information request needs. # Send a notification if we think it might be one. if role is ConversationRole.TEAM: - detection_result = await detect_information_request_needs( - context, new_message.content - ) + detection_result = await detect_information_request_needs(context, new_message.content) - if ( - detection_result.get("is_information_request", False) - and detection_result.get("confidence", 0) > 0.8 - ): + if detection_result.get("is_information_request", False) and detection_result.get("confidence", 0) > 0.8: suggested_title = detection_result.get("potential_title", "") suggested_priority = detection_result.get("suggested_priority", "medium") potential_description = detection_result.get("potential_description", "") @@ -611,9 +564,7 @@ class CoordinatorMessageList(BaseModel): "messages": completion_messages, "model": model, "max_tokens": config.request_config.response_tokens, - "response_format": CoordinatorOutput - if role == ConversationRole.COORDINATOR - else TeamOutput, + "response_format": CoordinatorOutput if role == ConversationRole.COORDINATOR else TeamOutput, } share_tools = ShareTools(context, role) @@ -629,11 +580,7 @@ class CoordinatorMessageList(BaseModel): # Add the token usage message to the footer items if completion_response: - response_tokens = ( - completion_response.usage.completion_tokens - if completion_response.usage - else 0 - ) + response_tokens = completion_response.usage.completion_tokens if completion_response.usage else 0 request_tokens = token_budget.used footer_items.append( get_token_usage_message( @@ -653,9 +600,7 @@ class CoordinatorMessageList(BaseModel): } ) - footer_items.append( - get_response_duration_message(response_end_time - response_start_time) - ) + footer_items.append(get_response_duration_message(response_end_time - response_start_time)) metadata["footer_items"] = footer_items content = message_content_from_completion(completion_response) diff --git a/assistants/knowledge-transfer-assistant/assistant/storage.py b/assistants/knowledge-transfer-assistant/assistant/storage.py index 4b3e87122..8b553dd02 100644 --- a/assistants/knowledge-transfer-assistant/assistant/storage.py +++ b/assistants/knowledge-transfer-assistant/assistant/storage.py @@ -147,9 +147,7 @@ def read_coordinator_conversation( return read_model(path, CoordinatorConversationMessages) @staticmethod - def write_coordinator_conversation( - share_id: str, conversation: CoordinatorConversationMessages - ) -> pathlib.Path: + def write_coordinator_conversation(share_id: str, conversation: CoordinatorConversationMessages) -> pathlib.Path: path = ShareStorageManager.get_coordinator_conversation_path(share_id) write_model(path, conversation) return path @@ -211,9 +209,7 @@ def write_knowledge_digest(share_id: str, digest: KnowledgeDigest) -> pathlib.Pa return ShareStorage.write_share(share_id, package) @staticmethod - def read_information_request( - share_id: str, request_id: str - ) -> Optional[InformationRequest]: + def read_information_request(share_id: str, request_id: str) -> Optional[InformationRequest]: """Reads an information request from the main share data.""" package = ShareStorage.read_share(share_id) if not package or not package.requests: @@ -226,9 +222,7 @@ def read_information_request( return None @staticmethod - def write_information_request( - share_id: str, request: InformationRequest - ) -> pathlib.Path: + def write_information_request(share_id: str, request: InformationRequest) -> pathlib.Path: """Writes an information request to the main share data.""" # Information requests must have an ID if not request.request_id: diff --git a/assistants/knowledge-transfer-assistant/assistant/string_utils.py b/assistants/knowledge-transfer-assistant/assistant/string_utils.py index b783dc6ca..a9bcb7892 100644 --- a/assistants/knowledge-transfer-assistant/assistant/string_utils.py +++ b/assistants/knowledge-transfer-assistant/assistant/string_utils.py @@ -18,9 +18,7 @@ def render(template: str, vars: dict[str, Any]) -> str: return parsed -def create_system_message( - content: str, delimiter: str | None = None -) -> ChatCompletionMessageParam: +def create_system_message(content: str, delimiter: str | None = None) -> ChatCompletionMessageParam: if delimiter: content = f"<{delimiter}>\n{content}\n" @@ -60,9 +58,7 @@ def __str__(self) -> str: s += f"{hashes} {self.title}\n\n" s += self.content if self.subsections: - s += "\n\n" + "\n\n".join( - str(subsection) for subsection in self.subsections - ) + s += "\n\n" + "\n\n".join(str(subsection) for subsection in self.subsections) return s diff --git a/assistants/knowledge-transfer-assistant/assistant/tools/information_requests.py b/assistants/knowledge-transfer-assistant/assistant/tools/information_requests.py index f889ded95..4196da531 100644 --- a/assistants/knowledge-transfer-assistant/assistant/tools/information_requests.py +++ b/assistants/knowledge-transfer-assistant/assistant/tools/information_requests.py @@ -72,9 +72,7 @@ async def create_information_request( else: return "Failed to create information request. Please try again." - async def resolve_information_request( - self, request_id: str, resolution: str - ) -> str: + async def resolve_information_request(self, request_id: str, resolution: str) -> str: """ Resolve an information request when you have the needed information to address it. Only use for active information requests. If there are no active information requests, this should never be called. @@ -98,9 +96,7 @@ async def resolve_information_request( "create_information_request to send requests to the Coordinator, not try to resolve them yourself. " "The Coordinator must use resolve_information_request to respond to your requests." ) - logger.warning( - f"Team member attempted to use resolve_information_request: {request_id}" - ) + logger.warning(f"Team member attempted to use resolve_information_request: {request_id}") return error_message share = await ShareManager.get_share(self.context) @@ -114,16 +110,10 @@ async def resolve_information_request( context=self.context, request_id=request_id, resolution=resolution ) if success and information_request: - return ( - f"Information request '{information_request.title}' has been resolved." - ) + return f"Information request '{information_request.title}' has been resolved." else: - logger.warning( - f"Failed to resolve information request. Invalid ID provided: '{request_id}'" - ) - return ( - f'ERROR: Could not resolve information request with ID "{request_id}".' - ) + logger.warning(f"Failed to resolve information request. Invalid ID provided: '{request_id}'") + return f'ERROR: Could not resolve information request with ID "{request_id}".' async def delete_information_request(self, request_id: str) -> str: """ @@ -142,12 +132,4 @@ async def delete_information_request(self, request_id: str) -> str: success, message = await InformationRequestManager.delete_information_request( context=self.context, request_id=request_id ) - return ( - message - if message - else ( - "Request deleted successfully." - if success - else "Failed to delete request." - ) - ) + return message if message else ("Request deleted successfully." if success else "Failed to delete request.") diff --git a/assistants/knowledge-transfer-assistant/assistant/tools/learning_objectives.py b/assistants/knowledge-transfer-assistant/assistant/tools/learning_objectives.py index bdce598ce..8fa90b0c4 100644 --- a/assistants/knowledge-transfer-assistant/assistant/tools/learning_objectives.py +++ b/assistants/knowledge-transfer-assistant/assistant/tools/learning_objectives.py @@ -15,9 +15,7 @@ class LearningObjectiveTools(ToolsBase): """Tools for managing learning objectives and outcomes.""" - async def add_learning_objective( - self, objective_name: str, description: str, learning_outcomes: List[str] - ) -> str: + async def add_learning_objective(self, objective_name: str, description: str, learning_outcomes: List[str]) -> str: """ Add a learning objective with measurable learning outcomes. @@ -85,11 +83,7 @@ async def update_learning_objective( return ( message if message - else ( - "Learning objective updated successfully." - if success - else "Failed to update learning objective." - ) + else ("Learning objective updated successfully." if success else "Failed to update learning objective.") ) async def delete_learning_objective(self, objective_id: str) -> str: @@ -122,9 +116,5 @@ async def delete_learning_objective(self, objective_id: str) -> str: return ( message if message - else ( - "Learning objective deleted successfully." - if success - else "Failed to delete learning objective." - ) + else ("Learning objective deleted successfully." if success else "Failed to delete learning objective.") ) diff --git a/assistants/knowledge-transfer-assistant/assistant/tools/learning_outcomes.py b/assistants/knowledge-transfer-assistant/assistant/tools/learning_outcomes.py index bc07014b0..dbaa15f36 100644 --- a/assistants/knowledge-transfer-assistant/assistant/tools/learning_outcomes.py +++ b/assistants/knowledge-transfer-assistant/assistant/tools/learning_outcomes.py @@ -13,9 +13,7 @@ class LearningOutcomeTools(ToolsBase): """Tools for managing learning outcomes.""" - async def add_learning_outcome( - self, objective_id: str, outcome_description: str - ) -> str: + async def add_learning_outcome(self, objective_id: str, outcome_description: str) -> str: """ Add a new learning outcome to an existing learning objective. @@ -44,16 +42,10 @@ async def add_learning_outcome( return ( message if message - else ( - "Learning outcome added successfully." - if success - else "Failed to add learning outcome." - ) + else ("Learning outcome added successfully." if success else "Failed to add learning outcome.") ) - async def update_learning_outcome( - self, outcome_id: str, new_description: str - ) -> str: + async def update_learning_outcome(self, outcome_id: str, new_description: str) -> str: """ Update the description of an existing learning outcome. @@ -82,11 +74,7 @@ async def update_learning_outcome( return ( message if message - else ( - "Learning outcome updated successfully." - if success - else "Failed to update learning outcome." - ) + else ("Learning outcome updated successfully." if success else "Failed to update learning outcome.") ) async def delete_learning_outcome(self, outcome_id: str) -> str: @@ -118,9 +106,5 @@ async def delete_learning_outcome(self, outcome_id: str) -> str: return ( message if message - else ( - "Learning outcome deleted successfully." - if success - else "Failed to delete learning outcome." - ) + else ("Learning outcome deleted successfully." if success else "Failed to delete learning outcome.") ) diff --git a/assistants/knowledge-transfer-assistant/assistant/tools/progress_tracking.py b/assistants/knowledge-transfer-assistant/assistant/tools/progress_tracking.py index 742af706c..4c009f033 100644 --- a/assistants/knowledge-transfer-assistant/assistant/tools/progress_tracking.py +++ b/assistants/knowledge-transfer-assistant/assistant/tools/progress_tracking.py @@ -26,9 +26,7 @@ class ProgressTrackingTools(ToolsBase): """Tools for tracking learning progress.""" - async def mark_learning_outcome_achieved( - self, objective_id: str, outcome_id: str - ) -> str: + async def mark_learning_outcome_achieved(self, objective_id: str, outcome_id: str) -> str: """ Mark a learning outcome as achieved for tracking knowledge transfer progress. @@ -112,19 +110,13 @@ async def mark_learning_outcome_achieved( return "Team conversation not properly registered. Please contact the coordinator." # Create achievement record - achievement = LearningOutcomeAchievement( - outcome_id=outcome.id, achieved=True, achieved_at=datetime.utcnow() - ) + achievement = LearningOutcomeAchievement(outcome_id=outcome.id, achieved=True, achieved_at=datetime.utcnow()) # Add achievement to team conversation's achievements - knowledge_package.team_conversations[ - conversation_id - ].outcome_achievements.append(achievement) + knowledge_package.team_conversations[conversation_id].outcome_achievements.append(achievement) # Update team conversation's last active timestamp - knowledge_package.team_conversations[ - conversation_id - ].last_active_at = datetime.utcnow() + knowledge_package.team_conversations[conversation_id].last_active_at = datetime.utcnow() # Save the updated knowledge package with the achieved outcome await ShareManager.set_share(self.context, knowledge_package) @@ -166,9 +158,7 @@ async def mark_learning_outcome_achieved( # Check if all outcomes are achieved for transfer completion # Get the knowledge package to check completion status knowledge_package = await ShareManager.get_share(self.context) - if knowledge_package and TransferManager._is_transfer_complete( - knowledge_package - ): + if knowledge_package and TransferManager._is_transfer_complete(knowledge_package): await self.context.send_messages( NewConversationMessage( content="🎉 All learning outcomes have been achieved! The knowledge transfer has been automatically marked as complete.", @@ -211,9 +201,7 @@ async def report_transfer_completion(self) -> str: return "No knowledge package found. Cannot complete transfer without package information." # Check if all outcomes are achieved - achieved_outcomes, total_outcomes = ( - LearningObjectivesManager.get_overall_completion(share) - ) + achieved_outcomes, total_outcomes = LearningObjectivesManager.get_overall_completion(share) if achieved_outcomes < total_outcomes: remaining = total_outcomes - achieved_outcomes return f"Cannot complete knowledge transfer - {remaining} learning outcomes are still pending achievement." @@ -249,9 +237,7 @@ async def report_transfer_completion(self) -> str: share.share_id, "🎉 **Knowledge Transfer Complete**: Team has reported that all learning objectives have been achieved. The knowledge transfer is now complete.", ) - await Notifications.notify_all_state_update( - self.context, share.share_id, [InspectorTab.BRIEF] - ) + await Notifications.notify_all_state_update(self.context, share.share_id, [InspectorTab.BRIEF]) await self.context.send_messages( NewConversationMessage( diff --git a/assistants/knowledge-transfer-assistant/assistant/tools/share_setup.py b/assistants/knowledge-transfer-assistant/assistant/tools/share_setup.py index dd4bb261f..edeea9f20 100644 --- a/assistants/knowledge-transfer-assistant/assistant/tools/share_setup.py +++ b/assistants/knowledge-transfer-assistant/assistant/tools/share_setup.py @@ -35,15 +35,7 @@ async def update_audience(self, audience_description: str) -> str: audience_description=audience_description, ) - return ( - message - if message - else ( - "Audience updated successfully." - if success - else "Failed to update audience." - ) - ) + return message if message else ("Audience updated successfully." if success else "Failed to update audience.") async def set_knowledge_organized(self, is_organized: bool) -> str: """ diff --git a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/brief.py b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/brief.py index cfcf0fa35..536c27ebb 100644 --- a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/brief.py +++ b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/brief.py @@ -35,9 +35,7 @@ def __init__(self, config_provider) -> None: async def is_enabled(self, context: ConversationContext) -> bool: return True - async def get( - self, context: ConversationContext - ) -> AssistantConversationInspectorStateDataModel: + async def get(self, context: ConversationContext) -> AssistantConversationInspectorStateDataModel: """Get brief and status information for display.""" conversation_role = await ShareManager.get_conversation_role(context) @@ -46,22 +44,16 @@ async def get( share = await ShareManager.get_share(context) if not share: return AssistantConversationInspectorStateDataModel( - data={ - "content": "No active knowledge package. Start a conversation to create one." - } + data={"content": "No active knowledge package. Start a conversation to create one."} ) brief = share.brief share_info = await ShareManager.get_share(context) if conversation_role == ConversationRole.COORDINATOR: - markdown = await self._format_coordinator_brief( - share.share_id, brief, share_info, context - ) + markdown = await self._format_coordinator_brief(share.share_id, brief, share_info, context) else: - markdown = await self._format_team_brief( - share.share_id, brief, share_info, context - ) + markdown = await self._format_team_brief(share.share_id, brief, share_info, context) return AssistantConversationInspectorStateDataModel(data={"content": markdown}) @@ -103,9 +95,7 @@ async def _format_coordinator_brief( return "\n".join(lines) - async def _format_team_brief( - self, share_id: str, brief: Any, share_info: Any, context: ConversationContext - ) -> str: + async def _format_team_brief(self, share_id: str, brief: Any, share_info: Any, context: ConversationContext) -> str: """Format brief information for team members.""" lines: List[str] = [] @@ -136,9 +126,7 @@ async def _format_team_brief( else: lines.append("## Knowledge Brief") lines.append("") - lines.append( - "_The coordinator is still setting up the knowledge brief. Check back soon!_" - ) + lines.append("_The coordinator is still setting up the knowledge brief. Check back soon!_") lines.append("") return "\n".join(lines) diff --git a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/common.py b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/common.py index 1c0c2ee5a..9a7267225 100644 --- a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/common.py +++ b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/common.py @@ -51,17 +51,11 @@ def get_stage_label(package: KnowledgePackage, for_coordinator: bool = True) -> return "📋 Organizing Knowledge" elif not package.brief: return "📝 Creating Brief" - elif ( - package.is_intended_to_accomplish_outcomes - and not package.learning_objectives - ): + elif package.is_intended_to_accomplish_outcomes and not package.learning_objectives: return "📚 Adding Objectives" elif not TransferManager.is_ready_for_transfer(package): return "📋 Finalizing Setup" - elif ( - package.is_intended_to_accomplish_outcomes - and TransferManager._is_transfer_complete(package) - ): + elif package.is_intended_to_accomplish_outcomes and TransferManager._is_transfer_complete(package): return "✅ Transfer Complete" elif TransferManager.is_actively_sharing(package): return "📤 Sharing in Progress" diff --git a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/debug.py b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/debug.py index caf1ca4cd..cee1fb7b5 100644 --- a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/debug.py +++ b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/debug.py @@ -29,38 +29,28 @@ def __init__(self, config_provider) -> None: async def is_enabled(self, context: ConversationContext) -> bool: return True - async def get( - self, context: ConversationContext - ) -> AssistantConversationInspectorStateDataModel: + async def get(self, context: ConversationContext) -> AssistantConversationInspectorStateDataModel: """Get debug information for display.""" # Get share information share_id = await ShareManager.get_share_id(context) if not share_id: return AssistantConversationInspectorStateDataModel( - data={ - "content": "No active knowledge package. Start a conversation to create one." - } + data={"content": "No active knowledge package. Start a conversation to create one."} ) markdown = await self._format_debug_info(share_id, context) return AssistantConversationInspectorStateDataModel(data={"content": markdown}) - async def _format_debug_info( - self, share_id: str, context: ConversationContext - ) -> str: + async def _format_debug_info(self, share_id: str, context: ConversationContext) -> str: """Format debug information including knowledge digest.""" lines: List[str] = [] lines.append("## Debug Information") lines.append("") - lines.append( - "This panel shows internal information maintained by the assistant. This data is automatically" - ) - lines.append( - "generated and updated by the assistant and is not directly editable by users." - ) + lines.append("This panel shows internal information maintained by the assistant. This data is automatically") + lines.append("generated and updated by the assistant and is not directly editable by users.") lines.append("") # Get the knowledge digest @@ -69,20 +59,14 @@ async def _format_debug_info( if not digest: lines.append("### Knowledge Digest") lines.append("") - lines.append( - "No knowledge digest has been generated yet. The assistant will create and update this" - ) + lines.append("No knowledge digest has been generated yet. The assistant will create and update this") lines.append("automatically as the conversation develops.") lines.append("") lines.append("## Knowledge Digest") lines.append("") - lines.append( - "The knowledge digest is an internal summary of the conversation that the assistant" - ) - lines.append( - "maintains to help understand the context and key information being shared. It is" - ) + lines.append("The knowledge digest is an internal summary of the conversation that the assistant") + lines.append("maintains to help understand the context and key information being shared. It is") lines.append("automatically updated as the conversation progresses.") lines.append("") @@ -94,9 +78,7 @@ async def _format_debug_info( lines.append("```") lines.append("") else: - lines.append( - "_No knowledge digest has been generated yet. The assistant will create and update_" - ) + lines.append("_No knowledge digest has been generated yet. The assistant will create and update_") lines.append("_this automatically as the conversation develops._") lines.append("") @@ -113,12 +95,8 @@ async def _format_debug_info( lines.append("## Share Metadata") lines.append("") lines.append(f"- **Share ID:** `{share_id}`") - lines.append( - f"- **Created:** {share.created_at.strftime('%Y-%m-%d %H:%M:%S')}" - ) - lines.append( - f"- **Last Updated:** {share.updated_at.strftime('%Y-%m-%d %H:%M:%S')}" - ) + lines.append(f"- **Created:** {share.created_at.strftime('%Y-%m-%d %H:%M:%S')}") + lines.append(f"- **Last Updated:** {share.updated_at.strftime('%Y-%m-%d %H:%M:%S')}") lines.append( f"- **Team Conversations:** {len(share.team_conversations) if share.team_conversations else 0}" ) @@ -126,16 +104,10 @@ async def _format_debug_info( f"- **Learning Objectives:** {len(share.learning_objectives) if share.learning_objectives else 0}" ) lines.append(f"- **Knowledge Organized:** {share.knowledge_organized}") - lines.append( - f"- **Ready for Transfer:** {TransferManager.is_ready_for_transfer(share)}" - ) - lines.append( - f"- **Actively Sharing:** {TransferManager.is_actively_sharing(share)}" - ) + lines.append(f"- **Ready for Transfer:** {TransferManager.is_ready_for_transfer(share)}") + lines.append(f"- **Actively Sharing:** {TransferManager.is_actively_sharing(share)}") if share.coordinator_conversation_id: - lines.append( - f"- **Conversation ID:** `{share.coordinator_conversation_id}`" - ) + lines.append(f"- **Conversation ID:** `{share.coordinator_conversation_id}`") lines.append("") except Exception as e: diff --git a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/learning.py b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/learning.py index 8639737d6..bbaf16b88 100644 --- a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/learning.py +++ b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/learning.py @@ -30,9 +30,7 @@ def __init__(self, config_provider) -> None: async def is_enabled(self, context: ConversationContext) -> bool: return True - async def get( - self, context: ConversationContext - ) -> AssistantConversationInspectorStateDataModel: + async def get(self, context: ConversationContext) -> AssistantConversationInspectorStateDataModel: """Get learning objectives and progress information.""" conversation_role = await ShareManager.get_conversation_role(context) @@ -41,9 +39,7 @@ async def get( share_id = await ShareManager.get_share_id(context) if not share_id: return AssistantConversationInspectorStateDataModel( - data={ - "content": "No active knowledge package. Start a conversation to create one." - } + data={"content": "No active knowledge package. Start a conversation to create one."} ) share = await ShareManager.get_share(context) @@ -55,9 +51,7 @@ async def get( return AssistantConversationInspectorStateDataModel(data={"content": markdown}) - async def _format_coordinator_objectives( - self, share: Any, context: ConversationContext - ) -> str: + async def _format_coordinator_objectives(self, share: Any, context: ConversationContext) -> str: """Format learning objectives for coordinator.""" lines: List[str] = [] @@ -75,22 +69,12 @@ async def _format_coordinator_objectives( lines.append("") # Overall progress summary - total_outcomes = sum( - len(obj.learning_outcomes) - for obj in share.learning_objectives - if obj.learning_outcomes - ) + total_outcomes = sum(len(obj.learning_outcomes) for obj in share.learning_objectives if obj.learning_outcomes) if total_outcomes > 0 and share.team_conversations: for conv_id, team_conv in share.team_conversations.items(): - achieved, total = ( - LearningObjectivesManager.get_completion_for_conversation( - share, conv_id - ) - ) + achieved, total = LearningObjectivesManager.get_completion_for_conversation(share, conv_id) progress_pct = int((achieved / total * 100)) if total > 0 else 0 - lines.append( - f"- **{team_conv.redeemer_name}**: {achieved}/{total} outcomes ({progress_pct}%)" - ) + lines.append(f"- **{team_conv.redeemer_name}**: {achieved}/{total} outcomes ({progress_pct}%)") lines.append("") # Detailed objectives @@ -105,9 +89,7 @@ async def _format_coordinator_objectives( for criterion in objective.learning_outcomes: # Check if any team conversation has achieved this outcome achieved_by_any = any( - LearningObjectivesManager.is_outcome_achieved_by_conversation( - share, criterion.id, conv_id - ) + LearningObjectivesManager.is_outcome_achieved_by_conversation(share, criterion.id, conv_id) for conv_id in share.team_conversations.keys() ) status_emoji = "✅" if achieved_by_any else "⬜" @@ -117,25 +99,19 @@ async def _format_coordinator_objectives( total_team_count = len(share.team_conversations) for conv_id in share.team_conversations.keys(): - if LearningObjectivesManager.is_outcome_achieved_by_conversation( - share, criterion.id, conv_id - ): + if LearningObjectivesManager.is_outcome_achieved_by_conversation(share, criterion.id, conv_id): achieved_count += 1 achievement_info = "" if total_team_count > 0: achievement_info = f" ({achieved_count}/{total_team_count})" - lines.append( - f"- {status_emoji} {criterion.description}{achievement_info}" - ) + lines.append(f"- {status_emoji} {criterion.description}{achievement_info}") lines.append("") return "\n".join(lines) - async def _format_team_objectives( - self, share: Any, context: ConversationContext - ) -> str: + async def _format_team_objectives(self, share: Any, context: ConversationContext) -> str: """Format learning objectives for team members.""" lines: List[str] = [] @@ -154,17 +130,11 @@ async def _format_team_objectives( # Show my personal progress conversation_id = str(context.id) - achieved_outcomes, total_outcomes = ( - LearningObjectivesManager.get_completion_for_conversation( - share, conversation_id - ) - ) - progress_pct = ( - int((achieved_outcomes / total_outcomes * 100)) if total_outcomes > 0 else 0 - ) - lines.append( - f"**My Progress:** {achieved_outcomes}/{total_outcomes} outcomes achieved ({progress_pct}%)" + achieved_outcomes, total_outcomes = LearningObjectivesManager.get_completion_for_conversation( + share, conversation_id ) + progress_pct = int((achieved_outcomes / total_outcomes * 100)) if total_outcomes > 0 else 0 + lines.append(f"**My Progress:** {achieved_outcomes}/{total_outcomes} outcomes achieved ({progress_pct}%)") lines.append("") for objective in share.learning_objectives: @@ -176,32 +146,23 @@ async def _format_team_objectives( lines.append("#### Learning Outcomes") for criterion in objective.learning_outcomes: # Check if I've achieved this outcome - achieved_by_me = ( - LearningObjectivesManager.is_outcome_achieved_by_conversation( - share, criterion.id, conversation_id - ) + achieved_by_me = LearningObjectivesManager.is_outcome_achieved_by_conversation( + share, criterion.id, conversation_id ) status_emoji = "✅" if achieved_by_me else "⬜" completion_info = "" if achieved_by_me: # Find my achievement record - my_achievements = ( - LearningObjectivesManager.get_achievements_for_conversation( - share, conversation_id - ) + my_achievements = LearningObjectivesManager.get_achievements_for_conversation( + share, conversation_id ) for achievement in my_achievements: - if ( - achievement.outcome_id == criterion.id - and achievement.achieved - ): + if achievement.outcome_id == criterion.id and achievement.achieved: completion_info = f" (achieved on {achievement.achieved_at.strftime('%Y-%m-%d')})" break - lines.append( - f"- {status_emoji} {criterion.description}{completion_info}" - ) + lines.append(f"- {status_emoji} {criterion.description}{completion_info}") lines.append("") return "\n".join(lines) diff --git a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/sharing.py b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/sharing.py index b32544c8b..b2140a16e 100644 --- a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/sharing.py +++ b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/sharing.py @@ -36,9 +36,7 @@ def __init__(self, config_provider) -> None: async def is_enabled(self, context: ConversationContext) -> bool: return True - async def get( - self, context: ConversationContext - ) -> AssistantConversationInspectorStateDataModel: + async def get(self, context: ConversationContext) -> AssistantConversationInspectorStateDataModel: """Get information requests for display.""" conversation_role = await ShareManager.get_conversation_role(context) @@ -47,9 +45,7 @@ async def get( share_id = await ShareManager.get_share_id(context) if not share_id: return AssistantConversationInspectorStateDataModel( - data={ - "content": "No active knowledge package. Start a conversation to create one." - } + data={"content": "No active knowledge package. Start a conversation to create one."} ) requests = await InformationRequestManager.get_information_requests(context) @@ -61,9 +57,7 @@ async def get( return AssistantConversationInspectorStateDataModel(data={"content": markdown}) - async def _format_coordinator_requests( - self, requests: List[Any], context: ConversationContext - ) -> str: + async def _format_coordinator_requests(self, requests: List[Any], context: ConversationContext) -> str: """Format sharing information and requests for coordinator.""" lines: List[str] = [] @@ -77,18 +71,12 @@ async def _format_coordinator_requests( lines.append("**Share this link with your team members:**") lines.append(f"[Knowledge Transfer link]({share_url})") lines.append("") - lines.append( - "The link never expires and can be used by multiple team members." - ) + lines.append("The link never expires and can be used by multiple team members.") lines.append("") # Filter requests by status - pending_requests = [ - req for req in requests if req.status != RequestStatus.RESOLVED - ] - resolved_requests = [ - req for req in requests if req.status == RequestStatus.RESOLVED - ] + pending_requests = [req for req in requests if req.status != RequestStatus.RESOLVED] + resolved_requests = [req for req in requests if req.status == RequestStatus.RESOLVED] lines.append("## Information Requests") lines.append("") @@ -119,9 +107,7 @@ async def _format_coordinator_requests( if not pending_requests and not resolved_requests: lines.append("No information requests yet.") lines.append("") - lines.append( - "_Team members can ask questions and create information requests that will appear here._" - ) + lines.append("_Team members can ask questions and create information requests that will appear here._") # Team summary if share and share.team_conversations: @@ -130,28 +116,16 @@ async def _format_coordinator_requests( lines.append("") for conv_id, team_conv in share.team_conversations.items(): - achieved, total = ( - LearningObjectivesManager.get_completion_for_conversation( - share, conv_id - ) - ) + achieved, total = LearningObjectivesManager.get_completion_for_conversation(share, conv_id) progress_pct = int((achieved / total * 100)) if total > 0 else 0 - lines.append( - f"- **{team_conv.redeemer_name}**: {achieved}/{total} outcomes ({progress_pct}%)" - ) - lines.append( - f" Joined: {team_conv.joined_at.strftime('%Y-%m-%d %H:%M')}" - ) - lines.append( - f" Last active: {team_conv.last_active_at.strftime('%Y-%m-%d %H:%M')}" - ) + lines.append(f"- **{team_conv.redeemer_name}**: {achieved}/{total} outcomes ({progress_pct}%)") + lines.append(f" Joined: {team_conv.joined_at.strftime('%Y-%m-%d %H:%M')}") + lines.append(f" Last active: {team_conv.last_active_at.strftime('%Y-%m-%d %H:%M')}") lines.append("") return "\n".join(lines) - async def _format_team_requests( - self, requests: List[Any], context: ConversationContext - ) -> str: + async def _format_team_requests(self, requests: List[Any], context: ConversationContext) -> str: """Format sharing information and requests for team members.""" lines: List[str] = [] diff --git a/assistants/knowledge-transfer-assistant/assistant/utils.py b/assistants/knowledge-transfer-assistant/assistant/utils.py index fa86bdf23..bbd0c4198 100644 --- a/assistants/knowledge-transfer-assistant/assistant/utils.py +++ b/assistants/knowledge-transfer-assistant/assistant/utils.py @@ -74,9 +74,7 @@ async def get_current_user_id(context: ConversationContext) -> Optional[str]: return user_id -async def require_current_user( - context: ConversationContext, operation_name: str -) -> Optional[str]: +async def require_current_user(context: ConversationContext, operation_name: str) -> Optional[str]: """ Extract the current user ID and log an error if none is found. diff --git a/assistants/knowledge-transfer-assistant/tests/test_artifact_loading.py b/assistants/knowledge-transfer-assistant/tests/test_artifact_loading.py index 27dd271d8..6db5fe71f 100644 --- a/assistants/knowledge-transfer-assistant/tests/test_artifact_loading.py +++ b/assistants/knowledge-transfer-assistant/tests/test_artifact_loading.py @@ -10,8 +10,6 @@ import uuid from typing import Any, TypeVar -from semantic_workbench_assistant import settings - from assistant.data import ( ConversationRole, KnowledgeBrief, @@ -21,6 +19,7 @@ ) from assistant.domain import KnowledgeBriefManager, ShareManager from assistant.storage import ShareStorage, ShareStorageManager +from semantic_workbench_assistant import settings # Type variable for better type annotations T = TypeVar("T") @@ -31,9 +30,7 @@ class TestShareStorage(unittest.IsolatedAsyncioTestCase): async def asyncSetUp(self): # Create a test storage path - self.test_dir = ( - pathlib.Path(__file__).parent.parent / ".data" / "test_project_storage" - ) + self.test_dir = pathlib.Path(__file__).parent.parent / ".data" / "test_project_storage" self.test_dir.mkdir(exist_ok=True, parents=True) # Mock settings to use our test directory @@ -76,9 +73,7 @@ def mock_storage_directory_for_context(context, *args, **kwargs): async def mock_get_share_id(context): return self.share_id - patch2 = unittest.mock.patch.object( - ShareManager, "get_share_id", side_effect=mock_get_share_id - ) + patch2 = unittest.mock.patch.object(ShareManager, "get_share_id", side_effect=mock_get_share_id) self.mock_get_project = patch2.start() self.patches.append(patch2) @@ -141,9 +136,7 @@ def create_test_brief(self): async def test_get_project_brief(self) -> None: """Test that get_project_brief correctly loads the brief from storage""" # Mock the KnowledgeTransferManager to use our test context - with unittest.mock.patch.object( - ShareManager, "get_share_id", return_value=self.share_id - ): + with unittest.mock.patch.object(ShareManager, "get_share_id", return_value=self.share_id): # Using Any here to satisfy type checker with our mock context: Any = self.context @@ -158,9 +151,7 @@ async def test_get_project_brief(self) -> None: # Verify the project goals were loaded correctly self.assertIsNotNone(project, "Should load the project") if project: # Type checking guard - self.assertEqual( - len(project.learning_objectives), 1, "Should have one goal" - ) + self.assertEqual(len(project.learning_objectives), 1, "Should have one goal") self.assertEqual(project.learning_objectives[0].name, "Test Goal") async def test_direct_storage_access(self) -> None: diff --git a/assistants/knowledge-transfer-assistant/tests/test_inspector.py b/assistants/knowledge-transfer-assistant/tests/test_inspector.py index 0f9434e7b..3478cb0ed 100644 --- a/assistants/knowledge-transfer-assistant/tests/test_inspector.py +++ b/assistants/knowledge-transfer-assistant/tests/test_inspector.py @@ -6,11 +6,10 @@ import logging from unittest.mock import AsyncMock, MagicMock +from assistant.assistant import assistant from semantic_workbench_api_model.workbench_model import AssistantStateEvent from semantic_workbench_assistant.assistant_app import ConversationContext -from assistant.assistant import assistant - # Set up logging logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) @@ -45,9 +44,7 @@ async def test_inspector(): logger.info(f"Testing {inspector_id} inspector...") # Create state event - state_event = AssistantStateEvent( - state_id=inspector_id, event="focus", state=None - ) + state_event = AssistantStateEvent(state_id=inspector_id, event="focus", state=None) # Send event logger.info("Sending state event...") diff --git a/assistants/knowledge-transfer-assistant/tests/test_share_manager.py b/assistants/knowledge-transfer-assistant/tests/test_share_manager.py index 99fcc2469..3184d9f8e 100644 --- a/assistants/knowledge-transfer-assistant/tests/test_share_manager.py +++ b/assistants/knowledge-transfer-assistant/tests/test_share_manager.py @@ -5,14 +5,13 @@ from unittest.mock import AsyncMock, MagicMock import pytest -from semantic_workbench_assistant.assistant_app import ConversationContext - from assistant.data import ( KnowledgePackage, LearningObjective, LearningOutcome, ) from assistant.domain import LearningObjectivesManager +from semantic_workbench_assistant.assistant_app import ConversationContext class TestKnowledgeTransferManager: @@ -136,9 +135,7 @@ def mock_write_share_info(proj_id, package): nonlocal write_project_info_called assert proj_id == project_id # Verify package was updated - assert ( - package.achieved_outcomes == 0 - ) # Completed criterion was in the deleted goal + assert package.achieved_outcomes == 0 # Completed criterion was in the deleted goal assert package.total_outcomes == 0 # All criteria were in the deleted goal # Note: completion_percentage removed from model assert package.version == 2 # Incremented @@ -189,9 +186,7 @@ async def mock_refresh_all_project_uis(*args, **kwargs): ( success, goal_name_result, - ) = await LearningObjectivesManager.delete_learning_objective( - context, objective_index - ) + ) = await LearningObjectivesManager.delete_learning_objective(context, objective_index) # Verify the result assert success is True @@ -206,9 +201,7 @@ async def mock_refresh_all_project_uis(*args, **kwargs): # DISABLED: delete_project_goal functionality has been removed from the codebase # @pytest.mark.asyncio - async def disabled_test_delete_project_goal_invalid_index( - self, context, monkeypatch - ): + async def disabled_test_delete_project_goal_invalid_index(self, context, monkeypatch): """Test deleting a goal with an invalid index.""" # Setup project_id = "test-project-id" @@ -269,9 +262,7 @@ def mock_read_project(proj_id): ( success, error_message, - ) = await LearningObjectivesManager.delete_learning_objective( - context, objective_index - ) + ) = await LearningObjectivesManager.delete_learning_objective(context, objective_index) # Verify the result indicates failure with appropriate error message assert success is False @@ -296,9 +287,7 @@ async def mock_get_project_id(*args, **kwargs): ( success, error_message, - ) = await LearningObjectivesManager.delete_learning_objective( - context, "test-objective-id" - ) + ) = await LearningObjectivesManager.delete_learning_objective(context, "test-objective-id") # Verify the result indicates failure with appropriate error message assert success is False diff --git a/assistants/knowledge-transfer-assistant/tests/test_share_storage.py b/assistants/knowledge-transfer-assistant/tests/test_share_storage.py index 80fe0aef9..d17546be5 100644 --- a/assistants/knowledge-transfer-assistant/tests/test_share_storage.py +++ b/assistants/knowledge-transfer-assistant/tests/test_share_storage.py @@ -9,9 +9,6 @@ import uuid from datetime import datetime -from semantic_workbench_api_model.workbench_model import AssistantStateEvent -from semantic_workbench_assistant import settings - from assistant.data import ( ConversationRole, CoordinatorConversationMessage, @@ -32,6 +29,8 @@ from assistant.domain.share_manager import ShareManager from assistant.notifications import Notifications from assistant.storage import ShareStorage, ShareStorageManager +from semantic_workbench_api_model.workbench_model import AssistantStateEvent +from semantic_workbench_assistant import settings class TestShareStorage(unittest.IsolatedAsyncioTestCase): @@ -40,9 +39,7 @@ class TestShareStorage(unittest.IsolatedAsyncioTestCase): async def asyncSetUp(self): """Set up test environment.""" # Create a test directory - self.test_dir = ( - pathlib.Path(__file__).parent.parent / ".data" / "test_project_storage" - ) + self.test_dir = pathlib.Path(__file__).parent.parent / ".data" / "test_project_storage" self.test_dir.mkdir(parents=True, exist_ok=True) # Mock settings to use our test directory @@ -82,9 +79,7 @@ async def asyncSetUp(self): # Mock get_participants with the correct structure participants_mock = unittest.mock.MagicMock() participants_mock.participants = [] - self.context.get_participants = unittest.mock.AsyncMock( - return_value=participants_mock - ) + self.context.get_participants = unittest.mock.AsyncMock(return_value=participants_mock) # Patch storage_directory_for_context def mock_storage_directory_for_context(context, *args, **kwargs): @@ -224,22 +219,16 @@ async def test_write_project_log(self): self.assertIsNotNone(log, "Should load the log") if log: # Type checking guard self.assertEqual(len(log.entries), 1) - self.assertEqual( - log.entries[0].entry_type, LogEntryType.SHARE_INFORMATION_UPDATE - ) + self.assertEqual(log.entries[0].entry_type, LogEntryType.SHARE_INFORMATION_UPDATE) self.assertEqual(log.entries[0].message, "Test log entry") async def test_project_directory_structure(self): """Test the project directory structure.""" # Verify project directory exists - self.assertTrue( - self.project_dir.exists(), "KnowledgePackage directory should exist" - ) + self.assertTrue(self.project_dir.exists(), "KnowledgePackage directory should exist") # Verify Coordinator directory exists - self.assertTrue( - self.coordinator_dir.exists(), "Coordinator directory should exist" - ) + self.assertTrue(self.coordinator_dir.exists(), "Coordinator directory should exist") # Verify team directory exists self.assertTrue(self.team_dir.exists(), "Team directory should exist") @@ -306,9 +295,7 @@ async def test_append_coordinator_message(self): storage = ShareStorage.read_coordinator_conversation(self.share_id) # Verify messages were added - self.assertIsNotNone( - storage, "Should create and load the coordinator conversation" - ) + self.assertIsNotNone(storage, "Should create and load the coordinator conversation") if storage: self.assertEqual(len(storage.messages), 2) self.assertEqual(storage.messages[0].content, "First message") @@ -419,15 +406,11 @@ async def test_knowledge_package_info(self): updated_package = ShareStorage.read_share(self.share_id) # Verify updates were saved - self.assertIsNotNone( - updated_package, "Should load updated knowledge package" - ) + self.assertIsNotNone(updated_package, "Should load updated knowledge package") if updated_package: self.assertEqual(updated_package.transfer_notes, "Test status message") # Note: completion_percentage removed from model - self.assertEqual( - updated_package.next_learning_actions, ["Action 1", "Action 2"] - ) + self.assertEqual(updated_package.next_learning_actions, ["Action 1", "Action 2"]) async def test_conversation_tracking_in_json(self): """Test that conversations are tracked in JSON instead of file system.""" @@ -440,26 +423,18 @@ async def test_conversation_tracking_in_json(self): self.assertIsInstance(package.team_conversations, dict) # Verify helper methods work - linked_conversations = await ShareManager.get_linked_conversations( - self.context - ) + linked_conversations = await ShareManager.get_linked_conversations(self.context) self.assertIsInstance(linked_conversations, list) async def test_conversation_association(self): """Test conversation role setting.""" # Mock ShareManager.set_conversation_role - with unittest.mock.patch( - "assistant.domain.share_manager.write_model" - ) as mock_write_model: + with unittest.mock.patch("assistant.domain.share_manager.write_model") as mock_write_model: # Mock conversation role path - conversation_role_file = ( - ShareStorageManager.get_conversation_role_file_path(self.context) - ) + conversation_role_file = ShareStorageManager.get_conversation_role_file_path(self.context) # Call set_conversation_role - await ShareManager.set_conversation_role( - self.context, self.share_id, ConversationRole.COORDINATOR - ) + await ShareManager.set_conversation_role(self.context, self.share_id, ConversationRole.COORDINATOR) # Verify write_model was called mock_write_model.assert_called_once() @@ -500,9 +475,7 @@ async def test_log_project_event(self): for entry in read_log.entries: if entry.message == "Test direct log entry": found_entry = True - self.assertEqual( - entry.entry_type, LogEntryType.SHARE_INFORMATION_UPDATE - ) + self.assertEqual(entry.entry_type, LogEntryType.SHARE_INFORMATION_UPDATE) self.assertEqual(entry.user_id, self.user_id) self.assertEqual(entry.user_name, "Test User") self.assertEqual(entry.related_entity_id, "test-entity-id") diff --git a/assistants/knowledge-transfer-assistant/tests/test_share_tools.py b/assistants/knowledge-transfer-assistant/tests/test_share_tools.py index feea3311c..6c79a0682 100644 --- a/assistants/knowledge-transfer-assistant/tests/test_share_tools.py +++ b/assistants/knowledge-transfer-assistant/tests/test_share_tools.py @@ -7,11 +7,10 @@ import openai_client import pytest -from semantic_workbench_assistant.assistant_app import ConversationContext - from assistant.agentic.analysis import detect_information_request_needs from assistant.data import ConversationRole from assistant.tools import ShareTools +from semantic_workbench_assistant.assistant_app import ConversationContext # Use pytest for all tests for consistency @@ -41,20 +40,11 @@ def test_initialization(self, context): # Verify Coordinator-specific functions are registered assert "update_brief" in coordinator_tools.tool_functions.function_map - assert ( - "resolve_information_request" - in coordinator_tools.tool_functions.function_map - ) + assert "resolve_information_request" in coordinator_tools.tool_functions.function_map # Verify Team-specific functions are NOT registered - assert ( - "create_information_request" - not in coordinator_tools.tool_functions.function_map - ) - assert ( - "delete_information_request" - not in coordinator_tools.tool_functions.function_map - ) + assert "create_information_request" not in coordinator_tools.tool_functions.function_map + assert "delete_information_request" not in coordinator_tools.tool_functions.function_map # Test Team role team_tools = ShareTools(context, ConversationRole.TEAM) @@ -67,15 +57,10 @@ def test_initialization(self, context): # Verify Coordinator-specific functions are NOT registered assert "update_brief" not in team_tools.tool_functions.function_map - assert ( - "resolve_information_request" not in team_tools.tool_functions.function_map - ) + assert "resolve_information_request" not in team_tools.tool_functions.function_map # detect_information_request_needs is not exposed as a tool function anymore - assert ( - "detect_information_request_needs" - not in team_tools.tool_functions.function_map - ) + assert "detect_information_request_needs" not in team_tools.tool_functions.function_map @pytest.mark.asyncio async def test_project_tools_with_config(self, context, monkeypatch): @@ -133,15 +118,11 @@ async def check_tools_with_config(context, role): return tools # Get the tools using our function that checks track_progress - project_tools = await check_tools_with_config( - context, ConversationRole.COORDINATOR - ) + project_tools = await check_tools_with_config(context, ConversationRole.COORDINATOR) # Verify basic tools are still available regardless of track_progress setting assert "update_brief" in project_tools.tool_functions.function_map - assert ( - "resolve_information_request" in project_tools.tool_functions.function_map - ) + assert "resolve_information_request" in project_tools.tool_functions.function_map # For team tools team_tools = await check_tools_with_config(context, ConversationRole.TEAM) @@ -162,9 +143,7 @@ async def test_detect_information_request_needs(self, context, monkeypatch): # Setup mock config to be returned from assistant_config.get mock_config = MagicMock() mock_config.track_progress = True - mock_config.service_config = ( - None # Will cause the method to return early with error info - ) + mock_config.service_config = None # Will cause the method to return early with error info async def mock_get_config(*args, **kwargs): return mock_config @@ -172,9 +151,7 @@ async def mock_get_config(*args, **kwargs): # Patch assistant_config.get mock_assistant_config = MagicMock() mock_assistant_config.get = AsyncMock(side_effect=mock_get_config) - monkeypatch.setattr( - "assistant.agentic.analysis.assistant_config", mock_assistant_config - ) + monkeypatch.setattr("assistant.agentic.analysis.assistant_config", mock_assistant_config) # Create a mock message for the message history mock_msg = MagicMock() @@ -270,9 +247,7 @@ async def mock_delete_project_goal(*args, **kwargs): # assert f"Goal '{goal_name}' has been successfully deleted from the project." in result # Verify that context.send_messages was called with appropriate message - expected_message_content = ( - f"Goal '{goal_name}' has been successfully deleted from the project." - ) + expected_message_content = f"Goal '{goal_name}' has been successfully deleted from the project." context.send_messages.assert_called_once() # Get the first positional argument passed to send_messages call_args = context.send_messages.call_args[0][0] diff --git a/assistants/knowledge-transfer-assistant/tests/test_team_mode.py b/assistants/knowledge-transfer-assistant/tests/test_team_mode.py index 3ee7704b9..0111cafbb 100644 --- a/assistants/knowledge-transfer-assistant/tests/test_team_mode.py +++ b/assistants/knowledge-transfer-assistant/tests/test_team_mode.py @@ -89,9 +89,7 @@ def __init__( class InformationRequest(BaseArtifact): - def __init__( - self, title=None, description=None, priority=None, status=None, **kwargs - ): + def __init__(self, title=None, description=None, priority=None, status=None, **kwargs): super().__init__(artifact_type=ArtifactType.INFORMATION_REQUEST, **kwargs) self.title = title or "Test Request" self.description = description or "Test Description" @@ -131,9 +129,7 @@ def __init__(self, id=None, description=None, completed=False, completed_by=None class LearningObjective: - def __init__( - self, id=None, name=None, description=None, priority=1, success_criteria=None - ): + def __init__(self, id=None, name=None, description=None, priority=1, success_criteria=None): self.id = id or "test-goal-id" self.name = name or "Test Goal" self.description = description or "Test Goal Description" @@ -155,9 +151,7 @@ def __init__(self, context): self.context = context self.log_action = AsyncMock() - async def create_information_request( - self, title, description, priority=RequestPriority.MEDIUM - ): + async def create_information_request(self, title, description, priority=RequestPriority.MEDIUM): # Mock implementation request = InformationRequest( title=title, @@ -200,9 +194,7 @@ async def update_project_info(self, progress_percentage, status_message=None): dashboard.transfer_notes = status_message # Call mocked log_action for state change and progress update - await self.log_action( - LogEntryType.MILESTONE_PASSED, "KnowledgePackage is now in progress" - ) + await self.log_action(LogEntryType.MILESTONE_PASSED, "KnowledgePackage is now in progress") await self.log_action( LogEntryType.STATUS_CHANGED, @@ -270,9 +262,7 @@ async def report_project_completion(self, completion_summary): dashboard.transfer_notes = completion_summary # Call mocked log_action - await self.log_action( - LogEntryType.PROJECT_COMPLETED, "KnowledgePackage marked as completed" - ) + await self.log_action(LogEntryType.PROJECT_COMPLETED, "KnowledgePackage marked as completed") # Send notification await self.context.send_messages( @@ -355,9 +345,7 @@ async def test_create_information_request(self, team_handler, mock_context): async def test_update_project_info(self, team_handler, mock_context): """Test updating the project information.""" # Call the method - success, message, dashboard = await team_handler.update_project_info( - 50, "Making progress in the team" - ) + success, message, dashboard = await team_handler.update_project_info(50, "Making progress in the team") # Assertions assert success is True @@ -381,9 +369,7 @@ async def test_mark_criterion_completed(self, team_handler, mock_context): success, message, updated_dashboard, - ) = await team_handler.mark_criterion_completed( - "test-goal-id", "test-criterion-id" - ) + ) = await team_handler.mark_criterion_completed("test-goal-id", "test-criterion-id") # Assertions assert success is True @@ -414,8 +400,7 @@ async def test_report_project_completion(self, team_handler, mock_context): assert dashboard.state == ProjectState.COMPLETED # Note: completion_percentage removed from model assert ( - dashboard.transfer_notes - == "KnowledgePackage has been successfully completed with all objectives achieved." + dashboard.transfer_notes == "KnowledgePackage has been successfully completed with all objectives achieved." ) # Verify that a notification was sent From 24c32505cf6be821bd6a1f12bb521afcd18376c6 Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Tue, 29 Jul 2025 11:58:33 -0700 Subject: [PATCH 09/21] Lint --- .../.vscode/settings.json | 6 + .../assistant/agentic/analysis.py | 30 ++- .../assistant/agentic/coordinator_support.py | 20 +- .../assistant/agentic/team_welcome.py | 18 +- .../assistant/assistant.py | 178 +++++++++++------- .../assistant/config.py | 4 +- .../assistant/conversation_clients.py | 12 +- .../assistant/data.py | 8 +- .../assistant/domain/audience_manager.py | 8 +- .../domain/information_request_manager.py | 76 +++++--- .../domain/knowledge_brief_manager.py | 12 +- .../domain/knowledge_digest_manager.py | 41 ++-- .../domain/learning_objectives_manager.py | 104 +++++++--- .../assistant/domain/share_manager.py | 74 +++++--- .../assistant/domain/transfer_manager.py | 13 +- .../assistant/files.py | 86 ++++++--- .../assistant/logging.py | 18 +- .../assistant/notifications.py | 34 +++- .../assistant/respond.py | 143 +++++++++----- .../assistant/storage.py | 12 +- .../assistant/string_utils.py | 8 +- .../assistant/tools/information_requests.py | 30 ++- .../assistant/tools/learning_objectives.py | 16 +- .../assistant/tools/learning_outcomes.py | 26 ++- .../assistant/tools/progress_tracking.py | 34 +++- .../assistant/tools/share_setup.py | 10 +- .../assistant/ui_tabs/brief.py | 24 ++- .../assistant/ui_tabs/common.py | 10 +- .../assistant/ui_tabs/debug.py | 56 ++++-- .../assistant/ui_tabs/learning.py | 79 ++++++-- .../assistant/ui_tabs/sharing.py | 50 +++-- .../assistant/utils.py | 4 +- .../pyproject.toml | 19 ++ .../tests/test_artifact_loading.py | 19 +- .../tests/test_inspector.py | 7 +- .../tests/test_share_manager.py | 23 ++- .../tests/test_share_storage.py | 57 ++++-- .../tests/test_share_tools.py | 47 +++-- .../tests/test_team_mode.py | 31 ++- 39 files changed, 1050 insertions(+), 397 deletions(-) diff --git a/assistants/knowledge-transfer-assistant/.vscode/settings.json b/assistants/knowledge-transfer-assistant/.vscode/settings.json index 39a26c721..fc40a34c4 100644 --- a/assistants/knowledge-transfer-assistant/.vscode/settings.json +++ b/assistants/knowledge-transfer-assistant/.vscode/settings.json @@ -58,6 +58,9 @@ "uv.lock" ], "cSpell.words": [ + "addopts", + "asctime", + "asyncio", "Codespaces", "contentsafety", "debugpy", @@ -70,11 +73,13 @@ "GIPHY", "jsonschema", "Langchain", + "levelname", "modelcontextprotocol", "moderations", "mzxrai", "openai", "pdfplumber", + "pycache", "pydantic", "pyproject", "pyright", @@ -83,6 +88,7 @@ "semanticworkbenchteam", "tiktoken", "updown", + "venv", "virtualenvs", "webresearch" ] diff --git a/assistants/knowledge-transfer-assistant/assistant/agentic/analysis.py b/assistants/knowledge-transfer-assistant/assistant/agentic/analysis.py index 14125accb..f2e942d95 100644 --- a/assistants/knowledge-transfer-assistant/assistant/agentic/analysis.py +++ b/assistants/knowledge-transfer-assistant/assistant/agentic/analysis.py @@ -16,7 +16,9 @@ from assistant.logging import logger -async def detect_information_request_needs(context: ConversationContext, message: str) -> Dict[str, Any]: +async def detect_information_request_needs( + context: ConversationContext, message: str +) -> Dict[str, Any]: """ Analyze a user message in context of recent chat history to detect potential information request needs. Uses an LLM for sophisticated detection. @@ -73,7 +75,9 @@ async def detect_information_request_needs(context: ConversationContext, message # Add to chat history role = "user" if sender_name == "Team Member" else "assistant" - chat_history.append({"role": role, "content": f"{sender_name}: {msg.content}"}) + chat_history.append( + {"role": role, "content": f"{sender_name}: {msg.content}"} + ) # Reverse to get chronological order chat_history.reverse() @@ -95,13 +99,17 @@ async def detect_information_request_needs(context: ConversationContext, message # Add chat history if available if chat_history: for history_msg in chat_history: - messages.append({"role": history_msg["role"], "content": history_msg["content"]}) + messages.append( + {"role": history_msg["role"], "content": history_msg["content"]} + ) # Add the current message for analysis - explicitly mark as the latest message - messages.append({ - "role": "user", - "content": f"Latest message from Team Member: {message}", - }) + messages.append( + { + "role": "user", + "content": f"Latest message from Team Member: {message}", + } + ) completion_args = { "model": "gpt-3.5-turbo", @@ -110,7 +118,9 @@ async def detect_information_request_needs(context: ConversationContext, message "max_tokens": 500, "temperature": 0.2, # Low temperature for more consistent analysis } - debug["completion_args"] = openai_client.make_completion_args_serializable(completion_args) + debug["completion_args"] = openai_client.make_completion_args_serializable( + completion_args + ) # Make the API call response = await client.chat.completions.create( @@ -126,7 +136,9 @@ async def detect_information_request_needs(context: ConversationContext, message result["original_message"] = message return result except json.JSONDecodeError: - logger.warning(f"Failed to parse JSON from LLM response: {response.choices[0].message.content}") + logger.warning( + f"Failed to parse JSON from LLM response: {response.choices[0].message.content}" + ) return { "is_information_request": False, "reason": "Failed to parse LLM response", diff --git a/assistants/knowledge-transfer-assistant/assistant/agentic/coordinator_support.py b/assistants/knowledge-transfer-assistant/assistant/agentic/coordinator_support.py index d59a43063..35ceefa0f 100644 --- a/assistants/knowledge-transfer-assistant/assistant/agentic/coordinator_support.py +++ b/assistants/knowledge-transfer-assistant/assistant/agentic/coordinator_support.py @@ -61,7 +61,10 @@ async def get_coordinator_next_action_suggestion( return "Your package needs a short introduction that will orient your team. Let's write a knowledge brief next. The knowledge brief helps your team understand the purpose of this knowledge transfer and will be visible to all team members in their side panel." # 5. If intended to have outcomes but none defined yet - if package.is_intended_to_accomplish_outcomes and not package.learning_objectives: + if ( + package.is_intended_to_accomplish_outcomes + and not package.learning_objectives + ): return ( "Would you like your team to achieve any specific outcomes? If so, let's define some learning objectives. " "If not, you can mark this package as 'exploratory' instead." @@ -69,18 +72,27 @@ async def get_coordinator_next_action_suggestion( # 6. Objectives exist, but missing outcomes if package.is_intended_to_accomplish_outcomes: - incomplete_objectives = [obj for obj in package.learning_objectives if not obj.learning_outcomes] + incomplete_objectives = [ + obj + for obj in package.learning_objectives + if not obj.learning_outcomes + ] if incomplete_objectives: name = incomplete_objectives[0].name return f"The learning objective '{name}' doesn't have any outcomes yet. Let's define what your team should accomplish to meet it." # 7. Ready for transfer but not yet shared - if TransferManager.is_ready_for_transfer(package) and not TransferManager.is_actively_sharing(package): + if TransferManager.is_ready_for_transfer( + package + ) and not TransferManager.is_actively_sharing(package): return "Your knowledge package is ready to share. Would you like to create a message and generate the invitation link?" # 8. Actively sharing - monitor and support ongoing transfer if TransferManager.is_actively_sharing(package): - if package.is_intended_to_accomplish_outcomes and not TransferManager._is_transfer_complete(package): + if ( + package.is_intended_to_accomplish_outcomes + and not TransferManager._is_transfer_complete(package) + ): team_count = len(package.team_conversations) return f"Great! Your knowledge is being shared with {team_count} team member{'s' if team_count != 1 else ''}. You can continue improving the package or respond to information requests as they come in." else: diff --git a/assistants/knowledge-transfer-assistant/assistant/agentic/team_welcome.py b/assistants/knowledge-transfer-assistant/assistant/agentic/team_welcome.py index d81e77c9b..c607f9ba0 100644 --- a/assistants/knowledge-transfer-assistant/assistant/agentic/team_welcome.py +++ b/assistants/knowledge-transfer-assistant/assistant/agentic/team_welcome.py @@ -29,11 +29,11 @@ async def generate_team_welcome_message( share = await ShareManager.get_share(context) if not share: - logger.warning("No active knowledge package found for welcome message generation") + logger.warning( + "No active knowledge package found for welcome message generation" + ) return config.team_config.default_welcome_message, debug - share_id = share.share_id - share_data: dict[str, str] = {} # Knowledge Brief @@ -92,9 +92,13 @@ async def generate_team_welcome_message( try: # Chat completion async with openai_client.create_client(config.service_config) as client: - share_info = "\n\n## KNOWLEDGE SHARE INFORMATION\n\n" + "\n".join(share_data.values()) + share_info = "\n\n## KNOWLEDGE SHARE INFORMATION\n\n" + "\n".join( + share_data.values() + ) - instructions = f"{config.prompt_config.welcome_message_generation}\n\n{share_info}" + instructions = ( + f"{config.prompt_config.welcome_message_generation}\n\n{share_info}" + ) messages: List[ChatCompletionMessageParam] = [ {"role": "system", "content": instructions}, ] @@ -105,7 +109,9 @@ async def generate_team_welcome_message( "max_tokens": 500, "temperature": 0.7, # Low temperature for more consistent analysis } - debug["completion_args"] = openai_client.make_completion_args_serializable(completion_args) + debug["completion_args"] = openai_client.make_completion_args_serializable( + completion_args + ) # LLM call response = await client.chat.completions.create( diff --git a/assistants/knowledge-transfer-assistant/assistant/assistant.py b/assistants/knowledge-transfer-assistant/assistant/assistant.py index 3449fc3c6..3972831b0 100644 --- a/assistants/knowledge-transfer-assistant/assistant/assistant.py +++ b/assistants/knowledge-transfer-assistant/assistant/assistant.py @@ -44,7 +44,9 @@ service_id = "knowledge-transfer-assistant.made-exploration" service_name = "Knowledge Transfer Assistant" -service_description = "A mediator assistant that facilitates sharing knowledge between parties." +service_description = ( + "A mediator assistant that facilitates sharing knowledge between parties." +) async def content_evaluator_factory( @@ -76,7 +78,9 @@ async def content_evaluator_factory( template_id=DEFAULT_TEMPLATE_ID, background_color="rgb(198, 177, 222)", icon=dashboard_card.image_to_url( - pathlib.Path(__file__).parent / "assets" / "icon-knowledge-transfer.svg", + pathlib.Path(__file__).parent + / "assets" + / "icon-knowledge-transfer.svg", "image/svg+xml", ), card_content=dashboard_card.CardContent( @@ -85,9 +89,11 @@ async def content_evaluator_factory( ), ), ), - **navigator.metadata_for_assistant_navigator({ - "default": load_text_include("assistant_info.md"), - }), + **navigator.metadata_for_assistant_navigator( + { + "default": load_text_include("assistant_info.md"), + } + ), }, ) @@ -113,12 +119,47 @@ async def on_conversation_created(context: ConversationContext) -> None: conversation_type = await ShareManager.get_conversation_role(context) match conversation_type: + case ConversationRole.COORDINATOR: + try: + # In the beginning, we created a share... + share_id = await ShareManager.create_share(context) + + # And it was good. So we then created a sharable conversation that we use as a template. + share_url = await ShareManager.create_shareable_team_conversation( + context=context, share_id=share_id + ) + + welcome_message = config.coordinator_config.welcome_message.format( + share_url=share_url or "" + ) + + 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)}" + + await context.send_messages( + NewConversationMessage( + content=welcome_message, + message_type=MessageType.chat, + ) + ) + + # Pop open the inspector panel. + await context.send_conversation_state_event( + AssistantStateEvent( + state_id="brief", + event="focus", + state=None, + ) + ) + case ConversationRole.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 ShareManager.set_conversation_role(context, share_id, ConversationRole.SHAREABLE_TEMPLATE) + await ShareManager.set_conversation_role( + context, share_id, ConversationRole.SHAREABLE_TEMPLATE + ) return case ConversationRole.TEAM: @@ -134,8 +175,12 @@ async def on_conversation_created(context: ConversationContext) -> None: ) ) - await ShareManager.set_conversation_role(context, share_id, ConversationRole.TEAM) - await ShareFilesManager.synchronize_files_to_team_conversation(context=context, share_id=share_id) + await ShareManager.set_conversation_role( + context, share_id, ConversationRole.TEAM + ) + await ShareFilesManager.synchronize_files_to_team_conversation( + context=context, share_id=share_id + ) welcome_message, debug = await generate_team_welcome_message(context) await context.send_messages( @@ -160,37 +205,6 @@ async def on_conversation_created(context: ConversationContext) -> None: return - case ConversationRole.COORDINATOR: - try: - # In the beginning, we created a share... - share_id = await ShareManager.create_share(context) - - # And it was good. So we then created a sharable conversation that we use as a template. - share_url = await ShareManager.create_shareable_team_conversation(context=context, share_id=share_id) - - welcome_message = config.coordinator_config.welcome_message.format( - share_url=share_url or "" - ) - - 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)}" - - await context.send_messages( - NewConversationMessage( - content=welcome_message, - message_type=MessageType.chat, - ) - ) - - # 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: @@ -217,9 +231,13 @@ async def on_conversation_updated(context: ConversationContext) -> None: 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}'") + 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}") + 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}") @@ -247,9 +265,11 @@ async def on_message_created( # If this is a Coordinator conversation, store the message for Team access async with context.set_status("jotting..."): role = await ShareManager.get_conversation_role(context) - if role == ConversationRole.COORDINATOR and message.message_type == MessageType.chat: + if ( + role == ConversationRole.COORDINATOR + and message.message_type == MessageType.chat + ): try: - # Get the sender's name sender_name = "Coordinator" if message.sender: participants = await context.get_participants() @@ -258,29 +278,19 @@ async def on_message_created( sender_name = participant.name break - # Store the message for Team access await ShareManager.append_coordinator_message( context=context, message_id=str(message.id), content=message.content, sender_name=sender_name, - is_assistant=message.sender.participant_role == ParticipantRole.assistant, + is_assistant=message.sender.participant_role + == ParticipantRole.assistant, timestamp=message.timestamp, ) - - # If this is the coordinator's first message, pop the share canvas - messages = await context.get_messages() - if len(messages.messages) == 2: - await context.send_conversation_state_event( - AssistantStateEvent( - state_id="brief", - event="focus", - state=None, - ) - ) except Exception as e: - # Don't fail message handling if storage fails - logger.exception(f"Error storing Coordinator message for Team access: {e}") + logger.exception( + f"Error storing Coordinator message for Team access: {e}" + ) async with context.set_status("pondering..."): await respond_to_conversation( @@ -290,9 +300,15 @@ async def on_message_created( metadata=metadata, ) - # If the message is from a Coordinator, update the whiteboard in the background - if role == ConversationRole.COORDINATOR and message.message_type == MessageType.chat: - asyncio.create_task(KnowledgeDigestManager.auto_update_knowledge_digest(context)) + # If the message is from a Coordinator, update the digest in the + # background + if ( + role == ConversationRole.COORDINATOR + and message.message_type == MessageType.chat + ): + asyncio.create_task( + KnowledgeDigestManager.auto_update_knowledge_digest(context) + ) except Exception as e: logger.exception(f"Error handling message: {e}") @@ -314,9 +330,13 @@ async def on_command_created( if message.message_type != MessageType.command: return - await context.update_participant_me(UpdateParticipant(status="processing command...")) + await context.update_participant_me( + UpdateParticipant(status="processing command...") + ) try: - metadata = {"debug": {"content_safety": event.data.get(content_safety.metadata_key, {})}} + metadata = { + "debug": {"content_safety": event.data.get(content_safety.metadata_key, {})} + } # Respond to the conversation await respond_to_conversation( @@ -349,7 +369,9 @@ async def on_file_created( try: share = await ShareManager.get_share(context) if not share or not file.filename: - logger.warning(f"No share found or missing filename. filename={file.filename}") + logger.warning( + f"No share found or missing filename. filename={file.filename}" + ) return role = await ShareManager.get_conversation_role(context) @@ -372,7 +394,9 @@ async def on_file_created( # 2. Synchronize to all Team conversations # Get all Team conversations - team_conversations = await ShareFilesManager.get_team_conversations(context, share.share_id) + team_conversations = await ShareFilesManager.get_team_conversations( + context, share.share_id + ) if team_conversations: for team_conv_id in team_conversations: @@ -384,7 +408,9 @@ async def on_file_created( ) # 3. Update all UIs but don't send notifications to reduce noise - await Notifications.notify_all_state_update(context, share.share_id, [InspectorTab.DEBUG]) + await Notifications.notify_all_state_update( + context, share.share_id, [InspectorTab.DEBUG] + ) # Team files don't need special handling as they're already in the conversation # Log file creation to knowledge transfer log for all files @@ -430,7 +456,9 @@ async def on_file_updated( logger.error(f"Failed to update file in share storage: {file.filename}") return - team_conversations = await ShareFilesManager.get_team_conversations(context, share.share_id) + team_conversations = await ShareFilesManager.get_team_conversations( + context, share.share_id + ) for team_conv_id in team_conversations: await ShareFilesManager.copy_file_to_conversation( context=context, @@ -440,7 +468,9 @@ async def on_file_updated( ) # 3. Update all UIs but don't send notifications to reduce noise - await Notifications.notify_all_state_update(context, share.share_id, [InspectorTab.DEBUG]) + await Notifications.notify_all_state_update( + context, share.share_id, [InspectorTab.DEBUG] + ) await ShareManager.log_share_event( context=context, @@ -478,10 +508,14 @@ async def on_file_deleted( ) if not success: - logger.error(f"Failed to delete file from share storage: {file.filename}") + logger.error( + f"Failed to delete file from share storage: {file.filename}" + ) # 2. Update all UIs about the deletion but don't send notifications to reduce noise - await Notifications.notify_all_state_update(context, share.share_id, [InspectorTab.DEBUG]) + await Notifications.notify_all_state_update( + context, share.share_id, [InspectorTab.DEBUG] + ) # Team files don't need special handling await ShareManager.log_share_event( @@ -526,7 +560,9 @@ async def on_participant_joined( if not share_id: return - await ShareFilesManager.synchronize_files_to_team_conversation(context=context, share_id=share_id) + await ShareFilesManager.synchronize_files_to_team_conversation( + context=context, share_id=share_id + ) await ShareManager.log_share_event( context=context, diff --git a/assistants/knowledge-transfer-assistant/assistant/config.py b/assistants/knowledge-transfer-assistant/assistant/config.py index f84d51d1d..9cb254742 100644 --- a/assistants/knowledge-transfer-assistant/assistant/config.py +++ b/assistants/knowledge-transfer-assistant/assistant/config.py @@ -172,7 +172,9 @@ class CoordinatorConfig(BaseModel): int, Field( title="Maximum Digest Tokens", - description=("The number of tokens to use for the knowledge digest. Default: 4096"), + description=( + "The number of tokens to use for the knowledge digest. Default: 4096" + ), ), ] = 4_096 diff --git a/assistants/knowledge-transfer-assistant/assistant/conversation_clients.py b/assistants/knowledge-transfer-assistant/assistant/conversation_clients.py index e390d1e7e..40d9e47bb 100644 --- a/assistants/knowledge-transfer-assistant/assistant/conversation_clients.py +++ b/assistants/knowledge-transfer-assistant/assistant/conversation_clients.py @@ -26,7 +26,9 @@ class ConversationClientManager: """ @staticmethod - def get_conversation_client(context: ConversationContext, conversation_id: str) -> ConversationAPIClient: + def get_conversation_client( + context: ConversationContext, conversation_id: str + ) -> ConversationAPIClient: """ Gets a client for accessing another conversation. """ @@ -40,7 +42,9 @@ async def get_coordinator_client_for_share( Gets a client for accessing the Coordinator conversation for a knowledge transfer. """ # Look for the Coordinator conversation directory - coordinator_dir = ShareStorageManager.get_share_dir(share_id) / ConversationRole.COORDINATOR + coordinator_dir = ( + ShareStorageManager.get_share_dir(share_id) / ConversationRole.COORDINATOR + ) if not coordinator_dir.exists(): return None, None @@ -64,7 +68,9 @@ async def get_coordinator_client_for_share( return None, coordinator_conversation_id # Create a client for the Coordinator conversation - client = ConversationClientManager.get_conversation_client(context, coordinator_conversation_id) + client = ConversationClientManager.get_conversation_client( + context, coordinator_conversation_id + ) return client, coordinator_conversation_id @staticmethod diff --git a/assistants/knowledge-transfer-assistant/assistant/data.py b/assistants/knowledge-transfer-assistant/assistant/data.py index e1cd1e847..3452cd9d0 100644 --- a/assistants/knowledge-transfer-assistant/assistant/data.py +++ b/assistants/knowledge-transfer-assistant/assistant/data.py @@ -161,7 +161,9 @@ class LogEntry(BaseModel): class KnowledgePackageLog(BaseModel): - entries: List[LogEntry] = Field(default_factory=list) # Chronological list of log entries + entries: List[LogEntry] = Field( + default_factory=list + ) # Chronological list of log entries class KnowledgePackage(BaseModel): @@ -193,7 +195,9 @@ class KnowledgePackage(BaseModel): archived: bool = False requests: List[InformationRequest] = Field(default_factory=list) - log: Optional[KnowledgePackageLog] = Field(default_factory=lambda: KnowledgePackageLog()) + log: Optional[KnowledgePackageLog] = Field( + default_factory=lambda: KnowledgePackageLog() + ) class CoordinatorConversationMessage(BaseModel): diff --git a/assistants/knowledge-transfer-assistant/assistant/domain/audience_manager.py b/assistants/knowledge-transfer-assistant/assistant/domain/audience_manager.py index 4f631c586..38dd0b27d 100644 --- a/assistants/knowledge-transfer-assistant/assistant/domain/audience_manager.py +++ b/assistants/knowledge-transfer-assistant/assistant/domain/audience_manager.py @@ -4,7 +4,7 @@ Handles knowledge transfer state updates, completion, and lifecycle operations. """ -from datetime import datetime +from datetime import datetime, timezone from typing import Tuple from semantic_workbench_assistant.assistant_app import ConversationContext @@ -47,7 +47,7 @@ async def update_audience( # Update the audience share.audience = audience_description.strip() - share.updated_at = datetime.utcnow() + share.updated_at = datetime.now(timezone.utc) # Save the updated package await ShareManager.set_share(context, share) @@ -63,7 +63,9 @@ async def update_audience( ) await Notifications.notify(context, "Audience updated.") - await Notifications.notify_all_state_update(context, share.share_id, [InspectorTab.DEBUG]) + await Notifications.notify_all_state_update( + context, share.share_id, [InspectorTab.DEBUG] + ) return True, f"Target audience updated successfully: {audience_description}" diff --git a/assistants/knowledge-transfer-assistant/assistant/domain/information_request_manager.py b/assistants/knowledge-transfer-assistant/assistant/domain/information_request_manager.py index 0768cb83e..167632e0b 100644 --- a/assistants/knowledge-transfer-assistant/assistant/domain/information_request_manager.py +++ b/assistants/knowledge-transfer-assistant/assistant/domain/information_request_manager.py @@ -4,7 +4,7 @@ Handles information request creation, resolution, and retrieval. """ -from datetime import datetime +from datetime import datetime, timezone from typing import List, Optional, Tuple from semantic_workbench_assistant.assistant_app import ConversationContext @@ -50,10 +50,14 @@ async def create_information_request( try: share_id = await ShareManager.get_share_id(context) if not share_id: - logger.error("Cannot create information request: no share associated with this conversation") + logger.error( + "Cannot create information request: no share associated with this conversation" + ) return False, None - current_user_id = await require_current_user(context, "create information request") + current_user_id = await require_current_user( + context, "create information request" + ) if not current_user_id: return False, None @@ -80,8 +84,12 @@ async def create_information_request( }, ) - await Notifications.notify_self_and_other(context, share_id, f"Information request '{title}' was created") - await Notifications.notify_all_state_update(context, share_id, [InspectorTab.SHARING]) + await Notifications.notify_self_and_other( + context, share_id, f"Information request '{title}' was created" + ) + await Notifications.notify_all_state_update( + context, share_id, [InspectorTab.SHARING] + ) return True, information_request @@ -98,15 +106,21 @@ async def resolve_information_request( try: share_id = await ShareManager.get_share_id(context) if not share_id: - logger.error("Cannot resolve information request: no share associated with this conversation") + logger.error( + "Cannot resolve information request: no share associated with this conversation" + ) return False, None - current_user_id = await require_current_user(context, "resolve information request") + current_user_id = await require_current_user( + context, "resolve information request" + ) if not current_user_id: return False, None # Get the information request - information_request = ShareStorage.read_information_request(share_id, request_id) + information_request = ShareStorage.read_information_request( + share_id, request_id + ) if not information_request: # Try to find it in all requests all_requests = ShareStorage.get_all_information_requests(share_id) @@ -127,19 +141,21 @@ async def resolve_information_request( # Update the request information_request.status = RequestStatus.RESOLVED information_request.resolution = resolution - information_request.resolved_at = datetime.utcnow() + information_request.resolved_at = datetime.now(timezone.utc) information_request.resolved_by = current_user_id # Add to history - information_request.updates.append({ - "timestamp": datetime.utcnow().isoformat(), - "user_id": current_user_id, - "message": f"Request resolved: {resolution}", - "status": RequestStatus.RESOLVED.value, - }) + information_request.updates.append( + { + "timestamp": datetime.now(timezone.utc).isoformat(), + "user_id": current_user_id, + "message": f"Request resolved: {resolution}", + "status": RequestStatus.RESOLVED.value, + } + ) # Update metadata - information_request.updated_at = datetime.utcnow() + information_request.updated_at = datetime.now(timezone.utc) information_request.updated_by = current_user_id information_request.version += 1 @@ -161,12 +177,16 @@ async def resolve_information_request( }, ) - await Notifications.notify_all_state_update(context, share_id, [InspectorTab.SHARING]) + await Notifications.notify_all_state_update( + context, share_id, [InspectorTab.SHARING] + ) await Notifications.notify_self_and_other( context, share_id, f"Information request '{information_request.title}' has been resolved: {resolution}", - information_request.conversation_id if information_request.conversation_id != str(context.id) else None, + information_request.conversation_id + if information_request.conversation_id != str(context.id) + else None, ) return True, information_request @@ -193,16 +213,22 @@ async def delete_information_request( try: share_id = await ShareManager.get_share_id(context) if not share_id: - logger.error("Cannot delete information request: no share associated with this conversation") + logger.error( + "Cannot delete information request: no share associated with this conversation" + ) return False, "No knowledge package associated with this conversation." - current_user_id = await require_current_user(context, "delete information request") + current_user_id = await require_current_user( + context, "delete information request" + ) if not current_user_id: return False, "Could not identify current user." # Get information request by ID cleaned_request_id = request_id.strip().replace('"', "").replace("'", "") - information_request = ShareStorage.read_information_request(share_id, cleaned_request_id) + information_request = ShareStorage.read_information_request( + share_id, cleaned_request_id + ) if not information_request: return False, f"Information request with ID '{request_id}' not found." @@ -240,7 +266,9 @@ async def delete_information_request( # Delete the information request from the main share data share = await ShareManager.get_share(context) if share and share.requests: - share.requests = [req for req in share.requests if req.request_id != actual_request_id] + share.requests = [ + req for req in share.requests if req.request_id != actual_request_id + ] await ShareManager.set_share(context, share) # Notify about the deletion @@ -249,7 +277,9 @@ async def delete_information_request( share_id, f"Information request '{request_title}' has been deleted.", ) - await Notifications.notify_all_state_update(context, share_id, [InspectorTab.SHARING]) + await Notifications.notify_all_state_update( + context, share_id, [InspectorTab.SHARING] + ) return ( True, diff --git a/assistants/knowledge-transfer-assistant/assistant/domain/knowledge_brief_manager.py b/assistants/knowledge-transfer-assistant/assistant/domain/knowledge_brief_manager.py index fbe210cf7..d46c55714 100644 --- a/assistants/knowledge-transfer-assistant/assistant/domain/knowledge_brief_manager.py +++ b/assistants/knowledge-transfer-assistant/assistant/domain/knowledge_brief_manager.py @@ -38,7 +38,9 @@ async def update_knowledge_brief( ) -> Optional[KnowledgeBrief]: share_id = await ShareManager.get_share_id(context) if not share_id: - logger.error("Cannot update brief: no share associated with this conversation") + logger.error( + "Cannot update brief: no share associated with this conversation" + ) return current_user_id = await require_current_user(context, "update brief") @@ -73,7 +75,11 @@ async def update_knowledge_brief( message=f"Created brief: {title}", ) - await Notifications.notify_all(context, share_id, "Knowledge brief has been updated") - await Notifications.notify_all_state_update(context, share_id, [InspectorTab.BRIEF]) + await Notifications.notify_all( + context, share_id, "Knowledge brief has been updated" + ) + await Notifications.notify_all_state_update( + context, share_id, [InspectorTab.BRIEF] + ) return brief diff --git a/assistants/knowledge-transfer-assistant/assistant/domain/knowledge_digest_manager.py b/assistants/knowledge-transfer-assistant/assistant/domain/knowledge_digest_manager.py index c7b3e3b00..59bfe570b 100644 --- a/assistants/knowledge-transfer-assistant/assistant/domain/knowledge_digest_manager.py +++ b/assistants/knowledge-transfer-assistant/assistant/domain/knowledge_digest_manager.py @@ -5,7 +5,7 @@ """ import re -from datetime import datetime +from datetime import datetime, timezone from typing import Optional, Tuple import openai_client @@ -43,10 +43,14 @@ async def update_knowledge_digest( try: share_id = await ShareManager.get_share_id(context) if not share_id: - logger.error("Cannot update knowledge digest: no share associated with this conversation") + logger.error( + "Cannot update knowledge digest: no share associated with this conversation" + ) return False, None - current_user_id = await require_current_user(context, "update knowledge digest") + current_user_id = await require_current_user( + context, "update knowledge digest" + ) if not current_user_id: return False, None @@ -64,7 +68,7 @@ async def update_knowledge_digest( digest.content = content digest.is_auto_generated = is_auto_generated - digest.updated_at = datetime.utcnow() + digest.updated_at = datetime.now(timezone.utc) digest.updated_by = current_user_id digest.version += 1 ShareStorage.write_knowledge_digest(share_id, digest) @@ -72,7 +76,9 @@ async def update_knowledge_digest( # Log the update event_type = LogEntryType.KNOWLEDGE_DIGEST_UPDATE update_type = "auto-generated" if is_auto_generated else "manual" - message = f"{'Created' if is_new else 'Updated'} knowledge digest ({update_type})" + message = ( + f"{'Created' if is_new else 'Updated'} knowledge digest ({update_type})" + ) await ShareManager.log_share_event( context=context, @@ -105,10 +111,14 @@ async def auto_update_knowledge_digest( share_id = await ShareManager.get_share_id(context) if not share_id: - logger.error("Cannot auto-update knowledge digest: no share associated with this conversation") + logger.error( + "Cannot auto-update knowledge digest: no share associated with this conversation" + ) return False, None - current_user_id = await require_current_user(context, "auto-update knowledge digest") + current_user_id = await require_current_user( + context, "auto-update knowledge digest" + ) if not current_user_id: return False, None @@ -121,7 +131,10 @@ async def auto_update_knowledge_digest( chat_history_text = "" for msg in chat_history: sender_type = ( - "User" if msg.sender and msg.sender.participant_role == ParticipantRole.user else "Assistant" + "User" + if msg.sender + and msg.sender.participant_role == ParticipantRole.user + else "Assistant" ) chat_history_text += f"{sender_type}: {msg.content}\n\n" @@ -135,7 +148,9 @@ async def auto_update_knowledge_digest( """ - async with openai_client.create_client(config.service_config, api_version="2024-06-01") as client: + async with openai_client.create_client( + config.service_config, api_version="2024-06-01" + ) as client: completion = await client.chat.completions.create( model=config.request_config.openai_model, messages=[{"role": "user", "content": digest_prompt}], @@ -144,14 +159,18 @@ async def auto_update_knowledge_digest( content = completion.choices[0].message.content or "" digest_content = "" - match = re.search(r"(.*?)", content, re.DOTALL) + match = re.search( + r"(.*?)", content, re.DOTALL + ) if match: digest_content = match.group(1).strip() else: digest_content = content.strip() if not digest_content: - logger.warning("No content extracted from knowledge digest LLM analysis") + logger.warning( + "No content extracted from knowledge digest LLM analysis" + ) return False, None result = await KnowledgeDigestManager.update_knowledge_digest( diff --git a/assistants/knowledge-transfer-assistant/assistant/domain/learning_objectives_manager.py b/assistants/knowledge-transfer-assistant/assistant/domain/learning_objectives_manager.py index ed60df69b..e5f81b24f 100644 --- a/assistants/knowledge-transfer-assistant/assistant/domain/learning_objectives_manager.py +++ b/assistants/knowledge-transfer-assistant/assistant/domain/learning_objectives_manager.py @@ -36,7 +36,9 @@ async def add_learning_objective( ) -> Optional[LearningObjective]: share_id = await ShareManager.get_share_id(context) if not share_id: - logger.error("Cannot add learning objective: no share associated with this conversation") + logger.error( + "Cannot add learning objective: no share associated with this conversation" + ) return None current_user_id = await require_current_user(context, "add learning objective") @@ -77,8 +79,12 @@ async def add_learning_objective( message=f"Added learning objective: {objective_name}", ) - await Notifications.notify_all(context, share_id, f"Learning objective '{objective_name}' was added") - await Notifications.notify_all_state_update(context, share_id, [InspectorTab.LEARNING, InspectorTab.BRIEF]) + await Notifications.notify_all( + context, share_id, f"Learning objective '{objective_name}' was added" + ) + await Notifications.notify_all_state_update( + context, share_id, [InspectorTab.LEARNING, InspectorTab.BRIEF] + ) return new_learning_objective @@ -92,10 +98,14 @@ async def update_learning_objective( """Update an existing learning objective's name or description.""" share_id = await ShareManager.get_share_id(context) if not share_id: - logger.error("Cannot update learning objective: no share associated with this conversation") + logger.error( + "Cannot update learning objective: no share associated with this conversation" + ) return False, "No share associated with this conversation." - current_user_id = await require_current_user(context, "update learning objective") + current_user_id = await require_current_user( + context, "update learning objective" + ) if not current_user_id: return False, "Could not identify current user." @@ -146,8 +156,12 @@ async def update_learning_objective( }, ) - await Notifications.notify_all(context, share_id, f"Learning objective '{objective.name}' has been updated") - await Notifications.notify_all_state_update(context, share_id, [InspectorTab.LEARNING, InspectorTab.BRIEF]) + await Notifications.notify_all( + context, share_id, f"Learning objective '{objective.name}' has been updated" + ) + await Notifications.notify_all_state_update( + context, share_id, [InspectorTab.LEARNING, InspectorTab.BRIEF] + ) return ( True, @@ -162,10 +176,14 @@ async def delete_learning_objective( """Delete a learning objective by ID.""" share_id = await ShareManager.get_share_id(context) if not share_id: - logger.error("Cannot delete learning objective: no share associated with this conversation") + logger.error( + "Cannot delete learning objective: no share associated with this conversation" + ) return False, "No share associated with this conversation." - current_user_id = await require_current_user(context, "delete learning objective") + current_user_id = await require_current_user( + context, "delete learning objective" + ) if not current_user_id: return False, "Could not identify current user." @@ -216,8 +234,12 @@ async def delete_learning_objective( }, ) - await Notifications.notify_all(context, share_id, f"Learning objective '{objective_name}' has been deleted") - await Notifications.notify_all_state_update(context, share_id, [InspectorTab.LEARNING, InspectorTab.BRIEF]) + await Notifications.notify_all( + context, share_id, f"Learning objective '{objective_name}' has been deleted" + ) + await Notifications.notify_all_state_update( + context, share_id, [InspectorTab.LEARNING, InspectorTab.BRIEF] + ) return ( True, @@ -252,7 +274,9 @@ async def add_learning_outcome( """Add a new learning outcome to an existing learning objective.""" share_id = await ShareManager.get_share_id(context) if not share_id: - logger.error("Cannot add learning outcome: no share associated with this conversation") + logger.error( + "Cannot add learning outcome: no share associated with this conversation" + ) return False, "No knowledge package associated with this conversation." current_user_id = await require_current_user(context, "add learning outcome") @@ -308,7 +332,9 @@ async def add_learning_outcome( share_id, f"Learning outcome '{outcome_description}' has been added", ) - await Notifications.notify_all_state_update(context, share_id, [InspectorTab.LEARNING, InspectorTab.BRIEF]) + await Notifications.notify_all_state_update( + context, share_id, [InspectorTab.LEARNING, InspectorTab.BRIEF] + ) return ( True, @@ -324,7 +350,9 @@ async def update_learning_outcome( """Update the description of an existing learning outcome.""" share_id = await ShareManager.get_share_id(context) if not share_id: - logger.error("Cannot update learning outcome: no share associated with this conversation") + logger.error( + "Cannot update learning outcome: no share associated with this conversation" + ) return False, "No knowledge package associated with this conversation." current_user_id = await require_current_user(context, "update learning outcome") @@ -384,8 +412,12 @@ async def update_learning_outcome( ) # Notify linked conversations - await Notifications.notify_all(context, share_id, f"Learning outcome '{new_description}' has been updated") - await Notifications.notify_all_state_update(context, share_id, [InspectorTab.LEARNING, InspectorTab.BRIEF]) + await Notifications.notify_all( + context, share_id, f"Learning outcome '{new_description}' has been updated" + ) + await Notifications.notify_all_state_update( + context, share_id, [InspectorTab.LEARNING, InspectorTab.BRIEF] + ) return ( True, @@ -400,7 +432,9 @@ async def delete_learning_outcome( """Delete a learning outcome from a learning objective.""" share_id = await ShareManager.get_share_id(context) if not share_id: - logger.error("Cannot delete learning outcome: no share associated with this conversation") + logger.error( + "Cannot delete learning outcome: no share associated with this conversation" + ) return False, "No knowledge package associated with this conversation." current_user_id = await require_current_user(context, "delete learning outcome") @@ -447,7 +481,9 @@ async def delete_learning_outcome( # Clean up any achievement records for this outcome across all team conversations for team_info in share.team_conversations.values(): team_info.outcome_achievements = [ - achievement for achievement in team_info.outcome_achievements if achievement.outcome_id != outcome_id + achievement + for achievement in team_info.outcome_achievements + if achievement.outcome_id != outcome_id ] # Save the updated knowledge package @@ -473,7 +509,9 @@ async def delete_learning_outcome( share_id, f"Learning outcome '{deleted_description}' has been removed", ) - await Notifications.notify_all_state_update(context, share_id, [InspectorTab.LEARNING, InspectorTab.BRIEF]) + await Notifications.notify_all_state_update( + context, share_id, [InspectorTab.LEARNING, InspectorTab.BRIEF] + ) return ( True, @@ -488,18 +526,28 @@ def get_achievements_for_conversation( return team_conv.outcome_achievements if team_conv else [] @staticmethod - def get_completion_for_conversation(package: KnowledgePackage, conversation_id: str) -> Tuple[int, int]: - achievements = LearningObjectivesManager.get_achievements_for_conversation(package, conversation_id) + def get_completion_for_conversation( + package: KnowledgePackage, conversation_id: str + ) -> Tuple[int, int]: + achievements = LearningObjectivesManager.get_achievements_for_conversation( + package, conversation_id + ) achieved_outcome_ids = {a.outcome_id for a in achievements if a.achieved} - total_outcomes = sum(len(obj.learning_outcomes) for obj in package.learning_objectives) + total_outcomes = sum( + len(obj.learning_outcomes) for obj in package.learning_objectives + ) achieved_outcomes = len(achieved_outcome_ids) return achieved_outcomes, total_outcomes @staticmethod - def is_outcome_achieved_by_conversation(package: KnowledgePackage, outcome_id: str, conversation_id: str) -> bool: - achievements = LearningObjectivesManager.get_achievements_for_conversation(package, conversation_id) + def is_outcome_achieved_by_conversation( + package: KnowledgePackage, outcome_id: str, conversation_id: str + ) -> bool: + achievements = LearningObjectivesManager.get_achievements_for_conversation( + package, conversation_id + ) return any(a.outcome_id == outcome_id and a.achieved for a in achievements) @staticmethod @@ -511,8 +559,12 @@ def get_overall_completion(package: KnowledgePackage) -> Tuple[int, int]: """ all_achieved_outcomes = set() for team_conv in package.team_conversations.values(): - achieved_ids = {a.outcome_id for a in team_conv.outcome_achievements if a.achieved} + achieved_ids = { + a.outcome_id for a in team_conv.outcome_achievements if a.achieved + } all_achieved_outcomes.update(achieved_ids) - total_outcomes = sum(len(obj.learning_outcomes) for obj in package.learning_objectives) + total_outcomes = sum( + len(obj.learning_outcomes) for obj in package.learning_objectives + ) return len(all_achieved_outcomes), total_outcomes diff --git a/assistants/knowledge-transfer-assistant/assistant/domain/share_manager.py b/assistants/knowledge-transfer-assistant/assistant/domain/share_manager.py index 390b73b50..58db1b27f 100644 --- a/assistants/knowledge-transfer-assistant/assistant/domain/share_manager.py +++ b/assistants/knowledge-transfer-assistant/assistant/domain/share_manager.py @@ -5,7 +5,7 @@ """ import uuid -from datetime import datetime +from datetime import datetime, timezone from typing import List, Optional from semantic_workbench_api_model.workbench_model import ( @@ -75,7 +75,9 @@ async def create_share(context: ConversationContext) -> str: # Associate the conversation with the share logger.debug(f"Associating conversation {context.id} with share {share_id}") - await ShareManager.set_conversation_role(context, share_id, ConversationRole.COORDINATOR) + await ShareManager.set_conversation_role( + context, share_id, ConversationRole.COORDINATOR + ) # No need to set conversation role in share storage, as we use metadata logger.debug(f"Conversation {context.id} is Coordinator for share {share_id}") @@ -85,11 +87,15 @@ async def create_share(context: ConversationContext) -> str: return share_id @staticmethod - async def set_conversation_role(context: ConversationContext, share_id: str, role: ConversationRole) -> None: + async def set_conversation_role( + context: ConversationContext, share_id: str, role: ConversationRole + ) -> None: """ Sets the role of a conversation in a knowledge transfer share. """ - role_data = ConversationShareInfo(share_id=share_id, role=role, conversation_id=str(context.id)) + role_data = ConversationShareInfo( + share_id=share_id, role=role, conversation_id=str(context.id) + ) role_path = ShareStorageManager.get_conversation_role_file_path(context) write_model(role_path, role_data) @@ -106,10 +112,12 @@ async def get_conversation_role( if role_data: return role_data.role - return None + return ConversationRole.COORDINATOR @staticmethod - async def create_shareable_team_conversation(context: ConversationContext, share_id: str) -> str: + async def create_shareable_team_conversation( + context: ConversationContext, share_id: str + ) -> str: """ Creates a new shareable team conversation template. @@ -145,7 +153,9 @@ async def create_shareable_team_conversation(context: ConversationContext, share }, ) client = context._conversations_client - conversation = await client.create_conversation_with_owner(new_conversation=new_conversation, owner_id=user_id) + conversation = await client.create_conversation_with_owner( + new_conversation=new_conversation, owner_id=user_id + ) if not conversation or not conversation.id: raise ValueError("Failed to create team conversation") @@ -161,8 +171,10 @@ async def create_shareable_team_conversation(context: ConversationContext, share "show_duplicate_action": True, }, ) - share = await context._conversations_client.create_conversation_share_with_owner( - new_conversation_share=new_share, owner_id=user_id + share = ( + await context._conversations_client.create_conversation_share_with_owner( + new_conversation_share=new_share, owner_id=user_id + ) ) share_url = f"/conversation-share/{share.id}/redeem" @@ -172,10 +184,12 @@ async def create_shareable_team_conversation(context: ConversationContext, share if knowledge_package: knowledge_package.shared_conversation_id = str(conversation.id) knowledge_package.share_url = share_url - knowledge_package.updated_at = datetime.utcnow() + knowledge_package.updated_at = datetime.now(timezone.utc) ShareStorage.write_share(share_id, knowledge_package) else: - raise ValueError(f"KnowledgePackage info not found for share ID: {share_id}") + raise ValueError( + f"KnowledgePackage info not found for share ID: {share_id}" + ) return share_url @@ -274,7 +288,9 @@ async def set_share(context: ConversationContext, share: KnowledgePackage) -> No """ share_id = await ShareManager.get_share_id(context) if not share_id: - raise ValueError("Cannot set share: no share ID found for this conversation") + raise ValueError( + "Cannot set share: no share ID found for this conversation" + ) ShareStorage.write_share(share_id, share) @@ -315,7 +331,9 @@ async def get_linked_conversations(context: ConversationContext) -> List[str]: return [] @staticmethod - async def _capture_redeemer_info(context: ConversationContext, share_id: str) -> None: + async def _capture_redeemer_info( + context: ConversationContext, share_id: str + ) -> None: """ Captures the redeemer (first non-assistant participant) information and stores it in the knowledge package. Only captures info for actual team member conversations, not coordinator or shared conversations. @@ -323,19 +341,25 @@ async def _capture_redeemer_info(context: ConversationContext, share_id: str) -> try: knowledge_package = ShareStorage.read_share(share_id) if not knowledge_package: - logger.warning(f"Could not load knowledge package {share_id} to capture redeemer info") + logger.warning( + f"Could not load knowledge package {share_id} to capture redeemer info" + ) return conversation_id = str(context.id) # Skip if this is the coordinator conversation if conversation_id == knowledge_package.coordinator_conversation_id: - logger.debug(f"Skipping redeemer capture for coordinator conversation {conversation_id}") + logger.debug( + f"Skipping redeemer capture for coordinator conversation {conversation_id}" + ) return # Skip if this is the shared conversation template if conversation_id == knowledge_package.shared_conversation_id: - logger.debug(f"Skipping redeemer capture for shared conversation template {conversation_id}") + logger.debug( + f"Skipping redeemer capture for shared conversation template {conversation_id}" + ) return # If we get here, it's a team member conversation - capture redeemer info @@ -343,7 +367,9 @@ async def _capture_redeemer_info(context: ConversationContext, share_id: str) -> user_id, user_name = await get_current_user(context) if not user_id or not user_name: - logger.warning(f"Could not identify redeemer for conversation {conversation_id}") + logger.warning( + f"Could not identify redeemer for conversation {conversation_id}" + ) return # Create team conversation info @@ -354,11 +380,15 @@ async def _capture_redeemer_info(context: ConversationContext, share_id: str) -> ) # Add to knowledge package - knowledge_package.team_conversations[conversation_id] = team_conversation_info + knowledge_package.team_conversations[conversation_id] = ( + team_conversation_info + ) # Save the updated knowledge package ShareStorage.write_share(share_id, knowledge_package) - logger.debug(f"Captured redeemer info for team conversation {conversation_id}: {user_name} ({user_id})") + logger.debug( + f"Captured redeemer info for team conversation {conversation_id}: {user_name} ({user_id})" + ) except Exception as e: logger.error(f"Error capturing redeemer info: {e}") @@ -391,7 +421,9 @@ async def append_coordinator_message( """ share_id = await ShareManager.get_share_id(context) if not share_id: - raise ValueError("Cannot append message: no share ID found for this conversation") + raise ValueError( + "Cannot append message: no share ID found for this conversation" + ) ShareStorage.append_coordinator_message( share_id=share_id, @@ -399,7 +431,7 @@ async def append_coordinator_message( content=content, sender_name=sender_name, is_assistant=is_assistant, - timestamp=timestamp or datetime.utcnow(), + timestamp=timestamp or datetime.now(timezone.utc), ) @staticmethod diff --git a/assistants/knowledge-transfer-assistant/assistant/domain/transfer_manager.py b/assistants/knowledge-transfer-assistant/assistant/domain/transfer_manager.py index 04cb9ba53..01744b729 100644 --- a/assistants/knowledge-transfer-assistant/assistant/domain/transfer_manager.py +++ b/assistants/knowledge-transfer-assistant/assistant/domain/transfer_manager.py @@ -16,7 +16,9 @@ class TransferManager: @staticmethod def is_ready_for_transfer(package: KnowledgePackage) -> bool: has_basic_requirements = ( - package.knowledge_organized and package.brief is not None and package.audience is not None + package.knowledge_organized + and package.brief is not None + and package.audience is not None ) if not has_basic_requirements: @@ -31,7 +33,10 @@ def is_ready_for_transfer(package: KnowledgePackage) -> bool: @staticmethod def is_actively_sharing(package: KnowledgePackage) -> bool: - return TransferManager.is_ready_for_transfer(package) and len(package.team_conversations) > 0 + return ( + TransferManager.is_ready_for_transfer(package) + and len(package.team_conversations) > 0 + ) @staticmethod def _is_transfer_complete(package: KnowledgePackage) -> bool: @@ -43,5 +48,7 @@ def _is_transfer_complete(package: KnowledgePackage) -> bool: if not package.is_intended_to_accomplish_outcomes: return False - achieved_outcomes, total_outcomes = LearningObjectivesManager.get_overall_completion(package) + achieved_outcomes, total_outcomes = ( + LearningObjectivesManager.get_overall_completion(package) + ) return total_outcomes > 0 and achieved_outcomes == total_outcomes diff --git a/assistants/knowledge-transfer-assistant/assistant/files.py b/assistants/knowledge-transfer-assistant/assistant/files.py index 2f4bb246b..00ef0a53b 100644 --- a/assistants/knowledge-transfer-assistant/assistant/files.py +++ b/assistants/knowledge-transfer-assistant/assistant/files.py @@ -138,7 +138,9 @@ async def copy_file_to_share_storage( try: # Verify file information if not file.filename: - logger.error("Missing filename in file metadata", extra=safe_extra(log_extra)) + logger.error( + "Missing filename in file metadata", extra=safe_extra(log_extra) + ) return False # Check if share storage directory exists @@ -226,7 +228,11 @@ async def copy_file_to_share_storage( # Check if file already exists in collection existing_idx = next( - (i for i, f in enumerate(metadata.files) if f.filename == file.filename), + ( + i + for i, f in enumerate(metadata.files) + if f.filename == file.filename + ), None, ) if existing_idx is not None: @@ -254,7 +260,9 @@ async def copy_file_to_share_storage( ) return False - file_exists_in_metadata = any(f.filename == file.filename for f in verification_metadata.files) + file_exists_in_metadata = any( + f.filename == file.filename for f in verification_metadata.files + ) if not file_exists_in_metadata: logger.error( f"File metadata doesn't contain entry for {file.filename}", @@ -272,7 +280,9 @@ async def copy_file_to_share_storage( return True except Exception as e: - logger.exception(f"Error copying file to share storage: {e}", extra=safe_extra(log_extra)) + logger.exception( + f"Error copying file to share storage: {e}", extra=safe_extra(log_extra) + ) return False @staticmethod @@ -323,13 +333,17 @@ async def notify_team_conversations_file_deleted( """ try: # Get Team conversations - team_conversations = await ShareFilesManager.get_team_conversations(context, share_id) + team_conversations = await ShareFilesManager.get_team_conversations( + context, share_id + ) if not team_conversations: return for conv_id in team_conversations: try: - client = ConversationClientManager.get_conversation_client(context, conv_id) + client = ConversationClientManager.get_conversation_client( + context, conv_id + ) # Check if file exists in the conversation conversation = await client.get_conversation() @@ -339,7 +353,9 @@ async def notify_team_conversations_file_deleted( if file_exists: # Delete the file await client.delete_file(filename) - logger.debug(f"Deleted file {filename} from Team conversation {conv_id}") + logger.debug( + f"Deleted file {filename} from Team conversation {conv_id}" + ) # Send notification await client.send_messages( @@ -349,10 +365,14 @@ async def notify_team_conversations_file_deleted( ) ) except Exception as e: - logger.warning(f"Failed to delete file {filename} from Team conversation {conv_id}: {e}") + logger.warning( + f"Failed to delete file {filename} from Team conversation {conv_id}: {e}" + ) except Exception as e: - logger.exception(f"Error notifying Team conversations about deleted file: {e}") + logger.exception( + f"Error notifying Team conversations about deleted file: {e}" + ) @staticmethod async def copy_file_to_conversation( @@ -378,15 +398,21 @@ async def copy_file_to_conversation( return False # Find the file metadata - file_meta = next((f for f in metadata.files if f.filename == filename), None) + file_meta = next( + (f for f in metadata.files if f.filename == filename), None + ) if not file_meta: logger.warning(f"No metadata found for file {filename}") return False # Create client for target conversation - target_client = ConversationClientManager.get_conversation_client(context, target_conversation_id) + target_client = ConversationClientManager.get_conversation_client( + context, target_conversation_id + ) if not target_client: - logger.warning(f"Could not create client for conversation {target_conversation_id}") + logger.warning( + f"Could not create client for conversation {target_conversation_id}" + ) return False # Read the file content @@ -395,7 +421,9 @@ async def copy_file_to_conversation( file_bytes = f.read() if not file_bytes: - logger.warning(f"Failed to read file content from {file_path} (empty file)") + logger.warning( + f"Failed to read file content from {file_path} (empty file)" + ) return False file_content = io.BytesIO(file_bytes) @@ -443,7 +471,9 @@ async def copy_file_to_conversation( return False @staticmethod - async def get_team_conversations(context: ConversationContext, share_id: str) -> List[str]: + async def get_team_conversations( + context: ConversationContext, share_id: str + ) -> List[str]: """ Gets all Team conversation IDs for a share. """ @@ -455,7 +485,9 @@ async def get_team_conversations(context: ConversationContext, share_id: str) -> team_conversations = [] for conv_id in linked_conversations: # Check if this is a team conversation - temp_context = await ShareFilesManager.create_temporary_context(context, conv_id) + temp_context = await ShareFilesManager.create_temporary_context( + context, conv_id + ) if temp_context: role = await ShareManager.get_conversation_role(temp_context) if role == ConversationRole.TEAM: @@ -564,9 +596,7 @@ async def synchronize_files_to_team_conversation( ) # Log the synchronization event - sync_message = ( - f"Synchronized files to Team conversation: {len(successful_files)} new, {len(skipped_files)} existing" - ) + sync_message = f"Synchronized files to Team conversation: {len(successful_files)} new, {len(skipped_files)} existing" await ShareManager.log_share_event( context=context, @@ -580,7 +610,9 @@ async def synchronize_files_to_team_conversation( ) @staticmethod - async def get_shared_files(context: ConversationContext, share_id: str) -> Dict[str, ShareFile]: + async def get_shared_files( + context: ConversationContext, share_id: str + ) -> Dict[str, ShareFile]: """ Gets all shared files for a share with filename as key. """ @@ -610,7 +642,9 @@ async def process_share_update_notification( role = await ShareManager.get_conversation_role(context) if role != ConversationRole.TEAM: - logger.warning("Only Team conversations should process file update notifications") + logger.warning( + "Only Team conversations should process file update notifications" + ) return False # Process based on update type @@ -627,7 +661,9 @@ async def process_share_update_notification( if success: return True else: - logger.warning(f"Failed to {action} file {filename} in Team conversation {context.id}") + logger.warning( + f"Failed to {action} file {filename} in Team conversation {context.id}" + ) return False elif update_type == "file_deleted": @@ -641,14 +677,18 @@ async def process_share_update_notification( if file_exists: # Delete the file await context.delete_file(filename) - logger.debug(f"Deleted file {filename} from Team conversation {context.id}") + logger.debug( + f"Deleted file {filename} from Team conversation {context.id}" + ) return True else: # File doesn't exist, nothing to do return True except Exception as e: - logger.warning(f"Failed to delete file {filename} from Team conversation: {e}") + logger.warning( + f"Failed to delete file {filename} from Team conversation: {e}" + ) return False else: logger.warning(f"Unknown file update type: {update_type}") diff --git a/assistants/knowledge-transfer-assistant/assistant/logging.py b/assistants/knowledge-transfer-assistant/assistant/logging.py index 6a50e1156..0a942f96f 100644 --- a/assistants/knowledge-transfer-assistant/assistant/logging.py +++ b/assistants/knowledge-transfer-assistant/assistant/logging.py @@ -161,7 +161,9 @@ def setup_file_logging(log_dir: Optional[str] = None) -> Path: # Also set up a regular text file handler for easier debugging text_file_handler = logging.FileHandler(line_log_file) text_file_handler.setLevel(logging.DEBUG) - text_file_handler.setFormatter(logging.Formatter("%(asctime)s - %(name)s - [%(levelname)s] %(message)s")) + text_file_handler.setFormatter( + logging.Formatter("%(asctime)s - %(name)s - [%(levelname)s] %(message)s") + ) logger.addHandler(text_file_handler) logger.debug( @@ -183,8 +185,12 @@ def setup_file_logging(log_dir: Optional[str] = None) -> Path: try: import stat - os.chmod(log_file, stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IROTH) - os.chmod(line_log_file, stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IROTH) + os.chmod( + log_file, stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IROTH + ) + os.chmod( + line_log_file, stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IROTH + ) except Exception as e: logger.warning(f"Could not set log file permissions: {e}") print(f"Permission error: {e}") @@ -210,7 +216,11 @@ def setup_file_logging(log_dir: Optional[str] = None) -> Path: text_file_handler = logging.FileHandler(line_log_file) text_file_handler.setLevel(logging.DEBUG) - text_file_handler.setFormatter(logging.Formatter("%(asctime)s - %(name)s - [%(levelname)s] %(message)s")) + text_file_handler.setFormatter( + logging.Formatter( + "%(asctime)s - %(name)s - [%(levelname)s] %(message)s" + ) + ) logger.addHandler(text_file_handler) logger.warning(f"Using fallback log location: {log_file}") diff --git a/assistants/knowledge-transfer-assistant/assistant/notifications.py b/assistants/knowledge-transfer-assistant/assistant/notifications.py index 365c20afb..ed3857ce5 100644 --- a/assistants/knowledge-transfer-assistant/assistant/notifications.py +++ b/assistants/knowledge-transfer-assistant/assistant/notifications.py @@ -59,7 +59,9 @@ async def notify_self_and_other( return try: - client = ConversationClientManager.get_conversation_client(context, target_id) + client = ConversationClientManager.get_conversation_client( + context, target_id + ) await client.send_messages( NewConversationMessage( content=message, @@ -70,7 +72,9 @@ async def notify_self_and_other( logger.error(f"Failed to notify conversation {target_id}: {e}") @staticmethod - async def notify_all(context: ConversationContext, share_id: str, message: str) -> None: + async def notify_all( + context: ConversationContext, share_id: str, message: str + ) -> None: """Send text message notification to all knowledge transfer conversations.""" knowledge_package = await ShareManager.get_share(context) @@ -102,9 +106,14 @@ async def notify_all(context: ConversationContext, share_id: str, message: str) # Notify all team conversations for conv_id in knowledge_package.team_conversations.keys(): - if conv_id != current_id and conv_id != knowledge_package.coordinator_conversation_id: + if ( + conv_id != current_id + and conv_id != knowledge_package.coordinator_conversation_id + ): try: - client = ConversationClientManager.get_conversation_client(context, conv_id) + client = ConversationClientManager.get_conversation_client( + context, conv_id + ) await client.send_messages( NewConversationMessage( content=message, @@ -117,7 +126,9 @@ async def notify_all(context: ConversationContext, share_id: str, message: str) # State Update Notifications (UI refreshes) @staticmethod - async def notify_state_update(context: ConversationContext, tabs: List[InspectorTab]) -> None: + async def notify_state_update( + context: ConversationContext, tabs: List[InspectorTab] + ) -> None: """Send state update notifications to refresh UI in current conversation only.""" for tab in tabs: state_event = AssistantStateEvent( @@ -128,7 +139,9 @@ async def notify_state_update(context: ConversationContext, tabs: List[Inspector await context.send_conversation_state_event(state_event) @staticmethod - async def notify_all_state_update(context: ConversationContext, share_id: str, tabs: List[InspectorTab]) -> None: + async def notify_all_state_update( + context: ConversationContext, share_id: str, tabs: List[InspectorTab] + ) -> None: """Send state update notifications to refresh UI across all share conversations.""" # Refresh current conversation first @@ -167,9 +180,14 @@ async def notify_all_state_update(context: ConversationContext, share_id: str, t # Refresh all team conversations for conv_id in knowledge_package.team_conversations.keys(): - if conv_id != current_id and conv_id != knowledge_package.coordinator_conversation_id: + if ( + conv_id != current_id + and conv_id != knowledge_package.coordinator_conversation_id + ): try: - client = ConversationClientManager.get_conversation_client(context, conv_id) + client = ConversationClientManager.get_conversation_client( + context, conv_id + ) for tab in tabs: state_event = AssistantStateEvent( diff --git a/assistants/knowledge-transfer-assistant/assistant/respond.py b/assistants/knowledge-transfer-assistant/assistant/respond.py index 6268539ad..4d2d8ef0c 100644 --- a/assistants/knowledge-transfer-assistant/assistant/respond.py +++ b/assistants/knowledge-transfer-assistant/assistant/respond.py @@ -42,13 +42,21 @@ SILENCE_TOKEN = "{{SILENCE}}" -def format_message(participants: ConversationParticipantList, message: ConversationMessage) -> str: +def format_message( + participants: ConversationParticipantList, message: ConversationMessage +) -> str: """Consistent formatter that includes the participant name for multi-participant and name references""" conversation_participant = next( - (participant for participant in participants.participants if participant.id == message.sender.participant_id), + ( + participant + for participant in participants.participants + if participant.id == message.sender.participant_id + ), None, ) - participant_name = conversation_participant.name if conversation_participant else "unknown" + participant_name = ( + conversation_participant.name if conversation_participant else "unknown" + ) message_datetime = message.timestamp.strftime("%Y-%m-%d %H:%M:%S") return f"[{participant_name} - {message_datetime}]: {message.content}" @@ -115,7 +123,10 @@ async def respond_to_conversation( model = config.request_config.openai_model # Requirements - role = await ShareManager.get_conversation_role(context) or ConversationRole.COORDINATOR + role = ( + await ShareManager.get_conversation_role(context) + or ConversationRole.COORDINATOR + ) metadata["debug"]["role"] = role token_budget = TokenBudget(config.request_config.max_tokens) @@ -148,11 +159,13 @@ async def respond_to_conversation( "\n\n" f"There are {len(participants.participants)} participants in the conversation," " including you as the assistant and the following users:" - + ",".join([ - f' "{participant.name}"' - for participant in participants.participants - if participant.id != context.assistant.id - ]) + + ",".join( + [ + f' "{participant.name}"' + for participant in participants.participants + if participant.id != context.assistant.id + ] + ) + "\n\nYou do not need to respond to every message. Do not respond if the last thing said was a closing" " statement such as 'bye' or 'goodbye', or just a general acknowledgement like 'ok' or 'thanks'. Do not" f' respond as another user in the conversation, only as "{context.assistant.name}".' @@ -160,7 +173,11 @@ async def respond_to_conversation( f' be directed at you or the general audience, go ahead and respond.\n\nSay "{SILENCE_TOKEN}" to skip' " your turn." ) - instructions.add_subsection(Instructions(participant_text, "Multi-participant conversation instructions")) + instructions.add_subsection( + Instructions( + participant_text, "Multi-participant conversation instructions" + ) + ) prompt = Prompt( role=assistant_role, @@ -198,7 +215,9 @@ async def respond_to_conversation( # Brief if share and share.brief: brief_text = "" - brief_text = f"**Title:** {share.brief.title}\n**Description:** {share.brief.content}" + brief_text = ( + f"**Title:** {share.brief.title}\n**Description:** {share.brief.content}" + ) prompt.contexts.append( Context( "Knowledge Brief", @@ -228,29 +247,37 @@ async def respond_to_conversation( # Show progress based on role if role == ConversationRole.COORDINATOR: # Coordinator sees overall progress across all team members - achieved_overall, total_overall = LearningObjectivesManager.get_overall_completion(share) - learning_objectives_text += ( - f"Overall Progress: {achieved_overall}/{total_overall} outcomes achieved by team members\n\n" + achieved_overall, total_overall = ( + LearningObjectivesManager.get_overall_completion(share) ) + learning_objectives_text += f"Overall Progress: {achieved_overall}/{total_overall} outcomes achieved by team members\n\n" else: # Team member sees their personal progress if conversation_id in share.team_conversations: - achieved_personal, total_personal = LearningObjectivesManager.get_completion_for_conversation( - share, conversation_id + achieved_personal, total_personal = ( + LearningObjectivesManager.get_completion_for_conversation( + share, conversation_id + ) ) - progress_pct = int((achieved_personal / total_personal * 100)) if total_personal > 0 else 0 - learning_objectives_text += ( - f"My Progress: {achieved_personal}/{total_personal} outcomes achieved ({progress_pct}%)\n\n" + progress_pct = ( + int((achieved_personal / total_personal * 100)) + if total_personal > 0 + else 0 ) + learning_objectives_text += f"My Progress: {achieved_personal}/{total_personal} outcomes achieved ({progress_pct}%)\n\n" for i, objective in enumerate(share.learning_objectives): - learning_objectives_text += f"{i + 1}. **{objective.name}** - {objective.description}\n" + learning_objectives_text += ( + f"{i + 1}. **{objective.name}** - {objective.description}\n" + ) if objective.learning_outcomes: for criterion in objective.learning_outcomes: if role == ConversationRole.COORDINATOR: # Show if achieved by any team member achieved_by_any = any( - LearningObjectivesManager.is_outcome_achieved_by_conversation(share, criterion.id, conv_id) + LearningObjectivesManager.is_outcome_achieved_by_conversation( + share, criterion.id, conv_id + ) for conv_id in share.team_conversations.keys() ) check = "✅" if achieved_by_any else "⬜" @@ -283,20 +310,24 @@ async def respond_to_conversation( if share: all_requests = share.requests if role == ConversationRole.COORDINATOR: - active_requests = [r for r in all_requests if r.status != RequestStatus.RESOLVED] + active_requests = [ + r for r in all_requests if r.status != RequestStatus.RESOLVED + ] if active_requests: - coordinator_requests = ( - "> 📋 **Use the request ID (not the title) with resolve_information_request()**\n\n" - ) + coordinator_requests = "> 📋 **Use the request ID (not the title) with resolve_information_request()**\n\n" for req in active_requests[:10]: # Limit to 10 for brevity priority_emoji = get_priority_emoji(req.priority) status_emoji = get_status_emoji(req.status) - coordinator_requests += f"{priority_emoji} **{req.title}** {status_emoji}\n" + coordinator_requests += ( + f"{priority_emoji} **{req.title}** {status_emoji}\n" + ) coordinator_requests += f" **Request ID:** `{req.request_id}`\n" coordinator_requests += f" **Description:** {req.description}\n\n" if len(active_requests) > 10: - coordinator_requests += f"*...and {len(active_requests) - 10} more requests.*\n" + coordinator_requests += ( + f"*...and {len(active_requests) - 10} more requests.*\n" + ) else: coordinator_requests = "No active information requests." prompt.contexts.append( @@ -311,15 +342,16 @@ async def respond_to_conversation( # Filter for requests from this conversation that aren't resolved. my_requests = [ - r for r in all_requests if r.conversation_id == str(context.id) and r.status != RequestStatus.RESOLVED + r + for r in all_requests + if r.conversation_id == str(context.id) + and r.status != RequestStatus.RESOLVED ] if my_requests: information_requests_info = "" for req in my_requests: - information_requests_info += ( - f"- **{req.title}** (ID: `{req.request_id}`, Priority: {req.priority})\n" - ) + information_requests_info += f"- **{req.title}** (ID: `{req.request_id}`, Priority: {req.priority})\n" else: information_requests_info = "No active information requests." @@ -332,7 +364,9 @@ async def respond_to_conversation( # Add next action suggestions for coordinator if role == ConversationRole.COORDINATOR: - next_action_suggestion = await CoordinatorSupport.get_coordinator_next_action_suggestion(context) + next_action_suggestion = ( + await CoordinatorSupport.get_coordinator_next_action_suggestion(context) + ) if next_action_suggestion: prompt.contexts.append( Context( @@ -360,9 +394,13 @@ async def respond_to_conversation( if coordinator_conversation: # Limit messages to the configured max token count. total_coordinator_conversation_tokens = 0 - selected_coordinator_conversation_messages: List[CoordinatorConversationMessage] = [] + selected_coordinator_conversation_messages: List[ + CoordinatorConversationMessage + ] = [] for msg in reversed(coordinator_conversation.messages): - tokens = openai_client.num_tokens_from_string(msg.model_dump_json(), model=model) + tokens = openai_client.num_tokens_from_string( + msg.model_dump_json(), model=model + ) if ( total_coordinator_conversation_tokens + tokens > config.request_config.coordinator_conversation_token_limit @@ -376,7 +414,9 @@ class CoordinatorMessageList(BaseModel): messages: List[CoordinatorConversationMessage] = Field(default_factory=list) selected_coordinator_conversation_messages.reverse() - coordinator_message_list = CoordinatorMessageList(messages=selected_coordinator_conversation_messages) + coordinator_message_list = CoordinatorMessageList( + messages=selected_coordinator_conversation_messages + ) coordinator_conversation_message = ChatCompletionSystemMessageParam( role="system", content=( @@ -400,10 +440,12 @@ class CoordinatorMessageList(BaseModel): # in the proper flow of the conversation rather than as . # Generate the attachment messages. - attachment_messages: List[ChatCompletionMessageParam] = openai_client.convert_from_completion_messages( - await attachments_extension.get_completion_messages_for_attachments( - context, - config=config.attachments_config, + attachment_messages: List[ChatCompletionMessageParam] = ( + openai_client.convert_from_completion_messages( + await attachments_extension.get_completion_messages_for_attachments( + context, + config=config.attachments_config, + ) ) ) @@ -528,9 +570,14 @@ class CoordinatorMessageList(BaseModel): # For team role, analyze message for possible information request needs. # Send a notification if we think it might be one. if role is ConversationRole.TEAM: - detection_result = await detect_information_request_needs(context, new_message.content) + detection_result = await detect_information_request_needs( + context, new_message.content + ) - if detection_result.get("is_information_request", False) and detection_result.get("confidence", 0) > 0.8: + if ( + detection_result.get("is_information_request", False) + and detection_result.get("confidence", 0) > 0.8 + ): suggested_title = detection_result.get("potential_title", "") suggested_priority = detection_result.get("suggested_priority", "medium") potential_description = detection_result.get("potential_description", "") @@ -564,7 +611,9 @@ class CoordinatorMessageList(BaseModel): "messages": completion_messages, "model": model, "max_tokens": config.request_config.response_tokens, - "response_format": CoordinatorOutput if role == ConversationRole.COORDINATOR else TeamOutput, + "response_format": CoordinatorOutput + if role == ConversationRole.COORDINATOR + else TeamOutput, } share_tools = ShareTools(context, role) @@ -580,7 +629,11 @@ class CoordinatorMessageList(BaseModel): # Add the token usage message to the footer items if completion_response: - response_tokens = completion_response.usage.completion_tokens if completion_response.usage else 0 + response_tokens = ( + completion_response.usage.completion_tokens + if completion_response.usage + else 0 + ) request_tokens = token_budget.used footer_items.append( get_token_usage_message( @@ -600,7 +653,9 @@ class CoordinatorMessageList(BaseModel): } ) - footer_items.append(get_response_duration_message(response_end_time - response_start_time)) + footer_items.append( + get_response_duration_message(response_end_time - response_start_time) + ) metadata["footer_items"] = footer_items content = message_content_from_completion(completion_response) diff --git a/assistants/knowledge-transfer-assistant/assistant/storage.py b/assistants/knowledge-transfer-assistant/assistant/storage.py index 8b553dd02..4b3e87122 100644 --- a/assistants/knowledge-transfer-assistant/assistant/storage.py +++ b/assistants/knowledge-transfer-assistant/assistant/storage.py @@ -147,7 +147,9 @@ def read_coordinator_conversation( return read_model(path, CoordinatorConversationMessages) @staticmethod - def write_coordinator_conversation(share_id: str, conversation: CoordinatorConversationMessages) -> pathlib.Path: + def write_coordinator_conversation( + share_id: str, conversation: CoordinatorConversationMessages + ) -> pathlib.Path: path = ShareStorageManager.get_coordinator_conversation_path(share_id) write_model(path, conversation) return path @@ -209,7 +211,9 @@ def write_knowledge_digest(share_id: str, digest: KnowledgeDigest) -> pathlib.Pa return ShareStorage.write_share(share_id, package) @staticmethod - def read_information_request(share_id: str, request_id: str) -> Optional[InformationRequest]: + def read_information_request( + share_id: str, request_id: str + ) -> Optional[InformationRequest]: """Reads an information request from the main share data.""" package = ShareStorage.read_share(share_id) if not package or not package.requests: @@ -222,7 +226,9 @@ def read_information_request(share_id: str, request_id: str) -> Optional[Informa return None @staticmethod - def write_information_request(share_id: str, request: InformationRequest) -> pathlib.Path: + def write_information_request( + share_id: str, request: InformationRequest + ) -> pathlib.Path: """Writes an information request to the main share data.""" # Information requests must have an ID if not request.request_id: diff --git a/assistants/knowledge-transfer-assistant/assistant/string_utils.py b/assistants/knowledge-transfer-assistant/assistant/string_utils.py index a9bcb7892..b783dc6ca 100644 --- a/assistants/knowledge-transfer-assistant/assistant/string_utils.py +++ b/assistants/knowledge-transfer-assistant/assistant/string_utils.py @@ -18,7 +18,9 @@ def render(template: str, vars: dict[str, Any]) -> str: return parsed -def create_system_message(content: str, delimiter: str | None = None) -> ChatCompletionMessageParam: +def create_system_message( + content: str, delimiter: str | None = None +) -> ChatCompletionMessageParam: if delimiter: content = f"<{delimiter}>\n{content}\n" @@ -58,7 +60,9 @@ def __str__(self) -> str: s += f"{hashes} {self.title}\n\n" s += self.content if self.subsections: - s += "\n\n" + "\n\n".join(str(subsection) for subsection in self.subsections) + s += "\n\n" + "\n\n".join( + str(subsection) for subsection in self.subsections + ) return s diff --git a/assistants/knowledge-transfer-assistant/assistant/tools/information_requests.py b/assistants/knowledge-transfer-assistant/assistant/tools/information_requests.py index 4196da531..f889ded95 100644 --- a/assistants/knowledge-transfer-assistant/assistant/tools/information_requests.py +++ b/assistants/knowledge-transfer-assistant/assistant/tools/information_requests.py @@ -72,7 +72,9 @@ async def create_information_request( else: return "Failed to create information request. Please try again." - async def resolve_information_request(self, request_id: str, resolution: str) -> str: + async def resolve_information_request( + self, request_id: str, resolution: str + ) -> str: """ Resolve an information request when you have the needed information to address it. Only use for active information requests. If there are no active information requests, this should never be called. @@ -96,7 +98,9 @@ async def resolve_information_request(self, request_id: str, resolution: str) -> "create_information_request to send requests to the Coordinator, not try to resolve them yourself. " "The Coordinator must use resolve_information_request to respond to your requests." ) - logger.warning(f"Team member attempted to use resolve_information_request: {request_id}") + logger.warning( + f"Team member attempted to use resolve_information_request: {request_id}" + ) return error_message share = await ShareManager.get_share(self.context) @@ -110,10 +114,16 @@ async def resolve_information_request(self, request_id: str, resolution: str) -> context=self.context, request_id=request_id, resolution=resolution ) if success and information_request: - return f"Information request '{information_request.title}' has been resolved." + return ( + f"Information request '{information_request.title}' has been resolved." + ) else: - logger.warning(f"Failed to resolve information request. Invalid ID provided: '{request_id}'") - return f'ERROR: Could not resolve information request with ID "{request_id}".' + logger.warning( + f"Failed to resolve information request. Invalid ID provided: '{request_id}'" + ) + return ( + f'ERROR: Could not resolve information request with ID "{request_id}".' + ) async def delete_information_request(self, request_id: str) -> str: """ @@ -132,4 +142,12 @@ async def delete_information_request(self, request_id: str) -> str: success, message = await InformationRequestManager.delete_information_request( context=self.context, request_id=request_id ) - return message if message else ("Request deleted successfully." if success else "Failed to delete request.") + return ( + message + if message + else ( + "Request deleted successfully." + if success + else "Failed to delete request." + ) + ) diff --git a/assistants/knowledge-transfer-assistant/assistant/tools/learning_objectives.py b/assistants/knowledge-transfer-assistant/assistant/tools/learning_objectives.py index 8fa90b0c4..bdce598ce 100644 --- a/assistants/knowledge-transfer-assistant/assistant/tools/learning_objectives.py +++ b/assistants/knowledge-transfer-assistant/assistant/tools/learning_objectives.py @@ -15,7 +15,9 @@ class LearningObjectiveTools(ToolsBase): """Tools for managing learning objectives and outcomes.""" - async def add_learning_objective(self, objective_name: str, description: str, learning_outcomes: List[str]) -> str: + async def add_learning_objective( + self, objective_name: str, description: str, learning_outcomes: List[str] + ) -> str: """ Add a learning objective with measurable learning outcomes. @@ -83,7 +85,11 @@ async def update_learning_objective( return ( message if message - else ("Learning objective updated successfully." if success else "Failed to update learning objective.") + else ( + "Learning objective updated successfully." + if success + else "Failed to update learning objective." + ) ) async def delete_learning_objective(self, objective_id: str) -> str: @@ -116,5 +122,9 @@ async def delete_learning_objective(self, objective_id: str) -> str: return ( message if message - else ("Learning objective deleted successfully." if success else "Failed to delete learning objective.") + else ( + "Learning objective deleted successfully." + if success + else "Failed to delete learning objective." + ) ) diff --git a/assistants/knowledge-transfer-assistant/assistant/tools/learning_outcomes.py b/assistants/knowledge-transfer-assistant/assistant/tools/learning_outcomes.py index dbaa15f36..bc07014b0 100644 --- a/assistants/knowledge-transfer-assistant/assistant/tools/learning_outcomes.py +++ b/assistants/knowledge-transfer-assistant/assistant/tools/learning_outcomes.py @@ -13,7 +13,9 @@ class LearningOutcomeTools(ToolsBase): """Tools for managing learning outcomes.""" - async def add_learning_outcome(self, objective_id: str, outcome_description: str) -> str: + async def add_learning_outcome( + self, objective_id: str, outcome_description: str + ) -> str: """ Add a new learning outcome to an existing learning objective. @@ -42,10 +44,16 @@ async def add_learning_outcome(self, objective_id: str, outcome_description: str return ( message if message - else ("Learning outcome added successfully." if success else "Failed to add learning outcome.") + else ( + "Learning outcome added successfully." + if success + else "Failed to add learning outcome." + ) ) - async def update_learning_outcome(self, outcome_id: str, new_description: str) -> str: + async def update_learning_outcome( + self, outcome_id: str, new_description: str + ) -> str: """ Update the description of an existing learning outcome. @@ -74,7 +82,11 @@ async def update_learning_outcome(self, outcome_id: str, new_description: str) - return ( message if message - else ("Learning outcome updated successfully." if success else "Failed to update learning outcome.") + else ( + "Learning outcome updated successfully." + if success + else "Failed to update learning outcome." + ) ) async def delete_learning_outcome(self, outcome_id: str) -> str: @@ -106,5 +118,9 @@ async def delete_learning_outcome(self, outcome_id: str) -> str: return ( message if message - else ("Learning outcome deleted successfully." if success else "Failed to delete learning outcome.") + else ( + "Learning outcome deleted successfully." + if success + else "Failed to delete learning outcome." + ) ) diff --git a/assistants/knowledge-transfer-assistant/assistant/tools/progress_tracking.py b/assistants/knowledge-transfer-assistant/assistant/tools/progress_tracking.py index 4c009f033..bd1be50cf 100644 --- a/assistants/knowledge-transfer-assistant/assistant/tools/progress_tracking.py +++ b/assistants/knowledge-transfer-assistant/assistant/tools/progress_tracking.py @@ -4,7 +4,7 @@ Tools for tracking learning progress and completing knowledge transfer activities. """ -from datetime import datetime +from datetime import datetime, timezone from semantic_workbench_api_model.workbench_model import ( MessageType, @@ -26,7 +26,9 @@ class ProgressTrackingTools(ToolsBase): """Tools for tracking learning progress.""" - async def mark_learning_outcome_achieved(self, objective_id: str, outcome_id: str) -> str: + async def mark_learning_outcome_achieved( + self, objective_id: str, outcome_id: str + ) -> str: """ Mark a learning outcome as achieved for tracking knowledge transfer progress. @@ -110,13 +112,19 @@ async def mark_learning_outcome_achieved(self, objective_id: str, outcome_id: st return "Team conversation not properly registered. Please contact the coordinator." # Create achievement record - achievement = LearningOutcomeAchievement(outcome_id=outcome.id, achieved=True, achieved_at=datetime.utcnow()) + achievement = LearningOutcomeAchievement( + outcome_id=outcome.id, achieved=True, achieved_at=datetime.now(timezone.utc) + ) # Add achievement to team conversation's achievements - knowledge_package.team_conversations[conversation_id].outcome_achievements.append(achievement) + knowledge_package.team_conversations[ + conversation_id + ].outcome_achievements.append(achievement) # Update team conversation's last active timestamp - knowledge_package.team_conversations[conversation_id].last_active_at = datetime.utcnow() + knowledge_package.team_conversations[ + conversation_id + ].last_active_at = datetime.now(timezone.utc) # Save the updated knowledge package with the achieved outcome await ShareManager.set_share(self.context, knowledge_package) @@ -136,7 +144,7 @@ async def mark_learning_outcome_achieved(self, objective_id: str, outcome_id: st # Update knowledge package if knowledge_package: # Update metadata - knowledge_package.updated_at = datetime.utcnow() + knowledge_package.updated_at = datetime.now(timezone.utc) knowledge_package.updated_by = current_user_id knowledge_package.version += 1 @@ -158,7 +166,9 @@ async def mark_learning_outcome_achieved(self, objective_id: str, outcome_id: st # Check if all outcomes are achieved for transfer completion # Get the knowledge package to check completion status knowledge_package = await ShareManager.get_share(self.context) - if knowledge_package and TransferManager._is_transfer_complete(knowledge_package): + if knowledge_package and TransferManager._is_transfer_complete( + knowledge_package + ): await self.context.send_messages( NewConversationMessage( content="🎉 All learning outcomes have been achieved! The knowledge transfer has been automatically marked as complete.", @@ -201,7 +211,9 @@ async def report_transfer_completion(self) -> str: return "No knowledge package found. Cannot complete transfer without package information." # Check if all outcomes are achieved - achieved_outcomes, total_outcomes = LearningObjectivesManager.get_overall_completion(share) + achieved_outcomes, total_outcomes = ( + LearningObjectivesManager.get_overall_completion(share) + ) if achieved_outcomes < total_outcomes: remaining = total_outcomes - achieved_outcomes return f"Cannot complete knowledge transfer - {remaining} learning outcomes are still pending achievement." @@ -218,7 +230,7 @@ async def report_transfer_completion(self) -> str: if not current_user_id: return "Could not identify current user." - share.updated_at = datetime.utcnow() + share.updated_at = datetime.now(timezone.utc) share.updated_by = current_user_id share.version += 1 await ShareManager.set_share(self.context, share) @@ -237,7 +249,9 @@ async def report_transfer_completion(self) -> str: share.share_id, "🎉 **Knowledge Transfer Complete**: Team has reported that all learning objectives have been achieved. The knowledge transfer is now complete.", ) - await Notifications.notify_all_state_update(self.context, share.share_id, [InspectorTab.BRIEF]) + await Notifications.notify_all_state_update( + self.context, share.share_id, [InspectorTab.BRIEF] + ) await self.context.send_messages( NewConversationMessage( diff --git a/assistants/knowledge-transfer-assistant/assistant/tools/share_setup.py b/assistants/knowledge-transfer-assistant/assistant/tools/share_setup.py index edeea9f20..dd4bb261f 100644 --- a/assistants/knowledge-transfer-assistant/assistant/tools/share_setup.py +++ b/assistants/knowledge-transfer-assistant/assistant/tools/share_setup.py @@ -35,7 +35,15 @@ async def update_audience(self, audience_description: str) -> str: audience_description=audience_description, ) - return message if message else ("Audience updated successfully." if success else "Failed to update audience.") + return ( + message + if message + else ( + "Audience updated successfully." + if success + else "Failed to update audience." + ) + ) async def set_knowledge_organized(self, is_organized: bool) -> str: """ diff --git a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/brief.py b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/brief.py index 536c27ebb..cfcf0fa35 100644 --- a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/brief.py +++ b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/brief.py @@ -35,7 +35,9 @@ def __init__(self, config_provider) -> None: async def is_enabled(self, context: ConversationContext) -> bool: return True - async def get(self, context: ConversationContext) -> AssistantConversationInspectorStateDataModel: + async def get( + self, context: ConversationContext + ) -> AssistantConversationInspectorStateDataModel: """Get brief and status information for display.""" conversation_role = await ShareManager.get_conversation_role(context) @@ -44,16 +46,22 @@ async def get(self, context: ConversationContext) -> AssistantConversationInspec share = await ShareManager.get_share(context) if not share: return AssistantConversationInspectorStateDataModel( - data={"content": "No active knowledge package. Start a conversation to create one."} + data={ + "content": "No active knowledge package. Start a conversation to create one." + } ) brief = share.brief share_info = await ShareManager.get_share(context) if conversation_role == ConversationRole.COORDINATOR: - markdown = await self._format_coordinator_brief(share.share_id, brief, share_info, context) + markdown = await self._format_coordinator_brief( + share.share_id, brief, share_info, context + ) else: - markdown = await self._format_team_brief(share.share_id, brief, share_info, context) + markdown = await self._format_team_brief( + share.share_id, brief, share_info, context + ) return AssistantConversationInspectorStateDataModel(data={"content": markdown}) @@ -95,7 +103,9 @@ async def _format_coordinator_brief( return "\n".join(lines) - async def _format_team_brief(self, share_id: str, brief: Any, share_info: Any, context: ConversationContext) -> str: + async def _format_team_brief( + self, share_id: str, brief: Any, share_info: Any, context: ConversationContext + ) -> str: """Format brief information for team members.""" lines: List[str] = [] @@ -126,7 +136,9 @@ async def _format_team_brief(self, share_id: str, brief: Any, share_info: Any, c else: lines.append("## Knowledge Brief") lines.append("") - lines.append("_The coordinator is still setting up the knowledge brief. Check back soon!_") + lines.append( + "_The coordinator is still setting up the knowledge brief. Check back soon!_" + ) lines.append("") return "\n".join(lines) diff --git a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/common.py b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/common.py index 9a7267225..1c0c2ee5a 100644 --- a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/common.py +++ b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/common.py @@ -51,11 +51,17 @@ def get_stage_label(package: KnowledgePackage, for_coordinator: bool = True) -> return "📋 Organizing Knowledge" elif not package.brief: return "📝 Creating Brief" - elif package.is_intended_to_accomplish_outcomes and not package.learning_objectives: + elif ( + package.is_intended_to_accomplish_outcomes + and not package.learning_objectives + ): return "📚 Adding Objectives" elif not TransferManager.is_ready_for_transfer(package): return "📋 Finalizing Setup" - elif package.is_intended_to_accomplish_outcomes and TransferManager._is_transfer_complete(package): + elif ( + package.is_intended_to_accomplish_outcomes + and TransferManager._is_transfer_complete(package) + ): return "✅ Transfer Complete" elif TransferManager.is_actively_sharing(package): return "📤 Sharing in Progress" diff --git a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/debug.py b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/debug.py index cee1fb7b5..caf1ca4cd 100644 --- a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/debug.py +++ b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/debug.py @@ -29,28 +29,38 @@ def __init__(self, config_provider) -> None: async def is_enabled(self, context: ConversationContext) -> bool: return True - async def get(self, context: ConversationContext) -> AssistantConversationInspectorStateDataModel: + async def get( + self, context: ConversationContext + ) -> AssistantConversationInspectorStateDataModel: """Get debug information for display.""" # Get share information share_id = await ShareManager.get_share_id(context) if not share_id: return AssistantConversationInspectorStateDataModel( - data={"content": "No active knowledge package. Start a conversation to create one."} + data={ + "content": "No active knowledge package. Start a conversation to create one." + } ) markdown = await self._format_debug_info(share_id, context) return AssistantConversationInspectorStateDataModel(data={"content": markdown}) - async def _format_debug_info(self, share_id: str, context: ConversationContext) -> str: + async def _format_debug_info( + self, share_id: str, context: ConversationContext + ) -> str: """Format debug information including knowledge digest.""" lines: List[str] = [] lines.append("## Debug Information") lines.append("") - lines.append("This panel shows internal information maintained by the assistant. This data is automatically") - lines.append("generated and updated by the assistant and is not directly editable by users.") + lines.append( + "This panel shows internal information maintained by the assistant. This data is automatically" + ) + lines.append( + "generated and updated by the assistant and is not directly editable by users." + ) lines.append("") # Get the knowledge digest @@ -59,14 +69,20 @@ async def _format_debug_info(self, share_id: str, context: ConversationContext) if not digest: lines.append("### Knowledge Digest") lines.append("") - lines.append("No knowledge digest has been generated yet. The assistant will create and update this") + lines.append( + "No knowledge digest has been generated yet. The assistant will create and update this" + ) lines.append("automatically as the conversation develops.") lines.append("") lines.append("## Knowledge Digest") lines.append("") - lines.append("The knowledge digest is an internal summary of the conversation that the assistant") - lines.append("maintains to help understand the context and key information being shared. It is") + lines.append( + "The knowledge digest is an internal summary of the conversation that the assistant" + ) + lines.append( + "maintains to help understand the context and key information being shared. It is" + ) lines.append("automatically updated as the conversation progresses.") lines.append("") @@ -78,7 +94,9 @@ async def _format_debug_info(self, share_id: str, context: ConversationContext) lines.append("```") lines.append("") else: - lines.append("_No knowledge digest has been generated yet. The assistant will create and update_") + lines.append( + "_No knowledge digest has been generated yet. The assistant will create and update_" + ) lines.append("_this automatically as the conversation develops._") lines.append("") @@ -95,8 +113,12 @@ async def _format_debug_info(self, share_id: str, context: ConversationContext) lines.append("## Share Metadata") lines.append("") lines.append(f"- **Share ID:** `{share_id}`") - lines.append(f"- **Created:** {share.created_at.strftime('%Y-%m-%d %H:%M:%S')}") - lines.append(f"- **Last Updated:** {share.updated_at.strftime('%Y-%m-%d %H:%M:%S')}") + lines.append( + f"- **Created:** {share.created_at.strftime('%Y-%m-%d %H:%M:%S')}" + ) + lines.append( + f"- **Last Updated:** {share.updated_at.strftime('%Y-%m-%d %H:%M:%S')}" + ) lines.append( f"- **Team Conversations:** {len(share.team_conversations) if share.team_conversations else 0}" ) @@ -104,10 +126,16 @@ async def _format_debug_info(self, share_id: str, context: ConversationContext) f"- **Learning Objectives:** {len(share.learning_objectives) if share.learning_objectives else 0}" ) lines.append(f"- **Knowledge Organized:** {share.knowledge_organized}") - lines.append(f"- **Ready for Transfer:** {TransferManager.is_ready_for_transfer(share)}") - lines.append(f"- **Actively Sharing:** {TransferManager.is_actively_sharing(share)}") + lines.append( + f"- **Ready for Transfer:** {TransferManager.is_ready_for_transfer(share)}" + ) + lines.append( + f"- **Actively Sharing:** {TransferManager.is_actively_sharing(share)}" + ) if share.coordinator_conversation_id: - lines.append(f"- **Conversation ID:** `{share.coordinator_conversation_id}`") + lines.append( + f"- **Conversation ID:** `{share.coordinator_conversation_id}`" + ) lines.append("") except Exception as e: diff --git a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/learning.py b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/learning.py index bbaf16b88..8639737d6 100644 --- a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/learning.py +++ b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/learning.py @@ -30,7 +30,9 @@ def __init__(self, config_provider) -> None: async def is_enabled(self, context: ConversationContext) -> bool: return True - async def get(self, context: ConversationContext) -> AssistantConversationInspectorStateDataModel: + async def get( + self, context: ConversationContext + ) -> AssistantConversationInspectorStateDataModel: """Get learning objectives and progress information.""" conversation_role = await ShareManager.get_conversation_role(context) @@ -39,7 +41,9 @@ async def get(self, context: ConversationContext) -> AssistantConversationInspec share_id = await ShareManager.get_share_id(context) if not share_id: return AssistantConversationInspectorStateDataModel( - data={"content": "No active knowledge package. Start a conversation to create one."} + data={ + "content": "No active knowledge package. Start a conversation to create one." + } ) share = await ShareManager.get_share(context) @@ -51,7 +55,9 @@ async def get(self, context: ConversationContext) -> AssistantConversationInspec return AssistantConversationInspectorStateDataModel(data={"content": markdown}) - async def _format_coordinator_objectives(self, share: Any, context: ConversationContext) -> str: + async def _format_coordinator_objectives( + self, share: Any, context: ConversationContext + ) -> str: """Format learning objectives for coordinator.""" lines: List[str] = [] @@ -69,12 +75,22 @@ async def _format_coordinator_objectives(self, share: Any, context: Conversation lines.append("") # Overall progress summary - total_outcomes = sum(len(obj.learning_outcomes) for obj in share.learning_objectives if obj.learning_outcomes) + total_outcomes = sum( + len(obj.learning_outcomes) + for obj in share.learning_objectives + if obj.learning_outcomes + ) if total_outcomes > 0 and share.team_conversations: for conv_id, team_conv in share.team_conversations.items(): - achieved, total = LearningObjectivesManager.get_completion_for_conversation(share, conv_id) + achieved, total = ( + LearningObjectivesManager.get_completion_for_conversation( + share, conv_id + ) + ) progress_pct = int((achieved / total * 100)) if total > 0 else 0 - lines.append(f"- **{team_conv.redeemer_name}**: {achieved}/{total} outcomes ({progress_pct}%)") + lines.append( + f"- **{team_conv.redeemer_name}**: {achieved}/{total} outcomes ({progress_pct}%)" + ) lines.append("") # Detailed objectives @@ -89,7 +105,9 @@ async def _format_coordinator_objectives(self, share: Any, context: Conversation for criterion in objective.learning_outcomes: # Check if any team conversation has achieved this outcome achieved_by_any = any( - LearningObjectivesManager.is_outcome_achieved_by_conversation(share, criterion.id, conv_id) + LearningObjectivesManager.is_outcome_achieved_by_conversation( + share, criterion.id, conv_id + ) for conv_id in share.team_conversations.keys() ) status_emoji = "✅" if achieved_by_any else "⬜" @@ -99,19 +117,25 @@ async def _format_coordinator_objectives(self, share: Any, context: Conversation total_team_count = len(share.team_conversations) for conv_id in share.team_conversations.keys(): - if LearningObjectivesManager.is_outcome_achieved_by_conversation(share, criterion.id, conv_id): + if LearningObjectivesManager.is_outcome_achieved_by_conversation( + share, criterion.id, conv_id + ): achieved_count += 1 achievement_info = "" if total_team_count > 0: achievement_info = f" ({achieved_count}/{total_team_count})" - lines.append(f"- {status_emoji} {criterion.description}{achievement_info}") + lines.append( + f"- {status_emoji} {criterion.description}{achievement_info}" + ) lines.append("") return "\n".join(lines) - async def _format_team_objectives(self, share: Any, context: ConversationContext) -> str: + async def _format_team_objectives( + self, share: Any, context: ConversationContext + ) -> str: """Format learning objectives for team members.""" lines: List[str] = [] @@ -130,11 +154,17 @@ async def _format_team_objectives(self, share: Any, context: ConversationContext # Show my personal progress conversation_id = str(context.id) - achieved_outcomes, total_outcomes = LearningObjectivesManager.get_completion_for_conversation( - share, conversation_id + achieved_outcomes, total_outcomes = ( + LearningObjectivesManager.get_completion_for_conversation( + share, conversation_id + ) + ) + progress_pct = ( + int((achieved_outcomes / total_outcomes * 100)) if total_outcomes > 0 else 0 + ) + lines.append( + f"**My Progress:** {achieved_outcomes}/{total_outcomes} outcomes achieved ({progress_pct}%)" ) - progress_pct = int((achieved_outcomes / total_outcomes * 100)) if total_outcomes > 0 else 0 - lines.append(f"**My Progress:** {achieved_outcomes}/{total_outcomes} outcomes achieved ({progress_pct}%)") lines.append("") for objective in share.learning_objectives: @@ -146,23 +176,32 @@ async def _format_team_objectives(self, share: Any, context: ConversationContext lines.append("#### Learning Outcomes") for criterion in objective.learning_outcomes: # Check if I've achieved this outcome - achieved_by_me = LearningObjectivesManager.is_outcome_achieved_by_conversation( - share, criterion.id, conversation_id + achieved_by_me = ( + LearningObjectivesManager.is_outcome_achieved_by_conversation( + share, criterion.id, conversation_id + ) ) status_emoji = "✅" if achieved_by_me else "⬜" completion_info = "" if achieved_by_me: # Find my achievement record - my_achievements = LearningObjectivesManager.get_achievements_for_conversation( - share, conversation_id + my_achievements = ( + LearningObjectivesManager.get_achievements_for_conversation( + share, conversation_id + ) ) for achievement in my_achievements: - if achievement.outcome_id == criterion.id and achievement.achieved: + if ( + achievement.outcome_id == criterion.id + and achievement.achieved + ): completion_info = f" (achieved on {achievement.achieved_at.strftime('%Y-%m-%d')})" break - lines.append(f"- {status_emoji} {criterion.description}{completion_info}") + lines.append( + f"- {status_emoji} {criterion.description}{completion_info}" + ) lines.append("") return "\n".join(lines) diff --git a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/sharing.py b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/sharing.py index b2140a16e..b32544c8b 100644 --- a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/sharing.py +++ b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/sharing.py @@ -36,7 +36,9 @@ def __init__(self, config_provider) -> None: async def is_enabled(self, context: ConversationContext) -> bool: return True - async def get(self, context: ConversationContext) -> AssistantConversationInspectorStateDataModel: + async def get( + self, context: ConversationContext + ) -> AssistantConversationInspectorStateDataModel: """Get information requests for display.""" conversation_role = await ShareManager.get_conversation_role(context) @@ -45,7 +47,9 @@ async def get(self, context: ConversationContext) -> AssistantConversationInspec share_id = await ShareManager.get_share_id(context) if not share_id: return AssistantConversationInspectorStateDataModel( - data={"content": "No active knowledge package. Start a conversation to create one."} + data={ + "content": "No active knowledge package. Start a conversation to create one." + } ) requests = await InformationRequestManager.get_information_requests(context) @@ -57,7 +61,9 @@ async def get(self, context: ConversationContext) -> AssistantConversationInspec return AssistantConversationInspectorStateDataModel(data={"content": markdown}) - async def _format_coordinator_requests(self, requests: List[Any], context: ConversationContext) -> str: + async def _format_coordinator_requests( + self, requests: List[Any], context: ConversationContext + ) -> str: """Format sharing information and requests for coordinator.""" lines: List[str] = [] @@ -71,12 +77,18 @@ async def _format_coordinator_requests(self, requests: List[Any], context: Conve lines.append("**Share this link with your team members:**") lines.append(f"[Knowledge Transfer link]({share_url})") lines.append("") - lines.append("The link never expires and can be used by multiple team members.") + lines.append( + "The link never expires and can be used by multiple team members." + ) lines.append("") # Filter requests by status - pending_requests = [req for req in requests if req.status != RequestStatus.RESOLVED] - resolved_requests = [req for req in requests if req.status == RequestStatus.RESOLVED] + pending_requests = [ + req for req in requests if req.status != RequestStatus.RESOLVED + ] + resolved_requests = [ + req for req in requests if req.status == RequestStatus.RESOLVED + ] lines.append("## Information Requests") lines.append("") @@ -107,7 +119,9 @@ async def _format_coordinator_requests(self, requests: List[Any], context: Conve if not pending_requests and not resolved_requests: lines.append("No information requests yet.") lines.append("") - lines.append("_Team members can ask questions and create information requests that will appear here._") + lines.append( + "_Team members can ask questions and create information requests that will appear here._" + ) # Team summary if share and share.team_conversations: @@ -116,16 +130,28 @@ async def _format_coordinator_requests(self, requests: List[Any], context: Conve lines.append("") for conv_id, team_conv in share.team_conversations.items(): - achieved, total = LearningObjectivesManager.get_completion_for_conversation(share, conv_id) + achieved, total = ( + LearningObjectivesManager.get_completion_for_conversation( + share, conv_id + ) + ) progress_pct = int((achieved / total * 100)) if total > 0 else 0 - lines.append(f"- **{team_conv.redeemer_name}**: {achieved}/{total} outcomes ({progress_pct}%)") - lines.append(f" Joined: {team_conv.joined_at.strftime('%Y-%m-%d %H:%M')}") - lines.append(f" Last active: {team_conv.last_active_at.strftime('%Y-%m-%d %H:%M')}") + lines.append( + f"- **{team_conv.redeemer_name}**: {achieved}/{total} outcomes ({progress_pct}%)" + ) + lines.append( + f" Joined: {team_conv.joined_at.strftime('%Y-%m-%d %H:%M')}" + ) + lines.append( + f" Last active: {team_conv.last_active_at.strftime('%Y-%m-%d %H:%M')}" + ) lines.append("") return "\n".join(lines) - async def _format_team_requests(self, requests: List[Any], context: ConversationContext) -> str: + async def _format_team_requests( + self, requests: List[Any], context: ConversationContext + ) -> str: """Format sharing information and requests for team members.""" lines: List[str] = [] diff --git a/assistants/knowledge-transfer-assistant/assistant/utils.py b/assistants/knowledge-transfer-assistant/assistant/utils.py index bbd0c4198..fa86bdf23 100644 --- a/assistants/knowledge-transfer-assistant/assistant/utils.py +++ b/assistants/knowledge-transfer-assistant/assistant/utils.py @@ -74,7 +74,9 @@ async def get_current_user_id(context: ConversationContext) -> Optional[str]: return user_id -async def require_current_user(context: ConversationContext, operation_name: str) -> Optional[str]: +async def require_current_user( + context: ConversationContext, operation_name: str +) -> Optional[str]: """ Extract the current user ID and log an error if none is found. diff --git a/assistants/knowledge-transfer-assistant/pyproject.toml b/assistants/knowledge-transfer-assistant/pyproject.toml index 967017c3a..5d77f8357 100644 --- a/assistants/knowledge-transfer-assistant/pyproject.toml +++ b/assistants/knowledge-transfer-assistant/pyproject.toml @@ -21,6 +21,9 @@ dev = [ "pyright>=1.1.389", ] +[tool.hatch.build.targets.wheel] +packages = ["assistant"] + [tool.uv] package = true @@ -44,3 +47,19 @@ log_cli_level = "WARNING" log_cli_format = "%(asctime)s | %(levelname)-7s | %(name)s | %(message)s" asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" + +# These ruff settings match /ruff.toml +# but include import sorting in the lint section. +# Leaving them all here for debugging temporarily. + +[tool.ruff] +line-length = 120 +target-version = "py311" + +[tool.ruff.lint] +select = ["E4", "E7", "E9", "F", "I"] + +[tool.ruff.format] +docstring-code-format = true +line-ending = "lf" +preview = true diff --git a/assistants/knowledge-transfer-assistant/tests/test_artifact_loading.py b/assistants/knowledge-transfer-assistant/tests/test_artifact_loading.py index 6db5fe71f..27dd271d8 100644 --- a/assistants/knowledge-transfer-assistant/tests/test_artifact_loading.py +++ b/assistants/knowledge-transfer-assistant/tests/test_artifact_loading.py @@ -10,6 +10,8 @@ import uuid from typing import Any, TypeVar +from semantic_workbench_assistant import settings + from assistant.data import ( ConversationRole, KnowledgeBrief, @@ -19,7 +21,6 @@ ) from assistant.domain import KnowledgeBriefManager, ShareManager from assistant.storage import ShareStorage, ShareStorageManager -from semantic_workbench_assistant import settings # Type variable for better type annotations T = TypeVar("T") @@ -30,7 +31,9 @@ class TestShareStorage(unittest.IsolatedAsyncioTestCase): async def asyncSetUp(self): # Create a test storage path - self.test_dir = pathlib.Path(__file__).parent.parent / ".data" / "test_project_storage" + self.test_dir = ( + pathlib.Path(__file__).parent.parent / ".data" / "test_project_storage" + ) self.test_dir.mkdir(exist_ok=True, parents=True) # Mock settings to use our test directory @@ -73,7 +76,9 @@ def mock_storage_directory_for_context(context, *args, **kwargs): async def mock_get_share_id(context): return self.share_id - patch2 = unittest.mock.patch.object(ShareManager, "get_share_id", side_effect=mock_get_share_id) + patch2 = unittest.mock.patch.object( + ShareManager, "get_share_id", side_effect=mock_get_share_id + ) self.mock_get_project = patch2.start() self.patches.append(patch2) @@ -136,7 +141,9 @@ def create_test_brief(self): async def test_get_project_brief(self) -> None: """Test that get_project_brief correctly loads the brief from storage""" # Mock the KnowledgeTransferManager to use our test context - with unittest.mock.patch.object(ShareManager, "get_share_id", return_value=self.share_id): + with unittest.mock.patch.object( + ShareManager, "get_share_id", return_value=self.share_id + ): # Using Any here to satisfy type checker with our mock context: Any = self.context @@ -151,7 +158,9 @@ async def test_get_project_brief(self) -> None: # Verify the project goals were loaded correctly self.assertIsNotNone(project, "Should load the project") if project: # Type checking guard - self.assertEqual(len(project.learning_objectives), 1, "Should have one goal") + self.assertEqual( + len(project.learning_objectives), 1, "Should have one goal" + ) self.assertEqual(project.learning_objectives[0].name, "Test Goal") async def test_direct_storage_access(self) -> None: diff --git a/assistants/knowledge-transfer-assistant/tests/test_inspector.py b/assistants/knowledge-transfer-assistant/tests/test_inspector.py index 3478cb0ed..0f9434e7b 100644 --- a/assistants/knowledge-transfer-assistant/tests/test_inspector.py +++ b/assistants/knowledge-transfer-assistant/tests/test_inspector.py @@ -6,10 +6,11 @@ import logging from unittest.mock import AsyncMock, MagicMock -from assistant.assistant import assistant from semantic_workbench_api_model.workbench_model import AssistantStateEvent from semantic_workbench_assistant.assistant_app import ConversationContext +from assistant.assistant import assistant + # Set up logging logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) @@ -44,7 +45,9 @@ async def test_inspector(): logger.info(f"Testing {inspector_id} inspector...") # Create state event - state_event = AssistantStateEvent(state_id=inspector_id, event="focus", state=None) + state_event = AssistantStateEvent( + state_id=inspector_id, event="focus", state=None + ) # Send event logger.info("Sending state event...") diff --git a/assistants/knowledge-transfer-assistant/tests/test_share_manager.py b/assistants/knowledge-transfer-assistant/tests/test_share_manager.py index 3184d9f8e..99fcc2469 100644 --- a/assistants/knowledge-transfer-assistant/tests/test_share_manager.py +++ b/assistants/knowledge-transfer-assistant/tests/test_share_manager.py @@ -5,13 +5,14 @@ from unittest.mock import AsyncMock, MagicMock import pytest +from semantic_workbench_assistant.assistant_app import ConversationContext + from assistant.data import ( KnowledgePackage, LearningObjective, LearningOutcome, ) from assistant.domain import LearningObjectivesManager -from semantic_workbench_assistant.assistant_app import ConversationContext class TestKnowledgeTransferManager: @@ -135,7 +136,9 @@ def mock_write_share_info(proj_id, package): nonlocal write_project_info_called assert proj_id == project_id # Verify package was updated - assert package.achieved_outcomes == 0 # Completed criterion was in the deleted goal + assert ( + package.achieved_outcomes == 0 + ) # Completed criterion was in the deleted goal assert package.total_outcomes == 0 # All criteria were in the deleted goal # Note: completion_percentage removed from model assert package.version == 2 # Incremented @@ -186,7 +189,9 @@ async def mock_refresh_all_project_uis(*args, **kwargs): ( success, goal_name_result, - ) = await LearningObjectivesManager.delete_learning_objective(context, objective_index) + ) = await LearningObjectivesManager.delete_learning_objective( + context, objective_index + ) # Verify the result assert success is True @@ -201,7 +206,9 @@ async def mock_refresh_all_project_uis(*args, **kwargs): # DISABLED: delete_project_goal functionality has been removed from the codebase # @pytest.mark.asyncio - async def disabled_test_delete_project_goal_invalid_index(self, context, monkeypatch): + async def disabled_test_delete_project_goal_invalid_index( + self, context, monkeypatch + ): """Test deleting a goal with an invalid index.""" # Setup project_id = "test-project-id" @@ -262,7 +269,9 @@ def mock_read_project(proj_id): ( success, error_message, - ) = await LearningObjectivesManager.delete_learning_objective(context, objective_index) + ) = await LearningObjectivesManager.delete_learning_objective( + context, objective_index + ) # Verify the result indicates failure with appropriate error message assert success is False @@ -287,7 +296,9 @@ async def mock_get_project_id(*args, **kwargs): ( success, error_message, - ) = await LearningObjectivesManager.delete_learning_objective(context, "test-objective-id") + ) = await LearningObjectivesManager.delete_learning_objective( + context, "test-objective-id" + ) # Verify the result indicates failure with appropriate error message assert success is False diff --git a/assistants/knowledge-transfer-assistant/tests/test_share_storage.py b/assistants/knowledge-transfer-assistant/tests/test_share_storage.py index d17546be5..80fe0aef9 100644 --- a/assistants/knowledge-transfer-assistant/tests/test_share_storage.py +++ b/assistants/knowledge-transfer-assistant/tests/test_share_storage.py @@ -9,6 +9,9 @@ import uuid from datetime import datetime +from semantic_workbench_api_model.workbench_model import AssistantStateEvent +from semantic_workbench_assistant import settings + from assistant.data import ( ConversationRole, CoordinatorConversationMessage, @@ -29,8 +32,6 @@ from assistant.domain.share_manager import ShareManager from assistant.notifications import Notifications from assistant.storage import ShareStorage, ShareStorageManager -from semantic_workbench_api_model.workbench_model import AssistantStateEvent -from semantic_workbench_assistant import settings class TestShareStorage(unittest.IsolatedAsyncioTestCase): @@ -39,7 +40,9 @@ class TestShareStorage(unittest.IsolatedAsyncioTestCase): async def asyncSetUp(self): """Set up test environment.""" # Create a test directory - self.test_dir = pathlib.Path(__file__).parent.parent / ".data" / "test_project_storage" + self.test_dir = ( + pathlib.Path(__file__).parent.parent / ".data" / "test_project_storage" + ) self.test_dir.mkdir(parents=True, exist_ok=True) # Mock settings to use our test directory @@ -79,7 +82,9 @@ async def asyncSetUp(self): # Mock get_participants with the correct structure participants_mock = unittest.mock.MagicMock() participants_mock.participants = [] - self.context.get_participants = unittest.mock.AsyncMock(return_value=participants_mock) + self.context.get_participants = unittest.mock.AsyncMock( + return_value=participants_mock + ) # Patch storage_directory_for_context def mock_storage_directory_for_context(context, *args, **kwargs): @@ -219,16 +224,22 @@ async def test_write_project_log(self): self.assertIsNotNone(log, "Should load the log") if log: # Type checking guard self.assertEqual(len(log.entries), 1) - self.assertEqual(log.entries[0].entry_type, LogEntryType.SHARE_INFORMATION_UPDATE) + self.assertEqual( + log.entries[0].entry_type, LogEntryType.SHARE_INFORMATION_UPDATE + ) self.assertEqual(log.entries[0].message, "Test log entry") async def test_project_directory_structure(self): """Test the project directory structure.""" # Verify project directory exists - self.assertTrue(self.project_dir.exists(), "KnowledgePackage directory should exist") + self.assertTrue( + self.project_dir.exists(), "KnowledgePackage directory should exist" + ) # Verify Coordinator directory exists - self.assertTrue(self.coordinator_dir.exists(), "Coordinator directory should exist") + self.assertTrue( + self.coordinator_dir.exists(), "Coordinator directory should exist" + ) # Verify team directory exists self.assertTrue(self.team_dir.exists(), "Team directory should exist") @@ -295,7 +306,9 @@ async def test_append_coordinator_message(self): storage = ShareStorage.read_coordinator_conversation(self.share_id) # Verify messages were added - self.assertIsNotNone(storage, "Should create and load the coordinator conversation") + self.assertIsNotNone( + storage, "Should create and load the coordinator conversation" + ) if storage: self.assertEqual(len(storage.messages), 2) self.assertEqual(storage.messages[0].content, "First message") @@ -406,11 +419,15 @@ async def test_knowledge_package_info(self): updated_package = ShareStorage.read_share(self.share_id) # Verify updates were saved - self.assertIsNotNone(updated_package, "Should load updated knowledge package") + self.assertIsNotNone( + updated_package, "Should load updated knowledge package" + ) if updated_package: self.assertEqual(updated_package.transfer_notes, "Test status message") # Note: completion_percentage removed from model - self.assertEqual(updated_package.next_learning_actions, ["Action 1", "Action 2"]) + self.assertEqual( + updated_package.next_learning_actions, ["Action 1", "Action 2"] + ) async def test_conversation_tracking_in_json(self): """Test that conversations are tracked in JSON instead of file system.""" @@ -423,18 +440,26 @@ async def test_conversation_tracking_in_json(self): self.assertIsInstance(package.team_conversations, dict) # Verify helper methods work - linked_conversations = await ShareManager.get_linked_conversations(self.context) + linked_conversations = await ShareManager.get_linked_conversations( + self.context + ) self.assertIsInstance(linked_conversations, list) async def test_conversation_association(self): """Test conversation role setting.""" # Mock ShareManager.set_conversation_role - with unittest.mock.patch("assistant.domain.share_manager.write_model") as mock_write_model: + with unittest.mock.patch( + "assistant.domain.share_manager.write_model" + ) as mock_write_model: # Mock conversation role path - conversation_role_file = ShareStorageManager.get_conversation_role_file_path(self.context) + conversation_role_file = ( + ShareStorageManager.get_conversation_role_file_path(self.context) + ) # Call set_conversation_role - await ShareManager.set_conversation_role(self.context, self.share_id, ConversationRole.COORDINATOR) + await ShareManager.set_conversation_role( + self.context, self.share_id, ConversationRole.COORDINATOR + ) # Verify write_model was called mock_write_model.assert_called_once() @@ -475,7 +500,9 @@ async def test_log_project_event(self): for entry in read_log.entries: if entry.message == "Test direct log entry": found_entry = True - self.assertEqual(entry.entry_type, LogEntryType.SHARE_INFORMATION_UPDATE) + self.assertEqual( + entry.entry_type, LogEntryType.SHARE_INFORMATION_UPDATE + ) self.assertEqual(entry.user_id, self.user_id) self.assertEqual(entry.user_name, "Test User") self.assertEqual(entry.related_entity_id, "test-entity-id") diff --git a/assistants/knowledge-transfer-assistant/tests/test_share_tools.py b/assistants/knowledge-transfer-assistant/tests/test_share_tools.py index 6c79a0682..feea3311c 100644 --- a/assistants/knowledge-transfer-assistant/tests/test_share_tools.py +++ b/assistants/knowledge-transfer-assistant/tests/test_share_tools.py @@ -7,10 +7,11 @@ import openai_client import pytest +from semantic_workbench_assistant.assistant_app import ConversationContext + from assistant.agentic.analysis import detect_information_request_needs from assistant.data import ConversationRole from assistant.tools import ShareTools -from semantic_workbench_assistant.assistant_app import ConversationContext # Use pytest for all tests for consistency @@ -40,11 +41,20 @@ def test_initialization(self, context): # Verify Coordinator-specific functions are registered assert "update_brief" in coordinator_tools.tool_functions.function_map - assert "resolve_information_request" in coordinator_tools.tool_functions.function_map + assert ( + "resolve_information_request" + in coordinator_tools.tool_functions.function_map + ) # Verify Team-specific functions are NOT registered - assert "create_information_request" not in coordinator_tools.tool_functions.function_map - assert "delete_information_request" not in coordinator_tools.tool_functions.function_map + assert ( + "create_information_request" + not in coordinator_tools.tool_functions.function_map + ) + assert ( + "delete_information_request" + not in coordinator_tools.tool_functions.function_map + ) # Test Team role team_tools = ShareTools(context, ConversationRole.TEAM) @@ -57,10 +67,15 @@ def test_initialization(self, context): # Verify Coordinator-specific functions are NOT registered assert "update_brief" not in team_tools.tool_functions.function_map - assert "resolve_information_request" not in team_tools.tool_functions.function_map + assert ( + "resolve_information_request" not in team_tools.tool_functions.function_map + ) # detect_information_request_needs is not exposed as a tool function anymore - assert "detect_information_request_needs" not in team_tools.tool_functions.function_map + assert ( + "detect_information_request_needs" + not in team_tools.tool_functions.function_map + ) @pytest.mark.asyncio async def test_project_tools_with_config(self, context, monkeypatch): @@ -118,11 +133,15 @@ async def check_tools_with_config(context, role): return tools # Get the tools using our function that checks track_progress - project_tools = await check_tools_with_config(context, ConversationRole.COORDINATOR) + project_tools = await check_tools_with_config( + context, ConversationRole.COORDINATOR + ) # Verify basic tools are still available regardless of track_progress setting assert "update_brief" in project_tools.tool_functions.function_map - assert "resolve_information_request" in project_tools.tool_functions.function_map + assert ( + "resolve_information_request" in project_tools.tool_functions.function_map + ) # For team tools team_tools = await check_tools_with_config(context, ConversationRole.TEAM) @@ -143,7 +162,9 @@ async def test_detect_information_request_needs(self, context, monkeypatch): # Setup mock config to be returned from assistant_config.get mock_config = MagicMock() mock_config.track_progress = True - mock_config.service_config = None # Will cause the method to return early with error info + mock_config.service_config = ( + None # Will cause the method to return early with error info + ) async def mock_get_config(*args, **kwargs): return mock_config @@ -151,7 +172,9 @@ async def mock_get_config(*args, **kwargs): # Patch assistant_config.get mock_assistant_config = MagicMock() mock_assistant_config.get = AsyncMock(side_effect=mock_get_config) - monkeypatch.setattr("assistant.agentic.analysis.assistant_config", mock_assistant_config) + monkeypatch.setattr( + "assistant.agentic.analysis.assistant_config", mock_assistant_config + ) # Create a mock message for the message history mock_msg = MagicMock() @@ -247,7 +270,9 @@ async def mock_delete_project_goal(*args, **kwargs): # assert f"Goal '{goal_name}' has been successfully deleted from the project." in result # Verify that context.send_messages was called with appropriate message - expected_message_content = f"Goal '{goal_name}' has been successfully deleted from the project." + expected_message_content = ( + f"Goal '{goal_name}' has been successfully deleted from the project." + ) context.send_messages.assert_called_once() # Get the first positional argument passed to send_messages call_args = context.send_messages.call_args[0][0] diff --git a/assistants/knowledge-transfer-assistant/tests/test_team_mode.py b/assistants/knowledge-transfer-assistant/tests/test_team_mode.py index 0111cafbb..3ee7704b9 100644 --- a/assistants/knowledge-transfer-assistant/tests/test_team_mode.py +++ b/assistants/knowledge-transfer-assistant/tests/test_team_mode.py @@ -89,7 +89,9 @@ def __init__( class InformationRequest(BaseArtifact): - def __init__(self, title=None, description=None, priority=None, status=None, **kwargs): + def __init__( + self, title=None, description=None, priority=None, status=None, **kwargs + ): super().__init__(artifact_type=ArtifactType.INFORMATION_REQUEST, **kwargs) self.title = title or "Test Request" self.description = description or "Test Description" @@ -129,7 +131,9 @@ def __init__(self, id=None, description=None, completed=False, completed_by=None class LearningObjective: - def __init__(self, id=None, name=None, description=None, priority=1, success_criteria=None): + def __init__( + self, id=None, name=None, description=None, priority=1, success_criteria=None + ): self.id = id or "test-goal-id" self.name = name or "Test Goal" self.description = description or "Test Goal Description" @@ -151,7 +155,9 @@ def __init__(self, context): self.context = context self.log_action = AsyncMock() - async def create_information_request(self, title, description, priority=RequestPriority.MEDIUM): + async def create_information_request( + self, title, description, priority=RequestPriority.MEDIUM + ): # Mock implementation request = InformationRequest( title=title, @@ -194,7 +200,9 @@ async def update_project_info(self, progress_percentage, status_message=None): dashboard.transfer_notes = status_message # Call mocked log_action for state change and progress update - await self.log_action(LogEntryType.MILESTONE_PASSED, "KnowledgePackage is now in progress") + await self.log_action( + LogEntryType.MILESTONE_PASSED, "KnowledgePackage is now in progress" + ) await self.log_action( LogEntryType.STATUS_CHANGED, @@ -262,7 +270,9 @@ async def report_project_completion(self, completion_summary): dashboard.transfer_notes = completion_summary # Call mocked log_action - await self.log_action(LogEntryType.PROJECT_COMPLETED, "KnowledgePackage marked as completed") + await self.log_action( + LogEntryType.PROJECT_COMPLETED, "KnowledgePackage marked as completed" + ) # Send notification await self.context.send_messages( @@ -345,7 +355,9 @@ async def test_create_information_request(self, team_handler, mock_context): async def test_update_project_info(self, team_handler, mock_context): """Test updating the project information.""" # Call the method - success, message, dashboard = await team_handler.update_project_info(50, "Making progress in the team") + success, message, dashboard = await team_handler.update_project_info( + 50, "Making progress in the team" + ) # Assertions assert success is True @@ -369,7 +381,9 @@ async def test_mark_criterion_completed(self, team_handler, mock_context): success, message, updated_dashboard, - ) = await team_handler.mark_criterion_completed("test-goal-id", "test-criterion-id") + ) = await team_handler.mark_criterion_completed( + "test-goal-id", "test-criterion-id" + ) # Assertions assert success is True @@ -400,7 +414,8 @@ async def test_report_project_completion(self, team_handler, mock_context): assert dashboard.state == ProjectState.COMPLETED # Note: completion_percentage removed from model assert ( - dashboard.transfer_notes == "KnowledgePackage has been successfully completed with all objectives achieved." + dashboard.transfer_notes + == "KnowledgePackage has been successfully completed with all objectives achieved." ) # Verify that a notification was sent From 74761d0f24420762eae567068b39b4fd36b5fe90 Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Tue, 29 Jul 2025 13:10:35 -0700 Subject: [PATCH 10/21] Better error handling in domain and tools. --- .../assistant/agentic/analysis.py | 30 +- .../assistant/agentic/coordinator_support.py | 20 +- .../assistant/agentic/team_welcome.py | 21 +- .../assistant/assistant.py | 101 ++--- .../assistant/config.py | 4 +- .../assistant/conversation_clients.py | 12 +- .../assistant/data.py | 8 +- .../assistant/domain/audience_manager.py | 67 +-- .../domain/information_request_manager.py | 385 +++++++----------- .../domain/knowledge_brief_manager.py | 21 +- .../domain/knowledge_digest_manager.py | 231 +++++------ .../domain/learning_objectives_manager.py | 266 ++++-------- .../assistant/domain/share_manager.py | 217 ++++------ .../assistant/domain/transfer_manager.py | 13 +- .../assistant/errors.py | 21 + .../assistant/files.py | 86 ++-- .../assistant/logging.py | 18 +- .../assistant/notifications.py | 34 +- .../assistant/respond.py | 143 ++----- .../assistant/storage.py | 12 +- .../assistant/string_utils.py | 8 +- .../assistant/tools/information_requests.py | 93 ++--- .../assistant/tools/learning_objectives.py | 63 +-- .../assistant/tools/learning_outcomes.py | 86 ++-- .../assistant/tools/progress_tracking.py | 286 ++++++------- .../assistant/tools/share_setup.py | 117 ++---- .../assistant/ui_tabs/brief.py | 24 +- .../assistant/ui_tabs/common.py | 10 +- .../assistant/ui_tabs/debug.py | 56 +-- .../assistant/ui_tabs/learning.py | 79 +--- .../assistant/ui_tabs/sharing.py | 50 +-- .../assistant/utils.py | 45 +- .../tests/test_artifact_loading.py | 16 +- .../tests/test_inspector.py | 4 +- .../tests/test_share_manager.py | 20 +- .../tests/test_share_storage.py | 52 +-- .../tests/test_share_tools.py | 44 +- .../tests/test_team_mode.py | 31 +- 38 files changed, 931 insertions(+), 1863 deletions(-) create mode 100644 assistants/knowledge-transfer-assistant/assistant/errors.py diff --git a/assistants/knowledge-transfer-assistant/assistant/agentic/analysis.py b/assistants/knowledge-transfer-assistant/assistant/agentic/analysis.py index f2e942d95..14125accb 100644 --- a/assistants/knowledge-transfer-assistant/assistant/agentic/analysis.py +++ b/assistants/knowledge-transfer-assistant/assistant/agentic/analysis.py @@ -16,9 +16,7 @@ from assistant.logging import logger -async def detect_information_request_needs( - context: ConversationContext, message: str -) -> Dict[str, Any]: +async def detect_information_request_needs(context: ConversationContext, message: str) -> Dict[str, Any]: """ Analyze a user message in context of recent chat history to detect potential information request needs. Uses an LLM for sophisticated detection. @@ -75,9 +73,7 @@ async def detect_information_request_needs( # Add to chat history role = "user" if sender_name == "Team Member" else "assistant" - chat_history.append( - {"role": role, "content": f"{sender_name}: {msg.content}"} - ) + chat_history.append({"role": role, "content": f"{sender_name}: {msg.content}"}) # Reverse to get chronological order chat_history.reverse() @@ -99,17 +95,13 @@ async def detect_information_request_needs( # Add chat history if available if chat_history: for history_msg in chat_history: - messages.append( - {"role": history_msg["role"], "content": history_msg["content"]} - ) + messages.append({"role": history_msg["role"], "content": history_msg["content"]}) # Add the current message for analysis - explicitly mark as the latest message - messages.append( - { - "role": "user", - "content": f"Latest message from Team Member: {message}", - } - ) + messages.append({ + "role": "user", + "content": f"Latest message from Team Member: {message}", + }) completion_args = { "model": "gpt-3.5-turbo", @@ -118,9 +110,7 @@ async def detect_information_request_needs( "max_tokens": 500, "temperature": 0.2, # Low temperature for more consistent analysis } - debug["completion_args"] = openai_client.make_completion_args_serializable( - completion_args - ) + debug["completion_args"] = openai_client.make_completion_args_serializable(completion_args) # Make the API call response = await client.chat.completions.create( @@ -136,9 +126,7 @@ async def detect_information_request_needs( result["original_message"] = message return result except json.JSONDecodeError: - logger.warning( - f"Failed to parse JSON from LLM response: {response.choices[0].message.content}" - ) + logger.warning(f"Failed to parse JSON from LLM response: {response.choices[0].message.content}") return { "is_information_request": False, "reason": "Failed to parse LLM response", diff --git a/assistants/knowledge-transfer-assistant/assistant/agentic/coordinator_support.py b/assistants/knowledge-transfer-assistant/assistant/agentic/coordinator_support.py index 35ceefa0f..d59a43063 100644 --- a/assistants/knowledge-transfer-assistant/assistant/agentic/coordinator_support.py +++ b/assistants/knowledge-transfer-assistant/assistant/agentic/coordinator_support.py @@ -61,10 +61,7 @@ async def get_coordinator_next_action_suggestion( return "Your package needs a short introduction that will orient your team. Let's write a knowledge brief next. The knowledge brief helps your team understand the purpose of this knowledge transfer and will be visible to all team members in their side panel." # 5. If intended to have outcomes but none defined yet - if ( - package.is_intended_to_accomplish_outcomes - and not package.learning_objectives - ): + if package.is_intended_to_accomplish_outcomes and not package.learning_objectives: return ( "Would you like your team to achieve any specific outcomes? If so, let's define some learning objectives. " "If not, you can mark this package as 'exploratory' instead." @@ -72,27 +69,18 @@ async def get_coordinator_next_action_suggestion( # 6. Objectives exist, but missing outcomes if package.is_intended_to_accomplish_outcomes: - incomplete_objectives = [ - obj - for obj in package.learning_objectives - if not obj.learning_outcomes - ] + incomplete_objectives = [obj for obj in package.learning_objectives if not obj.learning_outcomes] if incomplete_objectives: name = incomplete_objectives[0].name return f"The learning objective '{name}' doesn't have any outcomes yet. Let's define what your team should accomplish to meet it." # 7. Ready for transfer but not yet shared - if TransferManager.is_ready_for_transfer( - package - ) and not TransferManager.is_actively_sharing(package): + if TransferManager.is_ready_for_transfer(package) and not TransferManager.is_actively_sharing(package): return "Your knowledge package is ready to share. Would you like to create a message and generate the invitation link?" # 8. Actively sharing - monitor and support ongoing transfer if TransferManager.is_actively_sharing(package): - if ( - package.is_intended_to_accomplish_outcomes - and not TransferManager._is_transfer_complete(package) - ): + if package.is_intended_to_accomplish_outcomes and not TransferManager._is_transfer_complete(package): team_count = len(package.team_conversations) return f"Great! Your knowledge is being shared with {team_count} team member{'s' if team_count != 1 else ''}. You can continue improving the package or respond to information requests as they come in." else: diff --git a/assistants/knowledge-transfer-assistant/assistant/agentic/team_welcome.py b/assistants/knowledge-transfer-assistant/assistant/agentic/team_welcome.py index c607f9ba0..d3b539822 100644 --- a/assistants/knowledge-transfer-assistant/assistant/agentic/team_welcome.py +++ b/assistants/knowledge-transfer-assistant/assistant/agentic/team_welcome.py @@ -27,11 +27,10 @@ async def generate_team_welcome_message( config = await assistant_config.get(context.assistant) - share = await ShareManager.get_share(context) - if not share: - logger.warning( - "No active knowledge package found for welcome message generation" - ) + try: + share = await ShareManager.get_share(context) + except Exception as e: + logger.error(f"Failed to get share for welcome message generation: {e}") return config.team_config.default_welcome_message, debug share_data: dict[str, str] = {} @@ -92,13 +91,9 @@ async def generate_team_welcome_message( try: # Chat completion async with openai_client.create_client(config.service_config) as client: - share_info = "\n\n## KNOWLEDGE SHARE INFORMATION\n\n" + "\n".join( - share_data.values() - ) + share_info = "\n\n## KNOWLEDGE SHARE INFORMATION\n\n" + "\n".join(share_data.values()) - instructions = ( - f"{config.prompt_config.welcome_message_generation}\n\n{share_info}" - ) + instructions = f"{config.prompt_config.welcome_message_generation}\n\n{share_info}" messages: List[ChatCompletionMessageParam] = [ {"role": "system", "content": instructions}, ] @@ -109,9 +104,7 @@ async def generate_team_welcome_message( "max_tokens": 500, "temperature": 0.7, # Low temperature for more consistent analysis } - debug["completion_args"] = openai_client.make_completion_args_serializable( - completion_args - ) + debug["completion_args"] = openai_client.make_completion_args_serializable(completion_args) # LLM call response = await client.chat.completions.create( diff --git a/assistants/knowledge-transfer-assistant/assistant/assistant.py b/assistants/knowledge-transfer-assistant/assistant/assistant.py index 3972831b0..a531ccdd4 100644 --- a/assistants/knowledge-transfer-assistant/assistant/assistant.py +++ b/assistants/knowledge-transfer-assistant/assistant/assistant.py @@ -44,9 +44,7 @@ service_id = "knowledge-transfer-assistant.made-exploration" service_name = "Knowledge Transfer Assistant" -service_description = ( - "A mediator assistant that facilitates sharing knowledge between parties." -) +service_description = "A mediator assistant that facilitates sharing knowledge between parties." async def content_evaluator_factory( @@ -78,9 +76,7 @@ async def content_evaluator_factory( template_id=DEFAULT_TEMPLATE_ID, background_color="rgb(198, 177, 222)", icon=dashboard_card.image_to_url( - pathlib.Path(__file__).parent - / "assets" - / "icon-knowledge-transfer.svg", + pathlib.Path(__file__).parent / "assets" / "icon-knowledge-transfer.svg", "image/svg+xml", ), card_content=dashboard_card.CardContent( @@ -89,11 +85,9 @@ async def content_evaluator_factory( ), ), ), - **navigator.metadata_for_assistant_navigator( - { - "default": load_text_include("assistant_info.md"), - } - ), + **navigator.metadata_for_assistant_navigator({ + "default": load_text_include("assistant_info.md"), + }), }, ) @@ -125,9 +119,7 @@ async def on_conversation_created(context: ConversationContext) -> None: share_id = await ShareManager.create_share(context) # And it was good. So we then created a sharable conversation that we use as a template. - share_url = await ShareManager.create_shareable_team_conversation( - context=context, share_id=share_id - ) + share_url = await ShareManager.create_shareable_team_conversation(context=context, share_id=share_id) welcome_message = config.coordinator_config.welcome_message.format( share_url=share_url or "" @@ -157,9 +149,7 @@ async def on_conversation_created(context: ConversationContext) -> None: if not share_id: logger.error("No share ID found for shareable team conversation.") return - await ShareManager.set_conversation_role( - context, share_id, ConversationRole.SHAREABLE_TEMPLATE - ) + await ShareManager.set_conversation_role(context, share_id, ConversationRole.SHAREABLE_TEMPLATE) return case ConversationRole.TEAM: @@ -175,12 +165,8 @@ async def on_conversation_created(context: ConversationContext) -> None: ) ) - await ShareManager.set_conversation_role( - context, share_id, ConversationRole.TEAM - ) - await ShareFilesManager.synchronize_files_to_team_conversation( - context=context, share_id=share_id - ) + await ShareManager.set_conversation_role(context, share_id, ConversationRole.TEAM) + await ShareFilesManager.synchronize_files_to_team_conversation(context=context, share_id=share_id) welcome_message, debug = await generate_team_welcome_message(context) await context.send_messages( @@ -231,13 +217,9 @@ async def on_conversation_updated(context: ConversationContext) -> None: 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}'" - ) + 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}" - ) + 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}") @@ -265,10 +247,7 @@ async def on_message_created( # If this is a Coordinator conversation, store the message for Team access async with context.set_status("jotting..."): role = await ShareManager.get_conversation_role(context) - if ( - role == ConversationRole.COORDINATOR - and message.message_type == MessageType.chat - ): + if role == ConversationRole.COORDINATOR and message.message_type == MessageType.chat: try: sender_name = "Coordinator" if message.sender: @@ -283,14 +262,11 @@ async def on_message_created( message_id=str(message.id), content=message.content, sender_name=sender_name, - is_assistant=message.sender.participant_role - == ParticipantRole.assistant, + is_assistant=message.sender.participant_role == ParticipantRole.assistant, timestamp=message.timestamp, ) except Exception as e: - logger.exception( - f"Error storing Coordinator message for Team access: {e}" - ) + logger.exception(f"Error storing Coordinator message for Team access: {e}") async with context.set_status("pondering..."): await respond_to_conversation( @@ -302,13 +278,8 @@ async def on_message_created( # If the message is from a Coordinator, update the digest in the # background - if ( - role == ConversationRole.COORDINATOR - and message.message_type == MessageType.chat - ): - asyncio.create_task( - KnowledgeDigestManager.auto_update_knowledge_digest(context) - ) + if role == ConversationRole.COORDINATOR and message.message_type == MessageType.chat: + asyncio.create_task(KnowledgeDigestManager.auto_update_knowledge_digest(context)) except Exception as e: logger.exception(f"Error handling message: {e}") @@ -330,13 +301,9 @@ async def on_command_created( if message.message_type != MessageType.command: return - await context.update_participant_me( - UpdateParticipant(status="processing command...") - ) + await context.update_participant_me(UpdateParticipant(status="processing command...")) try: - metadata = { - "debug": {"content_safety": event.data.get(content_safety.metadata_key, {})} - } + metadata = {"debug": {"content_safety": event.data.get(content_safety.metadata_key, {})}} # Respond to the conversation await respond_to_conversation( @@ -369,9 +336,7 @@ async def on_file_created( try: share = await ShareManager.get_share(context) if not share or not file.filename: - logger.warning( - f"No share found or missing filename. filename={file.filename}" - ) + logger.warning(f"No share found or missing filename. filename={file.filename}") return role = await ShareManager.get_conversation_role(context) @@ -394,9 +359,7 @@ async def on_file_created( # 2. Synchronize to all Team conversations # Get all Team conversations - team_conversations = await ShareFilesManager.get_team_conversations( - context, share.share_id - ) + team_conversations = await ShareFilesManager.get_team_conversations(context, share.share_id) if team_conversations: for team_conv_id in team_conversations: @@ -408,9 +371,7 @@ async def on_file_created( ) # 3. Update all UIs but don't send notifications to reduce noise - await Notifications.notify_all_state_update( - context, share.share_id, [InspectorTab.DEBUG] - ) + await Notifications.notify_all_state_update(context, share.share_id, [InspectorTab.DEBUG]) # Team files don't need special handling as they're already in the conversation # Log file creation to knowledge transfer log for all files @@ -456,9 +417,7 @@ async def on_file_updated( logger.error(f"Failed to update file in share storage: {file.filename}") return - team_conversations = await ShareFilesManager.get_team_conversations( - context, share.share_id - ) + team_conversations = await ShareFilesManager.get_team_conversations(context, share.share_id) for team_conv_id in team_conversations: await ShareFilesManager.copy_file_to_conversation( context=context, @@ -468,9 +427,7 @@ async def on_file_updated( ) # 3. Update all UIs but don't send notifications to reduce noise - await Notifications.notify_all_state_update( - context, share.share_id, [InspectorTab.DEBUG] - ) + await Notifications.notify_all_state_update(context, share.share_id, [InspectorTab.DEBUG]) await ShareManager.log_share_event( context=context, @@ -508,14 +465,10 @@ async def on_file_deleted( ) if not success: - logger.error( - f"Failed to delete file from share storage: {file.filename}" - ) + logger.error(f"Failed to delete file from share storage: {file.filename}") # 2. Update all UIs about the deletion but don't send notifications to reduce noise - await Notifications.notify_all_state_update( - context, share.share_id, [InspectorTab.DEBUG] - ) + await Notifications.notify_all_state_update(context, share.share_id, [InspectorTab.DEBUG]) # Team files don't need special handling await ShareManager.log_share_event( @@ -560,9 +513,7 @@ async def on_participant_joined( if not share_id: return - await ShareFilesManager.synchronize_files_to_team_conversation( - context=context, share_id=share_id - ) + await ShareFilesManager.synchronize_files_to_team_conversation(context=context, share_id=share_id) await ShareManager.log_share_event( context=context, diff --git a/assistants/knowledge-transfer-assistant/assistant/config.py b/assistants/knowledge-transfer-assistant/assistant/config.py index 9cb254742..f84d51d1d 100644 --- a/assistants/knowledge-transfer-assistant/assistant/config.py +++ b/assistants/knowledge-transfer-assistant/assistant/config.py @@ -172,9 +172,7 @@ class CoordinatorConfig(BaseModel): int, Field( title="Maximum Digest Tokens", - description=( - "The number of tokens to use for the knowledge digest. Default: 4096" - ), + description=("The number of tokens to use for the knowledge digest. Default: 4096"), ), ] = 4_096 diff --git a/assistants/knowledge-transfer-assistant/assistant/conversation_clients.py b/assistants/knowledge-transfer-assistant/assistant/conversation_clients.py index 40d9e47bb..e390d1e7e 100644 --- a/assistants/knowledge-transfer-assistant/assistant/conversation_clients.py +++ b/assistants/knowledge-transfer-assistant/assistant/conversation_clients.py @@ -26,9 +26,7 @@ class ConversationClientManager: """ @staticmethod - def get_conversation_client( - context: ConversationContext, conversation_id: str - ) -> ConversationAPIClient: + def get_conversation_client(context: ConversationContext, conversation_id: str) -> ConversationAPIClient: """ Gets a client for accessing another conversation. """ @@ -42,9 +40,7 @@ async def get_coordinator_client_for_share( Gets a client for accessing the Coordinator conversation for a knowledge transfer. """ # Look for the Coordinator conversation directory - coordinator_dir = ( - ShareStorageManager.get_share_dir(share_id) / ConversationRole.COORDINATOR - ) + coordinator_dir = ShareStorageManager.get_share_dir(share_id) / ConversationRole.COORDINATOR if not coordinator_dir.exists(): return None, None @@ -68,9 +64,7 @@ async def get_coordinator_client_for_share( return None, coordinator_conversation_id # Create a client for the Coordinator conversation - client = ConversationClientManager.get_conversation_client( - context, coordinator_conversation_id - ) + client = ConversationClientManager.get_conversation_client(context, coordinator_conversation_id) return client, coordinator_conversation_id @staticmethod diff --git a/assistants/knowledge-transfer-assistant/assistant/data.py b/assistants/knowledge-transfer-assistant/assistant/data.py index 3452cd9d0..e1cd1e847 100644 --- a/assistants/knowledge-transfer-assistant/assistant/data.py +++ b/assistants/knowledge-transfer-assistant/assistant/data.py @@ -161,9 +161,7 @@ class LogEntry(BaseModel): class KnowledgePackageLog(BaseModel): - entries: List[LogEntry] = Field( - default_factory=list - ) # Chronological list of log entries + entries: List[LogEntry] = Field(default_factory=list) # Chronological list of log entries class KnowledgePackage(BaseModel): @@ -195,9 +193,7 @@ class KnowledgePackage(BaseModel): archived: bool = False requests: List[InformationRequest] = Field(default_factory=list) - log: Optional[KnowledgePackageLog] = Field( - default_factory=lambda: KnowledgePackageLog() - ) + log: Optional[KnowledgePackageLog] = Field(default_factory=lambda: KnowledgePackageLog()) class CoordinatorConversationMessage(BaseModel): diff --git a/assistants/knowledge-transfer-assistant/assistant/domain/audience_manager.py b/assistants/knowledge-transfer-assistant/assistant/domain/audience_manager.py index 38dd0b27d..17d77da51 100644 --- a/assistants/knowledge-transfer-assistant/assistant/domain/audience_manager.py +++ b/assistants/knowledge-transfer-assistant/assistant/domain/audience_manager.py @@ -5,70 +5,37 @@ """ from datetime import datetime, timezone -from typing import Tuple from semantic_workbench_assistant.assistant_app import ConversationContext from assistant.data import InspectorTab, LogEntryType -from assistant.logging import logger from assistant.notifications import Notifications from .share_manager import ShareManager class AudienceManager: - """Manages knowledge transfer lifecycle and state operations.""" - @staticmethod async def update_audience( context: ConversationContext, audience_description: str, - ) -> Tuple[bool, str]: + ) -> None: """ Update the target audience description for a knowledge package. - - Args: - context: Current conversation context - audience_description: Description of the intended audience and their existing knowledge level - - Returns: - Tuple of (success, message) where: - - success: Boolean indicating if the update was successful - - message: Result message """ - try: - # Get existing knowledge package - share = await ShareManager.get_share(context) - if not share: - return ( - False, - "No knowledge package found. Please create a knowledge brief first.", - ) - - # Update the audience - share.audience = audience_description.strip() - share.updated_at = datetime.now(timezone.utc) - - # Save the updated package - await ShareManager.set_share(context, share) - - # Log the event - await ShareManager.log_share_event( - context=context, - entry_type=LogEntryType.STATUS_CHANGED.value, - message=f"Updated target audience: {audience_description}", - metadata={ - "audience": audience_description, - }, - ) - - await Notifications.notify(context, "Audience updated.") - await Notifications.notify_all_state_update( - context, share.share_id, [InspectorTab.DEBUG] - ) - - return True, f"Target audience updated successfully: {audience_description}" - - except Exception as e: - logger.exception(f"Error updating audience: {e}") - return False, "Failed to update the audience. Please try again." + share = await ShareManager.get_share(context) + share.audience = audience_description.strip() + share.updated_at = datetime.now(timezone.utc) + await ShareManager.set_share(context, share) + + await ShareManager.log_share_event( + context=context, + entry_type=LogEntryType.STATUS_CHANGED.value, + message=f"Updated target audience: {audience_description}", + metadata={ + "audience": audience_description, + }, + ) + + await Notifications.notify(context, "Audience updated.") + await Notifications.notify_all_state_update(context, share.share_id, [InspectorTab.DEBUG]) diff --git a/assistants/knowledge-transfer-assistant/assistant/domain/information_request_manager.py b/assistants/knowledge-transfer-assistant/assistant/domain/information_request_manager.py index 167632e0b..74a4d21cf 100644 --- a/assistants/knowledge-transfer-assistant/assistant/domain/information_request_manager.py +++ b/assistants/knowledge-transfer-assistant/assistant/domain/information_request_manager.py @@ -5,7 +5,7 @@ """ from datetime import datetime, timezone -from typing import List, Optional, Tuple +from typing import List, Optional from semantic_workbench_assistant.assistant_app import ConversationContext @@ -32,11 +32,7 @@ async def get_information_requests( context: ConversationContext, ) -> List[InformationRequest]: """Gets all information requests for the current conversation's share.""" - share_id = await ShareManager.get_share_id(context) - if not share_id: - return [] - return ShareStorage.get_all_information_requests(share_id) @staticmethod @@ -46,160 +42,119 @@ async def create_information_request( description: str, priority: RequestPriority = RequestPriority.MEDIUM, related_objective_ids: Optional[List[str]] = None, - ) -> Tuple[bool, Optional[InformationRequest]]: - try: - share_id = await ShareManager.get_share_id(context) - if not share_id: - logger.error( - "Cannot create information request: no share associated with this conversation" - ) - return False, None - - current_user_id = await require_current_user( - context, "create information request" - ) - if not current_user_id: - return False, None - - information_request = InformationRequest( - title=title, - description=description, - priority=priority, - related_objective_ids=related_objective_ids or [], - created_by=current_user_id, - updated_by=current_user_id, - conversation_id=str(context.id), - ) - - ShareStorage.write_information_request(share_id, information_request) - - await ShareManager.log_share_event( - context=context, - entry_type=LogEntryType.REQUEST_CREATED.value, - message=f"Created information request: {title}", - related_entity_id=information_request.request_id, - metadata={ - "priority": priority.value, - "request_id": information_request.request_id, - }, - ) - - await Notifications.notify_self_and_other( - context, share_id, f"Information request '{title}' was created" - ) - await Notifications.notify_all_state_update( - context, share_id, [InspectorTab.SHARING] - ) - - return True, information_request - - except Exception as e: - logger.exception(f"Error creating information request: {e}") - return False, None + ) -> InformationRequest: + share_id = await ShareManager.get_share_id(context) + + current_user_id = await require_current_user(context, "create information request") + if not current_user_id: + raise ValueError("Could not identify current user") + + information_request = InformationRequest( + title=title, + description=description, + priority=priority, + related_objective_ids=related_objective_ids or [], + created_by=current_user_id, + updated_by=current_user_id, + conversation_id=str(context.id), + ) + + ShareStorage.write_information_request(share_id, information_request) + + await ShareManager.log_share_event( + context=context, + entry_type=LogEntryType.REQUEST_CREATED.value, + message=f"Created information request: {title}", + related_entity_id=information_request.request_id, + metadata={ + "priority": priority.value, + "request_id": information_request.request_id, + }, + ) + + await Notifications.notify_self_and_other(context, share_id, f"Information request '{title}' was created") + await Notifications.notify_all_state_update(context, share_id, [InspectorTab.SHARING]) + + return information_request @staticmethod async def resolve_information_request( context: ConversationContext, request_id: str, resolution: str, - ) -> Tuple[bool, Optional[InformationRequest]]: - try: - share_id = await ShareManager.get_share_id(context) - if not share_id: - logger.error( - "Cannot resolve information request: no share associated with this conversation" - ) - return False, None - - current_user_id = await require_current_user( - context, "resolve information request" - ) - if not current_user_id: - return False, None - - # Get the information request - information_request = ShareStorage.read_information_request( - share_id, request_id - ) + ) -> InformationRequest: + share_id = await ShareManager.get_share_id(context) + current_user_id = await require_current_user(context, "resolve information request") + + information_request = ShareStorage.read_information_request(share_id, request_id) + if not information_request: + # Try to find it in all requests + all_requests = ShareStorage.get_all_information_requests(share_id) + for request in all_requests: + if request.request_id == request_id: + information_request = request + break + if not information_request: - # Try to find it in all requests - all_requests = ShareStorage.get_all_information_requests(share_id) - for request in all_requests: - if request.request_id == request_id: - information_request = request - break - - if not information_request: - logger.error(f"Information request {request_id} not found") - return False, None - - # Check if already resolved - if information_request.status == RequestStatus.RESOLVED: - logger.warning(f"Information request {request_id} is already resolved") - return True, information_request - - # Update the request - information_request.status = RequestStatus.RESOLVED - information_request.resolution = resolution - information_request.resolved_at = datetime.now(timezone.utc) - information_request.resolved_by = current_user_id - - # Add to history - information_request.updates.append( - { - "timestamp": datetime.now(timezone.utc).isoformat(), - "user_id": current_user_id, - "message": f"Request resolved: {resolution}", - "status": RequestStatus.RESOLVED.value, - } - ) - - # Update metadata - information_request.updated_at = datetime.now(timezone.utc) - information_request.updated_by = current_user_id - information_request.version += 1 - - # Save the updated request - ShareStorage.write_information_request(share_id, information_request) - - # Log the resolution - await ShareManager.log_share_event( - context=context, - entry_type=LogEntryType.REQUEST_RESOLVED.value, - message=f"Resolved information request: {information_request.title}", - related_entity_id=information_request.request_id, - metadata={ - "resolution": resolution, - "request_title": information_request.title, - "request_priority": information_request.priority.value - if hasattr(information_request.priority, "value") - else information_request.priority, - }, - ) - - await Notifications.notify_all_state_update( - context, share_id, [InspectorTab.SHARING] - ) - await Notifications.notify_self_and_other( - context, - share_id, - f"Information request '{information_request.title}' has been resolved: {resolution}", - information_request.conversation_id - if information_request.conversation_id != str(context.id) - else None, - ) - - return True, information_request - - except Exception as e: - logger.exception(f"Error resolving information request: {e}") - return False, None + raise ValueError(f"Information request {request_id} not found") + + # Check if already resolved + if information_request.status == RequestStatus.RESOLVED: + logger.warning(f"Information request {request_id} is already resolved") + return information_request + + # Update the request + information_request.status = RequestStatus.RESOLVED + information_request.resolution = resolution + information_request.resolved_at = datetime.now(timezone.utc) + information_request.resolved_by = current_user_id + + # Add to history + information_request.updates.append({ + "timestamp": datetime.now(timezone.utc).isoformat(), + "user_id": current_user_id, + "message": f"Request resolved: {resolution}", + "status": RequestStatus.RESOLVED.value, + }) + + # Update metadata + information_request.updated_at = datetime.now(timezone.utc) + information_request.updated_by = current_user_id + information_request.version += 1 + + # Save the updated request + ShareStorage.write_information_request(share_id, information_request) + + # Log the resolution + await ShareManager.log_share_event( + context=context, + entry_type=LogEntryType.REQUEST_RESOLVED.value, + message=f"Resolved information request: {information_request.title}", + related_entity_id=information_request.request_id, + metadata={ + "resolution": resolution, + "request_title": information_request.title, + "request_priority": information_request.priority.value + if hasattr(information_request.priority, "value") + else information_request.priority, + }, + ) + + await Notifications.notify_all_state_update(context, share_id, [InspectorTab.SHARING]) + await Notifications.notify_self_and_other( + context, + share_id, + f"Information request '{information_request.title}' has been resolved: {resolution}", + information_request.conversation_id if information_request.conversation_id != str(context.id) else None, + ) + + return information_request @staticmethod async def delete_information_request( context: ConversationContext, request_id: str, - ) -> Tuple[bool, Optional[str]]: + ) -> str: """ Delete an information request. @@ -208,87 +163,57 @@ async def delete_information_request( request_id: ID of the request to delete Returns: - Tuple of (success, message) + Success message """ - try: - share_id = await ShareManager.get_share_id(context) - if not share_id: - logger.error( - "Cannot delete information request: no share associated with this conversation" - ) - return False, "No knowledge package associated with this conversation." - - current_user_id = await require_current_user( - context, "delete information request" - ) - if not current_user_id: - return False, "Could not identify current user." - - # Get information request by ID - cleaned_request_id = request_id.strip().replace('"', "").replace("'", "") - information_request = ShareStorage.read_information_request( - share_id, cleaned_request_id - ) - if not information_request: - return False, f"Information request with ID '{request_id}' not found." - - # Check ownership - only allow deletion by the creator - if information_request.conversation_id != str(context.id): - return ( - False, - "You can only delete information requests that you created.", - ) - - # Get user info for logging - participants = await context.get_participants() - current_username = "Team Member" - for participant in participants.participants: - if participant.role == "user": - current_username = participant.name - break - - request_title = information_request.title - actual_request_id = information_request.request_id - - # Log the deletion - await ShareManager.log_share_event( - context=context, - entry_type=LogEntryType.REQUEST_DELETED.value, - message=f"Information request '{request_title}' was deleted by {current_username}", - related_entity_id=actual_request_id, - metadata={ - "request_title": request_title, - "deleted_by": current_user_id, - "deleted_by_name": current_username, - }, - ) - - # Delete the information request from the main share data - share = await ShareManager.get_share(context) - if share and share.requests: - share.requests = [ - req for req in share.requests if req.request_id != actual_request_id - ] - await ShareManager.set_share(context, share) - - # Notify about the deletion - await Notifications.notify_self_and_other( - context, - share_id, - f"Information request '{request_title}' has been deleted.", - ) - await Notifications.notify_all_state_update( - context, share_id, [InspectorTab.SHARING] - ) - - return ( - True, - f"Information request '{request_title}' has been successfully deleted.", - ) - - except Exception as e: - logger.exception(f"Error deleting information request: {e}") - return ( - False, - f"Error deleting information request: {str(e)}. Please try again later.", - ) + share_id = await ShareManager.get_share_id(context) + current_user_id = await require_current_user(context, "delete information request") + + # Get information request by ID + cleaned_request_id = request_id.strip().replace('"', "").replace("'", "") + information_request = ShareStorage.read_information_request(share_id, cleaned_request_id) + if not information_request: + raise ValueError(f"Information request with ID '{request_id}' not found") + + # Check ownership - only allow deletion by the creator + if information_request.conversation_id != str(context.id): + raise PermissionError("You can only delete information requests that you created") + + # Get user info for logging + participants = await context.get_participants() + current_username = "Team Member" + for participant in participants.participants: + if participant.role == "user": + current_username = participant.name + break + + request_title = information_request.title + actual_request_id = information_request.request_id + + # Log the deletion + await ShareManager.log_share_event( + context=context, + entry_type=LogEntryType.REQUEST_DELETED.value, + message=f"Information request '{request_title}' was deleted by {current_username}", + related_entity_id=actual_request_id, + metadata={ + "request_title": request_title, + "deleted_by": current_user_id, + "deleted_by_name": current_username, + }, + ) + + # Delete the information request from the main share data + share = await ShareManager.get_share(context) + if share and share.requests: + share.requests = [req for req in share.requests if req.request_id != actual_request_id] + await ShareManager.set_share(context, share) + + # Notify about the deletion + await Notifications.notify_self_and_other( + context, + share_id, + f"Information request '{request_title}' has been deleted.", + ) + await Notifications.notify_all_state_update(context, share_id, [InspectorTab.SHARING]) + + return f"Information request '{request_title}' has been successfully deleted." diff --git a/assistants/knowledge-transfer-assistant/assistant/domain/knowledge_brief_manager.py b/assistants/knowledge-transfer-assistant/assistant/domain/knowledge_brief_manager.py index d46c55714..a4bab5a6b 100644 --- a/assistants/knowledge-transfer-assistant/assistant/domain/knowledge_brief_manager.py +++ b/assistants/knowledge-transfer-assistant/assistant/domain/knowledge_brief_manager.py @@ -9,7 +9,6 @@ from semantic_workbench_assistant.assistant_app import ConversationContext from assistant.data import InspectorTab, KnowledgeBrief, LogEntryType -from assistant.logging import logger from assistant.notifications import Notifications from assistant.storage import ShareStorage from assistant.utils import require_current_user @@ -25,8 +24,6 @@ async def get_knowledge_brief( context: ConversationContext, ) -> Optional[KnowledgeBrief]: share_id = await ShareManager.get_share_id(context) - if not share_id: - return None return ShareStorage.read_knowledge_brief(share_id) @staticmethod @@ -35,17 +32,9 @@ async def update_knowledge_brief( title: str, description: str, timeline: Optional[str] = None, - ) -> Optional[KnowledgeBrief]: + ) -> KnowledgeBrief: share_id = await ShareManager.get_share_id(context) - if not share_id: - logger.error( - "Cannot update brief: no share associated with this conversation" - ) - return - current_user_id = await require_current_user(context, "update brief") - if not current_user_id: - return brief = KnowledgeBrief( title=title, @@ -75,11 +64,7 @@ async def update_knowledge_brief( message=f"Created brief: {title}", ) - await Notifications.notify_all( - context, share_id, "Knowledge brief has been updated" - ) - await Notifications.notify_all_state_update( - context, share_id, [InspectorTab.BRIEF] - ) + await Notifications.notify_all(context, share_id, "Knowledge brief has been updated") + await Notifications.notify_all_state_update(context, share_id, [InspectorTab.BRIEF]) return brief diff --git a/assistants/knowledge-transfer-assistant/assistant/domain/knowledge_digest_manager.py b/assistants/knowledge-transfer-assistant/assistant/domain/knowledge_digest_manager.py index 59bfe570b..1299f364e 100644 --- a/assistants/knowledge-transfer-assistant/assistant/domain/knowledge_digest_manager.py +++ b/assistants/knowledge-transfer-assistant/assistant/domain/knowledge_digest_manager.py @@ -6,7 +6,7 @@ import re from datetime import datetime, timezone -from typing import Optional, Tuple +from typing import Optional import openai_client from semantic_workbench_api_model.workbench_model import ParticipantRole @@ -14,7 +14,6 @@ from assistant.config import assistant_config from assistant.data import InspectorTab, KnowledgeDigest, LogEntryType -from assistant.logging import logger from assistant.notifications import Notifications from assistant.storage import ShareStorage from assistant.utils import require_current_user @@ -39,147 +38,111 @@ async def update_knowledge_digest( context: ConversationContext, content: str, is_auto_generated: bool = True, - ) -> Tuple[bool, Optional[KnowledgeDigest]]: - try: - share_id = await ShareManager.get_share_id(context) - if not share_id: - logger.error( - "Cannot update knowledge digest: no share associated with this conversation" - ) - return False, None - - current_user_id = await require_current_user( - context, "update knowledge digest" - ) - if not current_user_id: - return False, None - - digest = ShareStorage.read_knowledge_digest(share_id) - is_new = False - - if not digest: - digest = KnowledgeDigest( - created_by=current_user_id, - updated_by=current_user_id, - conversation_id=str(context.id), - content="", - ) - is_new = True - - digest.content = content - digest.is_auto_generated = is_auto_generated - digest.updated_at = datetime.now(timezone.utc) - digest.updated_by = current_user_id - digest.version += 1 - ShareStorage.write_knowledge_digest(share_id, digest) - - # Log the update - event_type = LogEntryType.KNOWLEDGE_DIGEST_UPDATE - update_type = "auto-generated" if is_auto_generated else "manual" - message = ( - f"{'Created' if is_new else 'Updated'} knowledge digest ({update_type})" - ) - - await ShareManager.log_share_event( - context=context, - entry_type=event_type.value, - message=message, - ) + ) -> KnowledgeDigest: + share_id = await ShareManager.get_share_id(context) + if not share_id: + raise ValueError("Cannot update knowledge digest: no share associated with this conversation") - await Notifications.notify_all_state_update( - context, - share_id, - [InspectorTab.BRIEF], - ) + current_user_id = await require_current_user(context, "update knowledge digest") + if not current_user_id: + raise ValueError("Could not identify current user") - return True, digest + digest = ShareStorage.read_knowledge_digest(share_id) + is_new = False - except Exception as e: - logger.exception(f"Error updating knowledge digest: {e}") - return False, None + if not digest: + digest = KnowledgeDigest( + created_by=current_user_id, + updated_by=current_user_id, + conversation_id=str(context.id), + content="", + ) + is_new = True + + digest.content = content + digest.is_auto_generated = is_auto_generated + digest.updated_at = datetime.now(timezone.utc) + digest.updated_by = current_user_id + digest.version += 1 + ShareStorage.write_knowledge_digest(share_id, digest) + + # Log the update + event_type = LogEntryType.KNOWLEDGE_DIGEST_UPDATE + update_type = "auto-generated" if is_auto_generated else "manual" + message = f"{'Created' if is_new else 'Updated'} knowledge digest ({update_type})" + + await ShareManager.log_share_event( + context=context, + entry_type=event_type.value, + message=message, + ) + + await Notifications.notify_all_state_update( + context, + share_id, + [InspectorTab.BRIEF], + ) + + return digest @staticmethod async def auto_update_knowledge_digest( context: ConversationContext, - ) -> Tuple[bool, Optional[KnowledgeDigest]]: + ) -> KnowledgeDigest: """ Automatically updates the knowledge digest by analyzing chat history. """ - try: - messages = await context.get_messages() - chat_history = messages.messages - - share_id = await ShareManager.get_share_id(context) - if not share_id: - logger.error( - "Cannot auto-update knowledge digest: no share associated with this conversation" - ) - return False, None - - current_user_id = await require_current_user( - context, "auto-update knowledge digest" - ) - if not current_user_id: - return False, None - - # Skip if no messages to analyze - if not chat_history: - logger.warning("No chat history to analyze for knowledge digest update") - return False, None - - # Format the chat history for the prompt - chat_history_text = "" - for msg in chat_history: - sender_type = ( - "User" - if msg.sender - and msg.sender.participant_role == ParticipantRole.user - else "Assistant" - ) - chat_history_text += f"{sender_type}: {msg.content}\n\n" - - # Construct the knowledge digest prompt with the chat history - config = await assistant_config.get(context.assistant) - digest_prompt = f""" - {config.prompt_config.knowledge_digest_prompt} - - - {chat_history_text} - - """ - - async with openai_client.create_client( - config.service_config, api_version="2024-06-01" - ) as client: - completion = await client.chat.completions.create( - model=config.request_config.openai_model, - messages=[{"role": "user", "content": digest_prompt}], - max_tokens=config.coordinator_config.max_digest_tokens, - ) - - content = completion.choices[0].message.content or "" - digest_content = "" - match = re.search( - r"(.*?)", content, re.DOTALL - ) - if match: - digest_content = match.group(1).strip() - else: - digest_content = content.strip() - - if not digest_content: - logger.warning( - "No content extracted from knowledge digest LLM analysis" - ) - return False, None - - result = await KnowledgeDigestManager.update_knowledge_digest( - context=context, - content=digest_content, - is_auto_generated=True, + messages = await context.get_messages() + chat_history = messages.messages + + share_id = await ShareManager.get_share_id(context) + if not share_id: + raise ValueError("Cannot auto-update knowledge digest: no share associated with this conversation") + + current_user_id = await require_current_user(context, "auto-update knowledge digest") + if not current_user_id: + raise ValueError("Could not identify current user") + + # Skip if no messages to analyze + if not chat_history: + raise ValueError("No chat history to analyze for knowledge digest update") + + # Format the chat history for the prompt + chat_history_text = "" + for msg in chat_history: + sender_type = "User" if msg.sender and msg.sender.participant_role == ParticipantRole.user else "Assistant" + chat_history_text += f"{sender_type}: {msg.content}\n\n" + + # Construct the knowledge digest prompt with the chat history + config = await assistant_config.get(context.assistant) + digest_prompt = f""" + {config.prompt_config.knowledge_digest_prompt} + + + {chat_history_text} + + """ + + async with openai_client.create_client(config.service_config, api_version="2024-06-01") as client: + completion = await client.chat.completions.create( + model=config.request_config.openai_model, + messages=[{"role": "user", "content": digest_prompt}], + max_tokens=config.coordinator_config.max_digest_tokens, ) - return result - except Exception as e: - logger.exception(f"Error auto-updating knowledge digest: {e}") - return False, None + content = completion.choices[0].message.content or "" + digest_content = "" + match = re.search(r"(.*?)", content, re.DOTALL) + if match: + digest_content = match.group(1).strip() + else: + digest_content = content.strip() + + if not digest_content: + raise ValueError("No content extracted from knowledge digest LLM analysis") + + return await KnowledgeDigestManager.update_knowledge_digest( + context=context, + content=digest_content, + is_auto_generated=True, + ) diff --git a/assistants/knowledge-transfer-assistant/assistant/domain/learning_objectives_manager.py b/assistants/knowledge-transfer-assistant/assistant/domain/learning_objectives_manager.py index e5f81b24f..95fdfbe56 100644 --- a/assistants/knowledge-transfer-assistant/assistant/domain/learning_objectives_manager.py +++ b/assistants/knowledge-transfer-assistant/assistant/domain/learning_objectives_manager.py @@ -16,9 +16,7 @@ LearningOutcomeAchievement, LogEntryType, ) -from assistant.logging import logger from assistant.notifications import Notifications -from assistant.utils import require_current_user from .share_manager import ShareManager @@ -35,15 +33,6 @@ async def add_learning_objective( priority: int = 1, ) -> Optional[LearningObjective]: share_id = await ShareManager.get_share_id(context) - if not share_id: - logger.error( - "Cannot add learning objective: no share associated with this conversation" - ) - return None - - current_user_id = await require_current_user(context, "add learning objective") - if not current_user_id: - return None criterion_objects = [] if outcomes: @@ -58,18 +47,8 @@ async def add_learning_objective( ) share = await ShareManager.get_share(context) - if not share: - # Create a new share if it doesn't exist - share = KnowledgePackage( - share_id=share_id, - brief=None, - learning_objectives=[new_learning_objective], - digest=None, - requests=[], - log=None, - ) - else: - share.learning_objectives.append(new_learning_objective) + + share.learning_objectives.append(new_learning_objective) await ShareManager.set_share(context, share) @@ -79,12 +58,8 @@ async def add_learning_objective( message=f"Added learning objective: {objective_name}", ) - await Notifications.notify_all( - context, share_id, f"Learning objective '{objective_name}' was added" - ) - await Notifications.notify_all_state_update( - context, share_id, [InspectorTab.LEARNING, InspectorTab.BRIEF] - ) + await Notifications.notify_all(context, share_id, f"Learning objective '{objective_name}' was added") + await Notifications.notify_all_state_update(context, share_id, [InspectorTab.LEARNING, InspectorTab.BRIEF]) return new_learning_objective @@ -94,24 +69,16 @@ async def update_learning_objective( objective_id: str, objective_name: Optional[str] = None, description: Optional[str] = None, - ) -> Tuple[bool, Optional[str]]: - """Update an existing learning objective's name or description.""" - share_id = await ShareManager.get_share_id(context) - if not share_id: - logger.error( - "Cannot update learning objective: no share associated with this conversation" - ) - return False, "No share associated with this conversation." - - current_user_id = await require_current_user( - context, "update learning objective" - ) - if not current_user_id: - return False, "Could not identify current user." + ) -> str: + """ + Update an existing learning objective's name or description. + Returns: + Success message + """ share = await ShareManager.get_share(context) - if not share or not share.learning_objectives: - return False, "No learning objectives found." + if not share.learning_objectives: + raise ValueError("No learning objectives found") # Find objective by ID objective = None @@ -122,9 +89,8 @@ async def update_learning_objective( if not objective: available_ids = [obj.id for obj in share.learning_objectives] - return ( - False, - f"Learning objective with ID '{objective_id}' not found. Available objective IDs: {', '.join(available_ids[:3]) + ('...' if len(available_ids) > 3 else '')}", + raise ValueError( + f"Learning objective with ID '{objective_id}' not found. Available objective IDs: {', '.join(available_ids[:3]) + ('...' if len(available_ids) > 3 else '')}" ) original_name = objective.name @@ -140,7 +106,7 @@ async def update_learning_objective( changes_made.append("description updated") if not changes_made: - return True, "No changes specified" + return "No changes specified" await ShareManager.set_share(context, share) @@ -157,39 +123,28 @@ async def update_learning_objective( ) await Notifications.notify_all( - context, share_id, f"Learning objective '{objective.name}' has been updated" + context, share.share_id, f"Learning objective '{objective.name}' has been updated" ) await Notifications.notify_all_state_update( - context, share_id, [InspectorTab.LEARNING, InspectorTab.BRIEF] + context, share.share_id, [InspectorTab.LEARNING, InspectorTab.BRIEF] ) - return ( - True, - f"Learning objective '{objective.name}' has been successfully updated: {changes_text}.", - ) + return f"Learning objective '{objective.name}' has been successfully updated: {changes_text}." @staticmethod async def delete_learning_objective( context: ConversationContext, objective_id: str, - ) -> Tuple[bool, Optional[str]]: - """Delete a learning objective by ID.""" - share_id = await ShareManager.get_share_id(context) - if not share_id: - logger.error( - "Cannot delete learning objective: no share associated with this conversation" - ) - return False, "No share associated with this conversation." - - current_user_id = await require_current_user( - context, "delete learning objective" - ) - if not current_user_id: - return False, "Could not identify current user." + ) -> str: + """ + Delete a learning objective by ID. + Returns: + Success message + """ share = await ShareManager.get_share(context) - if not share or not share.learning_objectives: - return False, "No learning objectives found." + if not share.learning_objectives: + raise ValueError("No learning objectives found") # Find objective by ID objective = None @@ -202,9 +157,8 @@ async def delete_learning_objective( if not objective: available_ids = [obj.id for obj in share.learning_objectives] - return ( - False, - f"Learning objective with ID '{objective_id}' not found. Available objective IDs: {', '.join(available_ids[:3]) + ('...' if len(available_ids) > 3 else '')}", + raise ValueError( + f"Learning objective with ID '{objective_id}' not found. Available objective IDs: {', '.join(available_ids[:3]) + ('...' if len(available_ids) > 3 else '')}" ) objective_name = objective.name @@ -235,28 +189,19 @@ async def delete_learning_objective( ) await Notifications.notify_all( - context, share_id, f"Learning objective '{objective_name}' has been deleted" + context, share.share_id, f"Learning objective '{objective_name}' has been deleted" ) await Notifications.notify_all_state_update( - context, share_id, [InspectorTab.LEARNING, InspectorTab.BRIEF] + context, share.share_id, [InspectorTab.LEARNING, InspectorTab.BRIEF] ) - return ( - True, - f"Learning objective '{objective_name}' has been successfully deleted from the knowledge package.", - ) + return f"Learning objective '{objective_name}' has been successfully deleted from the knowledge package." @staticmethod async def get_learning_outcomes( context: ConversationContext, ) -> List[LearningOutcome]: - share_id = await ShareManager.get_share_id(context) - if not share_id: - return [] - share = await ShareManager.get_share(context) - if not share: - return [] objectives = share.learning_objectives outcomes = [] @@ -270,25 +215,16 @@ async def add_learning_outcome( context: ConversationContext, objective_id: str, outcome_description: str, - ) -> Tuple[bool, Optional[str]]: - """Add a new learning outcome to an existing learning objective.""" - share_id = await ShareManager.get_share_id(context) - if not share_id: - logger.error( - "Cannot add learning outcome: no share associated with this conversation" - ) - return False, "No knowledge package associated with this conversation." - - current_user_id = await require_current_user(context, "add learning outcome") - if not current_user_id: - return False, "Could not identify current user." + ) -> str: + """ + Add a new learning outcome to an existing learning objective. + Returns: + Success message + """ share = await ShareManager.get_share(context) - if not share or not share.learning_objectives: - return ( - False, - "No learning objectives found. Please add objectives before adding outcomes.", - ) + if not share.learning_objectives: + raise ValueError("No learning objectives found. Please add objectives before adding outcomes.") # Find the objective by ID objective = None @@ -299,9 +235,8 @@ async def add_learning_outcome( if objective is None: available_ids = [obj.id for obj in share.learning_objectives] - return ( - False, - f"Learning objective with ID '{objective_id}' not found. Available objective IDs: {', '.join(available_ids[:3]) + ('...' if len(available_ids) > 3 else '')}", + raise ValueError( + f"Learning objective with ID '{objective_id}' not found. Available objective IDs: {', '.join(available_ids[:3]) + ('...' if len(available_ids) > 3 else '')}" ) # Create the new outcome @@ -329,42 +264,30 @@ async def add_learning_outcome( # Notify linked conversations await Notifications.notify_all( context, - share_id, + share.share_id, f"Learning outcome '{outcome_description}' has been added", ) await Notifications.notify_all_state_update( - context, share_id, [InspectorTab.LEARNING, InspectorTab.BRIEF] + context, share.share_id, [InspectorTab.LEARNING, InspectorTab.BRIEF] ) - return ( - True, - f"Learning outcome added successfully to objective '{objective.name}': {outcome_description}", - ) + return f"Learning outcome added successfully to objective '{objective.name}': {outcome_description}" @staticmethod async def update_learning_outcome( context: ConversationContext, outcome_id: str, new_description: str, - ) -> Tuple[bool, Optional[str]]: - """Update the description of an existing learning outcome.""" - share_id = await ShareManager.get_share_id(context) - if not share_id: - logger.error( - "Cannot update learning outcome: no share associated with this conversation" - ) - return False, "No knowledge package associated with this conversation." - - current_user_id = await require_current_user(context, "update learning outcome") - if not current_user_id: - return False, "Could not identify current user." + ) -> str: + """ + Update the description of an existing learning outcome. + Returns: + Success message + """ share = await ShareManager.get_share(context) - if not share or not share.learning_objectives: - return ( - False, - "No learning objectives found. Please add objectives before updating outcomes.", - ) + if not share.learning_objectives: + raise ValueError("No learning objectives found. Please add objectives before updating outcomes.") # Find the outcome by ID across all objectives objective = None @@ -384,9 +307,8 @@ async def update_learning_outcome( for obj in share.learning_objectives: for out in obj.learning_outcomes: available_outcome_ids.append(out.id) - return ( - False, - f"Learning outcome with ID '{outcome_id}' not found. Available outcome IDs: {', '.join(available_outcome_ids[:3]) + ('...' if len(available_outcome_ids) > 3 else '')}", + raise ValueError( + f"Learning outcome with ID '{outcome_id}' not found. Available outcome IDs: {', '.join(available_outcome_ids[:3]) + ('...' if len(available_outcome_ids) > 3 else '')}" ) old_description = outcome.description @@ -413,40 +335,28 @@ async def update_learning_outcome( # Notify linked conversations await Notifications.notify_all( - context, share_id, f"Learning outcome '{new_description}' has been updated" + context, share.share_id, f"Learning outcome '{new_description}' has been updated" ) await Notifications.notify_all_state_update( - context, share_id, [InspectorTab.LEARNING, InspectorTab.BRIEF] + context, share.share_id, [InspectorTab.LEARNING, InspectorTab.BRIEF] ) - return ( - True, - f"Learning outcome updated successfully in objective '{objective.name}': {new_description}", - ) + return f"Learning outcome updated successfully in objective '{objective.name}': {new_description}" @staticmethod async def delete_learning_outcome( context: ConversationContext, outcome_id: str, - ) -> Tuple[bool, Optional[str]]: - """Delete a learning outcome from a learning objective.""" - share_id = await ShareManager.get_share_id(context) - if not share_id: - logger.error( - "Cannot delete learning outcome: no share associated with this conversation" - ) - return False, "No knowledge package associated with this conversation." - - current_user_id = await require_current_user(context, "delete learning outcome") - if not current_user_id: - return False, "Could not identify current user." + ) -> str: + """ + Delete a learning outcome from a learning objective. + Returns: + Success message + """ share = await ShareManager.get_share(context) - if not share or not share.learning_objectives: - return ( - False, - "No learning objectives found. Please add objectives before deleting outcomes.", - ) + if not share.learning_objectives: + raise ValueError("No learning objectives found. Please add objectives before deleting outcomes.") # Find the outcome by ID across all objectives objective = None @@ -468,9 +378,8 @@ async def delete_learning_outcome( for obj in share.learning_objectives: for out in obj.learning_outcomes: available_outcome_ids.append(out.id) - return ( - False, - f"Learning outcome with ID '{outcome_id}' not found. Available outcome IDs: {', '.join(available_outcome_ids[:3]) + ('...' if len(available_outcome_ids) > 3 else '')}", + raise ValueError( + f"Learning outcome with ID '{outcome_id}' not found. Available outcome IDs: {', '.join(available_outcome_ids[:3]) + ('...' if len(available_outcome_ids) > 3 else '')}" ) deleted_description = outcome_to_delete.description @@ -481,9 +390,7 @@ async def delete_learning_outcome( # Clean up any achievement records for this outcome across all team conversations for team_info in share.team_conversations.values(): team_info.outcome_achievements = [ - achievement - for achievement in team_info.outcome_achievements - if achievement.outcome_id != outcome_id + achievement for achievement in team_info.outcome_achievements if achievement.outcome_id != outcome_id ] # Save the updated knowledge package @@ -506,17 +413,14 @@ async def delete_learning_outcome( # Notify linked conversations await Notifications.notify_all( context, - share_id, + share.share_id, f"Learning outcome '{deleted_description}' has been removed", ) await Notifications.notify_all_state_update( - context, share_id, [InspectorTab.LEARNING, InspectorTab.BRIEF] + context, share.share_id, [InspectorTab.LEARNING, InspectorTab.BRIEF] ) - return ( - True, - f"Learning outcome deleted successfully from objective '{objective.name}': {deleted_description}", - ) + return f"Learning outcome deleted successfully from objective '{objective.name}': {deleted_description}" @staticmethod def get_achievements_for_conversation( @@ -526,28 +430,18 @@ def get_achievements_for_conversation( return team_conv.outcome_achievements if team_conv else [] @staticmethod - def get_completion_for_conversation( - package: KnowledgePackage, conversation_id: str - ) -> Tuple[int, int]: - achievements = LearningObjectivesManager.get_achievements_for_conversation( - package, conversation_id - ) + def get_completion_for_conversation(package: KnowledgePackage, conversation_id: str) -> Tuple[int, int]: + achievements = LearningObjectivesManager.get_achievements_for_conversation(package, conversation_id) achieved_outcome_ids = {a.outcome_id for a in achievements if a.achieved} - total_outcomes = sum( - len(obj.learning_outcomes) for obj in package.learning_objectives - ) + total_outcomes = sum(len(obj.learning_outcomes) for obj in package.learning_objectives) achieved_outcomes = len(achieved_outcome_ids) return achieved_outcomes, total_outcomes @staticmethod - def is_outcome_achieved_by_conversation( - package: KnowledgePackage, outcome_id: str, conversation_id: str - ) -> bool: - achievements = LearningObjectivesManager.get_achievements_for_conversation( - package, conversation_id - ) + def is_outcome_achieved_by_conversation(package: KnowledgePackage, outcome_id: str, conversation_id: str) -> bool: + achievements = LearningObjectivesManager.get_achievements_for_conversation(package, conversation_id) return any(a.outcome_id == outcome_id and a.achieved for a in achievements) @staticmethod @@ -559,12 +453,8 @@ def get_overall_completion(package: KnowledgePackage) -> Tuple[int, int]: """ all_achieved_outcomes = set() for team_conv in package.team_conversations.values(): - achieved_ids = { - a.outcome_id for a in team_conv.outcome_achievements if a.achieved - } + achieved_ids = {a.outcome_id for a in team_conv.outcome_achievements if a.achieved} all_achieved_outcomes.update(achieved_ids) - total_outcomes = sum( - len(obj.learning_outcomes) for obj in package.learning_objectives - ) + total_outcomes = sum(len(obj.learning_outcomes) for obj in package.learning_objectives) return len(all_achieved_outcomes), total_outcomes diff --git a/assistants/knowledge-transfer-assistant/assistant/domain/share_manager.py b/assistants/knowledge-transfer-assistant/assistant/domain/share_manager.py index 58db1b27f..51dd5178a 100644 --- a/assistants/knowledge-transfer-assistant/assistant/domain/share_manager.py +++ b/assistants/knowledge-transfer-assistant/assistant/domain/share_manager.py @@ -5,6 +5,7 @@ """ import uuid +from csv import Error from datetime import datetime, timezone from typing import List, Optional @@ -24,6 +25,7 @@ KnowledgePackageLog, TeamConversationInfo, ) +from assistant.errors import NoShareException, NoShareFoundError from assistant.logging import logger from assistant.storage import ShareStorage, ShareStorageManager from assistant.utils import get_current_user @@ -75,9 +77,7 @@ async def create_share(context: ConversationContext) -> str: # Associate the conversation with the share logger.debug(f"Associating conversation {context.id} with share {share_id}") - await ShareManager.set_conversation_role( - context, share_id, ConversationRole.COORDINATOR - ) + await ShareManager.set_conversation_role(context, share_id, ConversationRole.COORDINATOR) # No need to set conversation role in share storage, as we use metadata logger.debug(f"Conversation {context.id} is Coordinator for share {share_id}") @@ -87,15 +87,11 @@ async def create_share(context: ConversationContext) -> str: return share_id @staticmethod - async def set_conversation_role( - context: ConversationContext, share_id: str, role: ConversationRole - ) -> None: + async def set_conversation_role(context: ConversationContext, share_id: str, role: ConversationRole) -> None: """ Sets the role of a conversation in a knowledge transfer share. """ - role_data = ConversationShareInfo( - share_id=share_id, role=role, conversation_id=str(context.id) - ) + role_data = ConversationShareInfo(share_id=share_id, role=role, conversation_id=str(context.id)) role_path = ShareStorageManager.get_conversation_role_file_path(context) write_model(role_path, role_data) @@ -115,9 +111,7 @@ async def get_conversation_role( return ConversationRole.COORDINATOR @staticmethod - async def create_shareable_team_conversation( - context: ConversationContext, share_id: str - ) -> str: + async def create_shareable_team_conversation(context: ConversationContext, share_id: str) -> str: """ Creates a new shareable team conversation template. @@ -153,9 +147,7 @@ async def create_shareable_team_conversation( }, ) client = context._conversations_client - conversation = await client.create_conversation_with_owner( - new_conversation=new_conversation, owner_id=user_id - ) + conversation = await client.create_conversation_with_owner(new_conversation=new_conversation, owner_id=user_id) if not conversation or not conversation.id: raise ValueError("Failed to create team conversation") @@ -171,10 +163,8 @@ async def create_shareable_team_conversation( "show_duplicate_action": True, }, ) - share = ( - await context._conversations_client.create_conversation_share_with_owner( - new_conversation_share=new_share, owner_id=user_id - ) + share = await context._conversations_client.create_conversation_share_with_owner( + new_conversation_share=new_share, owner_id=user_id ) share_url = f"/conversation-share/{share.id}/redeem" @@ -187,9 +177,7 @@ async def create_shareable_team_conversation( knowledge_package.updated_at = datetime.now(timezone.utc) ShareStorage.write_share(share_id, knowledge_package) else: - raise ValueError( - f"KnowledgePackage info not found for share ID: {share_id}" - ) + raise ValueError(f"KnowledgePackage info not found for share ID: {share_id}") return share_url @@ -200,7 +188,7 @@ async def get_shared_conversation_id(context: ConversationContext) -> Optional[s """ try: share = await ShareManager.get_share(context) - if not share or not share.shared_conversation_id: + if not share.shared_conversation_id: return None return share.shared_conversation_id except Exception as e: @@ -212,7 +200,7 @@ async def join_share( context: ConversationContext, share_id: str, role: ConversationRole = ConversationRole.TEAM, - ) -> bool: + ) -> None: """ Joins an existing share. @@ -220,64 +208,44 @@ async def join_share( context: Current conversation context share_id: ID of the share to join role: Role for this conversation (COORDINATOR or TEAM) - - Returns: - True if joined successfully, False otherwise """ - try: - if not ShareStorageManager.share_exists(share_id): - logger.error(f"Cannot join share: share {share_id} does not exist") - return False - - # Associate the conversation with the share - await ShareManager.set_conversation_role(context, share_id, role) + if not ShareStorageManager.share_exists(share_id): + raise ValueError(f"Cannot join share: share {share_id} does not exist") - # Role is set in metadata, not in storage + # Associate the conversation with the share + await ShareManager.set_conversation_role(context, share_id, role) - logger.info(f"Joined share {share_id} as {role.value}") - return True + # Role is set in metadata, not in storage - except Exception as e: - logger.exception(f"Error joining share: {e}") - return False + logger.info(f"Joined share {share_id} as {role.value}") @staticmethod - async def get_share_id(context: ConversationContext) -> Optional[str]: - """ - Gets the share ID associated with the current conversation. - - Every conversation that's part of a knowledge transfer share has an - associated share ID. This method retrieves that ID, which is used for - accessing share-related data structures. - - Args: - context: Current conversation context - - Returns: - The share ID string if the conversation is part of a share, None - otherwise - """ - share_path = ShareStorageManager.get_conversation_role_file_path(context) - share_data = read_model(share_path, ConversationShareInfo) - if share_data: - return share_data.share_id - return None + async def get_share_id(context: ConversationContext) -> str: + try: + share_path = ShareStorageManager.get_conversation_role_file_path(context) + share_data = read_model(share_path, ConversationShareInfo) + if share_data: + return share_data.share_id + else: + raise NoShareException + except Error as e: + raise NoShareFoundError(str(e)) @staticmethod - async def get_share(context: ConversationContext) -> Optional[KnowledgePackage]: + async def get_share(context: ConversationContext) -> KnowledgePackage: """Gets the share information for the current conversation's share.""" - share_id = await ShareManager.get_share_id(context) - if not share_id: - return None - share = ShareStorage.read_share(share_id) - if share: - # Load the separate log file if not already loaded - if not share.log: - share.log = ShareStorage.read_share_log(share_id) - else: - # Create a new package if it doesn't exist, but this should be rare in get_share - return None - return share + try: + share_id = await ShareManager.get_share_id(context) + share = ShareStorage.read_share(share_id) + if share: + # Load the separate log file if not already loaded + if not share.log: + share.log = ShareStorage.read_share_log(share_id) + return share + else: + raise NoShareException + except Error as e: + raise NoShareFoundError(str(e)) @staticmethod async def set_share(context: ConversationContext, share: KnowledgePackage) -> None: @@ -287,11 +255,6 @@ async def set_share(context: ConversationContext, share: KnowledgePackage) -> No This updates the share data in storage, including the log if it exists. """ share_id = await ShareManager.get_share_id(context) - if not share_id: - raise ValueError( - "Cannot set share: no share ID found for this conversation" - ) - ShareStorage.write_share(share_id, share) # If the log exists, write it as well @@ -305,13 +268,8 @@ async def get_linked_conversations(context: ConversationContext) -> List[str]: """ try: share = await ShareManager.get_share(context) - if not share: - return [] - - # Get all linked conversations, excluding current conversation - conversation_id = str(context.id) - conversations = [] + # Add coordinator conversation if share.coordinator_conversation_id: conversations.append(share.coordinator_conversation_id) @@ -331,9 +289,7 @@ async def get_linked_conversations(context: ConversationContext) -> List[str]: return [] @staticmethod - async def _capture_redeemer_info( - context: ConversationContext, share_id: str - ) -> None: + async def _capture_redeemer_info(context: ConversationContext, share_id: str) -> None: """ Captures the redeemer (first non-assistant participant) information and stores it in the knowledge package. Only captures info for actual team member conversations, not coordinator or shared conversations. @@ -341,25 +297,19 @@ async def _capture_redeemer_info( try: knowledge_package = ShareStorage.read_share(share_id) if not knowledge_package: - logger.warning( - f"Could not load knowledge package {share_id} to capture redeemer info" - ) + logger.warning(f"Could not load knowledge package {share_id} to capture redeemer info") return conversation_id = str(context.id) # Skip if this is the coordinator conversation if conversation_id == knowledge_package.coordinator_conversation_id: - logger.debug( - f"Skipping redeemer capture for coordinator conversation {conversation_id}" - ) + logger.debug(f"Skipping redeemer capture for coordinator conversation {conversation_id}") return # Skip if this is the shared conversation template if conversation_id == knowledge_package.shared_conversation_id: - logger.debug( - f"Skipping redeemer capture for shared conversation template {conversation_id}" - ) + logger.debug(f"Skipping redeemer capture for shared conversation template {conversation_id}") return # If we get here, it's a team member conversation - capture redeemer info @@ -367,9 +317,7 @@ async def _capture_redeemer_info( user_id, user_name = await get_current_user(context) if not user_id or not user_name: - logger.warning( - f"Could not identify redeemer for conversation {conversation_id}" - ) + logger.warning(f"Could not identify redeemer for conversation {conversation_id}") return # Create team conversation info @@ -380,15 +328,11 @@ async def _capture_redeemer_info( ) # Add to knowledge package - knowledge_package.team_conversations[conversation_id] = ( - team_conversation_info - ) + knowledge_package.team_conversations[conversation_id] = team_conversation_info # Save the updated knowledge package ShareStorage.write_share(share_id, knowledge_package) - logger.debug( - f"Captured redeemer info for team conversation {conversation_id}: {user_name} ({user_id})" - ) + logger.debug(f"Captured redeemer info for team conversation {conversation_id}: {user_name} ({user_id})") except Exception as e: logger.error(f"Error capturing redeemer info: {e}") @@ -399,12 +343,13 @@ async def get_share_log( context: ConversationContext, ) -> Optional[KnowledgePackageLog]: """Gets the knowledge transfer log for the current conversation's share.""" - share_id = await ShareManager.get_share_id(context) - if not share_id: + try: + share_id = await ShareManager.get_share_id(context) + return ShareStorage.read_share_log(share_id) + except Error as e: + logger.error(f"Error reading share log: {e}") return None - return ShareStorage.read_share_log(share_id) - @staticmethod async def append_coordinator_message( context: ConversationContext, @@ -419,20 +364,18 @@ async def append_coordinator_message( This is used to log messages sent by the coordinator in the knowledge transfer process. """ - share_id = await ShareManager.get_share_id(context) - if not share_id: - raise ValueError( - "Cannot append message: no share ID found for this conversation" + try: + share_id = await ShareManager.get_share_id(context) + ShareStorage.append_coordinator_message( + share_id=share_id, + message_id=message_id, + content=content, + sender_name=sender_name, + is_assistant=is_assistant, + timestamp=timestamp or datetime.now(timezone.utc), ) - - ShareStorage.append_coordinator_message( - share_id=share_id, - message_id=message_id, - content=content, - sender_name=sender_name, - is_assistant=is_assistant, - timestamp=timestamp or datetime.now(timezone.utc), - ) + except Exception as e: + logger.error(f"Error appending coordinator message: {e}") @staticmethod async def get_coordinator_conversation( @@ -453,7 +396,7 @@ async def log_share_event( message: str, related_entity_id: Optional[str] = None, metadata: Optional[dict] = None, - ) -> bool: + ) -> None: """ Logs an event to the knowledge transfer log. @@ -463,19 +406,17 @@ async def log_share_event( message: Log message related_entity_id: Optional ID of a related entity metadata: Optional additional metadata - - Returns: - True if logged successfully, False otherwise """ - share_id = await ShareManager.get_share_id(context) - if not share_id: - return False - - return await ShareStorage.log_share_event( - context=context, - share_id=share_id, - entry_type=entry_type, - message=message, - related_entity_id=related_entity_id, - metadata=metadata, - ) + try: + share_id = await ShareManager.get_share_id(context) + + await ShareStorage.log_share_event( + context=context, + share_id=share_id, + entry_type=entry_type, + message=message, + related_entity_id=related_entity_id, + metadata=metadata, + ) + except Error as e: + logger.error(f"Error logging share event: {e}") diff --git a/assistants/knowledge-transfer-assistant/assistant/domain/transfer_manager.py b/assistants/knowledge-transfer-assistant/assistant/domain/transfer_manager.py index 01744b729..04cb9ba53 100644 --- a/assistants/knowledge-transfer-assistant/assistant/domain/transfer_manager.py +++ b/assistants/knowledge-transfer-assistant/assistant/domain/transfer_manager.py @@ -16,9 +16,7 @@ class TransferManager: @staticmethod def is_ready_for_transfer(package: KnowledgePackage) -> bool: has_basic_requirements = ( - package.knowledge_organized - and package.brief is not None - and package.audience is not None + package.knowledge_organized and package.brief is not None and package.audience is not None ) if not has_basic_requirements: @@ -33,10 +31,7 @@ def is_ready_for_transfer(package: KnowledgePackage) -> bool: @staticmethod def is_actively_sharing(package: KnowledgePackage) -> bool: - return ( - TransferManager.is_ready_for_transfer(package) - and len(package.team_conversations) > 0 - ) + return TransferManager.is_ready_for_transfer(package) and len(package.team_conversations) > 0 @staticmethod def _is_transfer_complete(package: KnowledgePackage) -> bool: @@ -48,7 +43,5 @@ def _is_transfer_complete(package: KnowledgePackage) -> bool: if not package.is_intended_to_accomplish_outcomes: return False - achieved_outcomes, total_outcomes = ( - LearningObjectivesManager.get_overall_completion(package) - ) + achieved_outcomes, total_outcomes = LearningObjectivesManager.get_overall_completion(package) return total_outcomes > 0 and achieved_outcomes == total_outcomes diff --git a/assistants/knowledge-transfer-assistant/assistant/errors.py b/assistants/knowledge-transfer-assistant/assistant/errors.py new file mode 100644 index 000000000..97cd8f431 --- /dev/null +++ b/assistants/knowledge-transfer-assistant/assistant/errors.py @@ -0,0 +1,21 @@ +# Define an Exception class for no share found +class NoShareFoundError(Exception): + """Exception raised when no active knowledge package is found.""" + + def __init__(self, message: str): + super().__init__(message) + self.message = message + + +NoShareException = NoShareFoundError("No active share found.") + + +class NoUserFoundError(Exception): + """Exception raised when no user is found in the context.""" + + def __init__(self, message: str): + super().__init__(message) + self.message = message + + +NoUserException = NoUserFoundError("No user found in the context.") diff --git a/assistants/knowledge-transfer-assistant/assistant/files.py b/assistants/knowledge-transfer-assistant/assistant/files.py index 00ef0a53b..2f4bb246b 100644 --- a/assistants/knowledge-transfer-assistant/assistant/files.py +++ b/assistants/knowledge-transfer-assistant/assistant/files.py @@ -138,9 +138,7 @@ async def copy_file_to_share_storage( try: # Verify file information if not file.filename: - logger.error( - "Missing filename in file metadata", extra=safe_extra(log_extra) - ) + logger.error("Missing filename in file metadata", extra=safe_extra(log_extra)) return False # Check if share storage directory exists @@ -228,11 +226,7 @@ async def copy_file_to_share_storage( # Check if file already exists in collection existing_idx = next( - ( - i - for i, f in enumerate(metadata.files) - if f.filename == file.filename - ), + (i for i, f in enumerate(metadata.files) if f.filename == file.filename), None, ) if existing_idx is not None: @@ -260,9 +254,7 @@ async def copy_file_to_share_storage( ) return False - file_exists_in_metadata = any( - f.filename == file.filename for f in verification_metadata.files - ) + file_exists_in_metadata = any(f.filename == file.filename for f in verification_metadata.files) if not file_exists_in_metadata: logger.error( f"File metadata doesn't contain entry for {file.filename}", @@ -280,9 +272,7 @@ async def copy_file_to_share_storage( return True except Exception as e: - logger.exception( - f"Error copying file to share storage: {e}", extra=safe_extra(log_extra) - ) + logger.exception(f"Error copying file to share storage: {e}", extra=safe_extra(log_extra)) return False @staticmethod @@ -333,17 +323,13 @@ async def notify_team_conversations_file_deleted( """ try: # Get Team conversations - team_conversations = await ShareFilesManager.get_team_conversations( - context, share_id - ) + team_conversations = await ShareFilesManager.get_team_conversations(context, share_id) if not team_conversations: return for conv_id in team_conversations: try: - client = ConversationClientManager.get_conversation_client( - context, conv_id - ) + client = ConversationClientManager.get_conversation_client(context, conv_id) # Check if file exists in the conversation conversation = await client.get_conversation() @@ -353,9 +339,7 @@ async def notify_team_conversations_file_deleted( if file_exists: # Delete the file await client.delete_file(filename) - logger.debug( - f"Deleted file {filename} from Team conversation {conv_id}" - ) + logger.debug(f"Deleted file {filename} from Team conversation {conv_id}") # Send notification await client.send_messages( @@ -365,14 +349,10 @@ async def notify_team_conversations_file_deleted( ) ) except Exception as e: - logger.warning( - f"Failed to delete file {filename} from Team conversation {conv_id}: {e}" - ) + logger.warning(f"Failed to delete file {filename} from Team conversation {conv_id}: {e}") except Exception as e: - logger.exception( - f"Error notifying Team conversations about deleted file: {e}" - ) + logger.exception(f"Error notifying Team conversations about deleted file: {e}") @staticmethod async def copy_file_to_conversation( @@ -398,21 +378,15 @@ async def copy_file_to_conversation( return False # Find the file metadata - file_meta = next( - (f for f in metadata.files if f.filename == filename), None - ) + file_meta = next((f for f in metadata.files if f.filename == filename), None) if not file_meta: logger.warning(f"No metadata found for file {filename}") return False # Create client for target conversation - target_client = ConversationClientManager.get_conversation_client( - context, target_conversation_id - ) + target_client = ConversationClientManager.get_conversation_client(context, target_conversation_id) if not target_client: - logger.warning( - f"Could not create client for conversation {target_conversation_id}" - ) + logger.warning(f"Could not create client for conversation {target_conversation_id}") return False # Read the file content @@ -421,9 +395,7 @@ async def copy_file_to_conversation( file_bytes = f.read() if not file_bytes: - logger.warning( - f"Failed to read file content from {file_path} (empty file)" - ) + logger.warning(f"Failed to read file content from {file_path} (empty file)") return False file_content = io.BytesIO(file_bytes) @@ -471,9 +443,7 @@ async def copy_file_to_conversation( return False @staticmethod - async def get_team_conversations( - context: ConversationContext, share_id: str - ) -> List[str]: + async def get_team_conversations(context: ConversationContext, share_id: str) -> List[str]: """ Gets all Team conversation IDs for a share. """ @@ -485,9 +455,7 @@ async def get_team_conversations( team_conversations = [] for conv_id in linked_conversations: # Check if this is a team conversation - temp_context = await ShareFilesManager.create_temporary_context( - context, conv_id - ) + temp_context = await ShareFilesManager.create_temporary_context(context, conv_id) if temp_context: role = await ShareManager.get_conversation_role(temp_context) if role == ConversationRole.TEAM: @@ -596,7 +564,9 @@ async def synchronize_files_to_team_conversation( ) # Log the synchronization event - sync_message = f"Synchronized files to Team conversation: {len(successful_files)} new, {len(skipped_files)} existing" + sync_message = ( + f"Synchronized files to Team conversation: {len(successful_files)} new, {len(skipped_files)} existing" + ) await ShareManager.log_share_event( context=context, @@ -610,9 +580,7 @@ async def synchronize_files_to_team_conversation( ) @staticmethod - async def get_shared_files( - context: ConversationContext, share_id: str - ) -> Dict[str, ShareFile]: + async def get_shared_files(context: ConversationContext, share_id: str) -> Dict[str, ShareFile]: """ Gets all shared files for a share with filename as key. """ @@ -642,9 +610,7 @@ async def process_share_update_notification( role = await ShareManager.get_conversation_role(context) if role != ConversationRole.TEAM: - logger.warning( - "Only Team conversations should process file update notifications" - ) + logger.warning("Only Team conversations should process file update notifications") return False # Process based on update type @@ -661,9 +627,7 @@ async def process_share_update_notification( if success: return True else: - logger.warning( - f"Failed to {action} file {filename} in Team conversation {context.id}" - ) + logger.warning(f"Failed to {action} file {filename} in Team conversation {context.id}") return False elif update_type == "file_deleted": @@ -677,18 +641,14 @@ async def process_share_update_notification( if file_exists: # Delete the file await context.delete_file(filename) - logger.debug( - f"Deleted file {filename} from Team conversation {context.id}" - ) + logger.debug(f"Deleted file {filename} from Team conversation {context.id}") return True else: # File doesn't exist, nothing to do return True except Exception as e: - logger.warning( - f"Failed to delete file {filename} from Team conversation: {e}" - ) + logger.warning(f"Failed to delete file {filename} from Team conversation: {e}") return False else: logger.warning(f"Unknown file update type: {update_type}") diff --git a/assistants/knowledge-transfer-assistant/assistant/logging.py b/assistants/knowledge-transfer-assistant/assistant/logging.py index 0a942f96f..6a50e1156 100644 --- a/assistants/knowledge-transfer-assistant/assistant/logging.py +++ b/assistants/knowledge-transfer-assistant/assistant/logging.py @@ -161,9 +161,7 @@ def setup_file_logging(log_dir: Optional[str] = None) -> Path: # Also set up a regular text file handler for easier debugging text_file_handler = logging.FileHandler(line_log_file) text_file_handler.setLevel(logging.DEBUG) - text_file_handler.setFormatter( - logging.Formatter("%(asctime)s - %(name)s - [%(levelname)s] %(message)s") - ) + text_file_handler.setFormatter(logging.Formatter("%(asctime)s - %(name)s - [%(levelname)s] %(message)s")) logger.addHandler(text_file_handler) logger.debug( @@ -185,12 +183,8 @@ def setup_file_logging(log_dir: Optional[str] = None) -> Path: try: import stat - os.chmod( - log_file, stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IROTH - ) - os.chmod( - line_log_file, stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IROTH - ) + os.chmod(log_file, stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IROTH) + os.chmod(line_log_file, stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IROTH) except Exception as e: logger.warning(f"Could not set log file permissions: {e}") print(f"Permission error: {e}") @@ -216,11 +210,7 @@ def setup_file_logging(log_dir: Optional[str] = None) -> Path: text_file_handler = logging.FileHandler(line_log_file) text_file_handler.setLevel(logging.DEBUG) - text_file_handler.setFormatter( - logging.Formatter( - "%(asctime)s - %(name)s - [%(levelname)s] %(message)s" - ) - ) + text_file_handler.setFormatter(logging.Formatter("%(asctime)s - %(name)s - [%(levelname)s] %(message)s")) logger.addHandler(text_file_handler) logger.warning(f"Using fallback log location: {log_file}") diff --git a/assistants/knowledge-transfer-assistant/assistant/notifications.py b/assistants/knowledge-transfer-assistant/assistant/notifications.py index ed3857ce5..365c20afb 100644 --- a/assistants/knowledge-transfer-assistant/assistant/notifications.py +++ b/assistants/knowledge-transfer-assistant/assistant/notifications.py @@ -59,9 +59,7 @@ async def notify_self_and_other( return try: - client = ConversationClientManager.get_conversation_client( - context, target_id - ) + client = ConversationClientManager.get_conversation_client(context, target_id) await client.send_messages( NewConversationMessage( content=message, @@ -72,9 +70,7 @@ async def notify_self_and_other( logger.error(f"Failed to notify conversation {target_id}: {e}") @staticmethod - async def notify_all( - context: ConversationContext, share_id: str, message: str - ) -> None: + async def notify_all(context: ConversationContext, share_id: str, message: str) -> None: """Send text message notification to all knowledge transfer conversations.""" knowledge_package = await ShareManager.get_share(context) @@ -106,14 +102,9 @@ async def notify_all( # Notify all team conversations for conv_id in knowledge_package.team_conversations.keys(): - if ( - conv_id != current_id - and conv_id != knowledge_package.coordinator_conversation_id - ): + if conv_id != current_id and conv_id != knowledge_package.coordinator_conversation_id: try: - client = ConversationClientManager.get_conversation_client( - context, conv_id - ) + client = ConversationClientManager.get_conversation_client(context, conv_id) await client.send_messages( NewConversationMessage( content=message, @@ -126,9 +117,7 @@ async def notify_all( # State Update Notifications (UI refreshes) @staticmethod - async def notify_state_update( - context: ConversationContext, tabs: List[InspectorTab] - ) -> None: + async def notify_state_update(context: ConversationContext, tabs: List[InspectorTab]) -> None: """Send state update notifications to refresh UI in current conversation only.""" for tab in tabs: state_event = AssistantStateEvent( @@ -139,9 +128,7 @@ async def notify_state_update( await context.send_conversation_state_event(state_event) @staticmethod - async def notify_all_state_update( - context: ConversationContext, share_id: str, tabs: List[InspectorTab] - ) -> None: + async def notify_all_state_update(context: ConversationContext, share_id: str, tabs: List[InspectorTab]) -> None: """Send state update notifications to refresh UI across all share conversations.""" # Refresh current conversation first @@ -180,14 +167,9 @@ async def notify_all_state_update( # Refresh all team conversations for conv_id in knowledge_package.team_conversations.keys(): - if ( - conv_id != current_id - and conv_id != knowledge_package.coordinator_conversation_id - ): + if conv_id != current_id and conv_id != knowledge_package.coordinator_conversation_id: try: - client = ConversationClientManager.get_conversation_client( - context, conv_id - ) + client = ConversationClientManager.get_conversation_client(context, conv_id) for tab in tabs: state_event = AssistantStateEvent( diff --git a/assistants/knowledge-transfer-assistant/assistant/respond.py b/assistants/knowledge-transfer-assistant/assistant/respond.py index 4d2d8ef0c..6268539ad 100644 --- a/assistants/knowledge-transfer-assistant/assistant/respond.py +++ b/assistants/knowledge-transfer-assistant/assistant/respond.py @@ -42,21 +42,13 @@ SILENCE_TOKEN = "{{SILENCE}}" -def format_message( - participants: ConversationParticipantList, message: ConversationMessage -) -> str: +def format_message(participants: ConversationParticipantList, message: ConversationMessage) -> str: """Consistent formatter that includes the participant name for multi-participant and name references""" conversation_participant = next( - ( - participant - for participant in participants.participants - if participant.id == message.sender.participant_id - ), + (participant for participant in participants.participants if participant.id == message.sender.participant_id), None, ) - participant_name = ( - conversation_participant.name if conversation_participant else "unknown" - ) + participant_name = conversation_participant.name if conversation_participant else "unknown" message_datetime = message.timestamp.strftime("%Y-%m-%d %H:%M:%S") return f"[{participant_name} - {message_datetime}]: {message.content}" @@ -123,10 +115,7 @@ async def respond_to_conversation( model = config.request_config.openai_model # Requirements - role = ( - await ShareManager.get_conversation_role(context) - or ConversationRole.COORDINATOR - ) + role = await ShareManager.get_conversation_role(context) or ConversationRole.COORDINATOR metadata["debug"]["role"] = role token_budget = TokenBudget(config.request_config.max_tokens) @@ -159,13 +148,11 @@ async def respond_to_conversation( "\n\n" f"There are {len(participants.participants)} participants in the conversation," " including you as the assistant and the following users:" - + ",".join( - [ - f' "{participant.name}"' - for participant in participants.participants - if participant.id != context.assistant.id - ] - ) + + ",".join([ + f' "{participant.name}"' + for participant in participants.participants + if participant.id != context.assistant.id + ]) + "\n\nYou do not need to respond to every message. Do not respond if the last thing said was a closing" " statement such as 'bye' or 'goodbye', or just a general acknowledgement like 'ok' or 'thanks'. Do not" f' respond as another user in the conversation, only as "{context.assistant.name}".' @@ -173,11 +160,7 @@ async def respond_to_conversation( f' be directed at you or the general audience, go ahead and respond.\n\nSay "{SILENCE_TOKEN}" to skip' " your turn." ) - instructions.add_subsection( - Instructions( - participant_text, "Multi-participant conversation instructions" - ) - ) + instructions.add_subsection(Instructions(participant_text, "Multi-participant conversation instructions")) prompt = Prompt( role=assistant_role, @@ -215,9 +198,7 @@ async def respond_to_conversation( # Brief if share and share.brief: brief_text = "" - brief_text = ( - f"**Title:** {share.brief.title}\n**Description:** {share.brief.content}" - ) + brief_text = f"**Title:** {share.brief.title}\n**Description:** {share.brief.content}" prompt.contexts.append( Context( "Knowledge Brief", @@ -247,37 +228,29 @@ async def respond_to_conversation( # Show progress based on role if role == ConversationRole.COORDINATOR: # Coordinator sees overall progress across all team members - achieved_overall, total_overall = ( - LearningObjectivesManager.get_overall_completion(share) + achieved_overall, total_overall = LearningObjectivesManager.get_overall_completion(share) + learning_objectives_text += ( + f"Overall Progress: {achieved_overall}/{total_overall} outcomes achieved by team members\n\n" ) - learning_objectives_text += f"Overall Progress: {achieved_overall}/{total_overall} outcomes achieved by team members\n\n" else: # Team member sees their personal progress if conversation_id in share.team_conversations: - achieved_personal, total_personal = ( - LearningObjectivesManager.get_completion_for_conversation( - share, conversation_id - ) + achieved_personal, total_personal = LearningObjectivesManager.get_completion_for_conversation( + share, conversation_id ) - progress_pct = ( - int((achieved_personal / total_personal * 100)) - if total_personal > 0 - else 0 + progress_pct = int((achieved_personal / total_personal * 100)) if total_personal > 0 else 0 + learning_objectives_text += ( + f"My Progress: {achieved_personal}/{total_personal} outcomes achieved ({progress_pct}%)\n\n" ) - learning_objectives_text += f"My Progress: {achieved_personal}/{total_personal} outcomes achieved ({progress_pct}%)\n\n" for i, objective in enumerate(share.learning_objectives): - learning_objectives_text += ( - f"{i + 1}. **{objective.name}** - {objective.description}\n" - ) + learning_objectives_text += f"{i + 1}. **{objective.name}** - {objective.description}\n" if objective.learning_outcomes: for criterion in objective.learning_outcomes: if role == ConversationRole.COORDINATOR: # Show if achieved by any team member achieved_by_any = any( - LearningObjectivesManager.is_outcome_achieved_by_conversation( - share, criterion.id, conv_id - ) + LearningObjectivesManager.is_outcome_achieved_by_conversation(share, criterion.id, conv_id) for conv_id in share.team_conversations.keys() ) check = "✅" if achieved_by_any else "⬜" @@ -310,24 +283,20 @@ async def respond_to_conversation( if share: all_requests = share.requests if role == ConversationRole.COORDINATOR: - active_requests = [ - r for r in all_requests if r.status != RequestStatus.RESOLVED - ] + active_requests = [r for r in all_requests if r.status != RequestStatus.RESOLVED] if active_requests: - coordinator_requests = "> 📋 **Use the request ID (not the title) with resolve_information_request()**\n\n" + coordinator_requests = ( + "> 📋 **Use the request ID (not the title) with resolve_information_request()**\n\n" + ) for req in active_requests[:10]: # Limit to 10 for brevity priority_emoji = get_priority_emoji(req.priority) status_emoji = get_status_emoji(req.status) - coordinator_requests += ( - f"{priority_emoji} **{req.title}** {status_emoji}\n" - ) + coordinator_requests += f"{priority_emoji} **{req.title}** {status_emoji}\n" coordinator_requests += f" **Request ID:** `{req.request_id}`\n" coordinator_requests += f" **Description:** {req.description}\n\n" if len(active_requests) > 10: - coordinator_requests += ( - f"*...and {len(active_requests) - 10} more requests.*\n" - ) + coordinator_requests += f"*...and {len(active_requests) - 10} more requests.*\n" else: coordinator_requests = "No active information requests." prompt.contexts.append( @@ -342,16 +311,15 @@ async def respond_to_conversation( # Filter for requests from this conversation that aren't resolved. my_requests = [ - r - for r in all_requests - if r.conversation_id == str(context.id) - and r.status != RequestStatus.RESOLVED + r for r in all_requests if r.conversation_id == str(context.id) and r.status != RequestStatus.RESOLVED ] if my_requests: information_requests_info = "" for req in my_requests: - information_requests_info += f"- **{req.title}** (ID: `{req.request_id}`, Priority: {req.priority})\n" + information_requests_info += ( + f"- **{req.title}** (ID: `{req.request_id}`, Priority: {req.priority})\n" + ) else: information_requests_info = "No active information requests." @@ -364,9 +332,7 @@ async def respond_to_conversation( # Add next action suggestions for coordinator if role == ConversationRole.COORDINATOR: - next_action_suggestion = ( - await CoordinatorSupport.get_coordinator_next_action_suggestion(context) - ) + next_action_suggestion = await CoordinatorSupport.get_coordinator_next_action_suggestion(context) if next_action_suggestion: prompt.contexts.append( Context( @@ -394,13 +360,9 @@ async def respond_to_conversation( if coordinator_conversation: # Limit messages to the configured max token count. total_coordinator_conversation_tokens = 0 - selected_coordinator_conversation_messages: List[ - CoordinatorConversationMessage - ] = [] + selected_coordinator_conversation_messages: List[CoordinatorConversationMessage] = [] for msg in reversed(coordinator_conversation.messages): - tokens = openai_client.num_tokens_from_string( - msg.model_dump_json(), model=model - ) + tokens = openai_client.num_tokens_from_string(msg.model_dump_json(), model=model) if ( total_coordinator_conversation_tokens + tokens > config.request_config.coordinator_conversation_token_limit @@ -414,9 +376,7 @@ class CoordinatorMessageList(BaseModel): messages: List[CoordinatorConversationMessage] = Field(default_factory=list) selected_coordinator_conversation_messages.reverse() - coordinator_message_list = CoordinatorMessageList( - messages=selected_coordinator_conversation_messages - ) + coordinator_message_list = CoordinatorMessageList(messages=selected_coordinator_conversation_messages) coordinator_conversation_message = ChatCompletionSystemMessageParam( role="system", content=( @@ -440,12 +400,10 @@ class CoordinatorMessageList(BaseModel): # in the proper flow of the conversation rather than as . # Generate the attachment messages. - attachment_messages: List[ChatCompletionMessageParam] = ( - openai_client.convert_from_completion_messages( - await attachments_extension.get_completion_messages_for_attachments( - context, - config=config.attachments_config, - ) + attachment_messages: List[ChatCompletionMessageParam] = openai_client.convert_from_completion_messages( + await attachments_extension.get_completion_messages_for_attachments( + context, + config=config.attachments_config, ) ) @@ -570,14 +528,9 @@ class CoordinatorMessageList(BaseModel): # For team role, analyze message for possible information request needs. # Send a notification if we think it might be one. if role is ConversationRole.TEAM: - detection_result = await detect_information_request_needs( - context, new_message.content - ) + detection_result = await detect_information_request_needs(context, new_message.content) - if ( - detection_result.get("is_information_request", False) - and detection_result.get("confidence", 0) > 0.8 - ): + if detection_result.get("is_information_request", False) and detection_result.get("confidence", 0) > 0.8: suggested_title = detection_result.get("potential_title", "") suggested_priority = detection_result.get("suggested_priority", "medium") potential_description = detection_result.get("potential_description", "") @@ -611,9 +564,7 @@ class CoordinatorMessageList(BaseModel): "messages": completion_messages, "model": model, "max_tokens": config.request_config.response_tokens, - "response_format": CoordinatorOutput - if role == ConversationRole.COORDINATOR - else TeamOutput, + "response_format": CoordinatorOutput if role == ConversationRole.COORDINATOR else TeamOutput, } share_tools = ShareTools(context, role) @@ -629,11 +580,7 @@ class CoordinatorMessageList(BaseModel): # Add the token usage message to the footer items if completion_response: - response_tokens = ( - completion_response.usage.completion_tokens - if completion_response.usage - else 0 - ) + response_tokens = completion_response.usage.completion_tokens if completion_response.usage else 0 request_tokens = token_budget.used footer_items.append( get_token_usage_message( @@ -653,9 +600,7 @@ class CoordinatorMessageList(BaseModel): } ) - footer_items.append( - get_response_duration_message(response_end_time - response_start_time) - ) + footer_items.append(get_response_duration_message(response_end_time - response_start_time)) metadata["footer_items"] = footer_items content = message_content_from_completion(completion_response) diff --git a/assistants/knowledge-transfer-assistant/assistant/storage.py b/assistants/knowledge-transfer-assistant/assistant/storage.py index 4b3e87122..8b553dd02 100644 --- a/assistants/knowledge-transfer-assistant/assistant/storage.py +++ b/assistants/knowledge-transfer-assistant/assistant/storage.py @@ -147,9 +147,7 @@ def read_coordinator_conversation( return read_model(path, CoordinatorConversationMessages) @staticmethod - def write_coordinator_conversation( - share_id: str, conversation: CoordinatorConversationMessages - ) -> pathlib.Path: + def write_coordinator_conversation(share_id: str, conversation: CoordinatorConversationMessages) -> pathlib.Path: path = ShareStorageManager.get_coordinator_conversation_path(share_id) write_model(path, conversation) return path @@ -211,9 +209,7 @@ def write_knowledge_digest(share_id: str, digest: KnowledgeDigest) -> pathlib.Pa return ShareStorage.write_share(share_id, package) @staticmethod - def read_information_request( - share_id: str, request_id: str - ) -> Optional[InformationRequest]: + def read_information_request(share_id: str, request_id: str) -> Optional[InformationRequest]: """Reads an information request from the main share data.""" package = ShareStorage.read_share(share_id) if not package or not package.requests: @@ -226,9 +222,7 @@ def read_information_request( return None @staticmethod - def write_information_request( - share_id: str, request: InformationRequest - ) -> pathlib.Path: + def write_information_request(share_id: str, request: InformationRequest) -> pathlib.Path: """Writes an information request to the main share data.""" # Information requests must have an ID if not request.request_id: diff --git a/assistants/knowledge-transfer-assistant/assistant/string_utils.py b/assistants/knowledge-transfer-assistant/assistant/string_utils.py index b783dc6ca..a9bcb7892 100644 --- a/assistants/knowledge-transfer-assistant/assistant/string_utils.py +++ b/assistants/knowledge-transfer-assistant/assistant/string_utils.py @@ -18,9 +18,7 @@ def render(template: str, vars: dict[str, Any]) -> str: return parsed -def create_system_message( - content: str, delimiter: str | None = None -) -> ChatCompletionMessageParam: +def create_system_message(content: str, delimiter: str | None = None) -> ChatCompletionMessageParam: if delimiter: content = f"<{delimiter}>\n{content}\n" @@ -60,9 +58,7 @@ def __str__(self) -> str: s += f"{hashes} {self.title}\n\n" s += self.content if self.subsections: - s += "\n\n" + "\n\n".join( - str(subsection) for subsection in self.subsections - ) + s += "\n\n" + "\n\n".join(str(subsection) for subsection in self.subsections) return s diff --git a/assistants/knowledge-transfer-assistant/assistant/tools/information_requests.py b/assistants/knowledge-transfer-assistant/assistant/tools/information_requests.py index f889ded95..4443ce8ac 100644 --- a/assistants/knowledge-transfer-assistant/assistant/tools/information_requests.py +++ b/assistants/knowledge-transfer-assistant/assistant/tools/information_requests.py @@ -6,8 +6,8 @@ from typing import Literal -from assistant.data import ConversationRole, RequestPriority -from assistant.domain import InformationRequestManager, ShareManager +from assistant.data import RequestPriority +from assistant.domain import InformationRequestManager from assistant.logging import logger from .base import ToolsBase @@ -46,12 +46,6 @@ async def create_information_request( Returns: A message indicating success or failure """ - if self.role is not ConversationRole.TEAM: - return "Only Team members can create information requests." - - share = await ShareManager.get_share(self.context) - if not share: - return "No knowledge package associated with this conversation. Unable to create information request." priority_map = { "low": RequestPriority.LOW, @@ -61,20 +55,19 @@ async def create_information_request( } priority_enum = priority_map.get(priority.lower(), RequestPriority.MEDIUM) - success, request = await InformationRequestManager.create_information_request( - context=self.context, - title=title, - description=description, - priority=priority_enum, - ) - if success and request: + try: + await InformationRequestManager.create_information_request( + context=self.context, + title=title, + description=description, + priority=priority_enum, + ) return f"Information request '{title}' created successfully. The Coordinator has been notified." - else: - return "Failed to create information request. Please try again." + except Exception as e: + logger.exception(f"Failed to create information request: {e}") + return f"Failed to create information request: {str(e)}" - async def resolve_information_request( - self, request_id: str, resolution: str - ) -> str: + async def resolve_information_request(self, request_id: str, resolution: str) -> str: """ Resolve an information request when you have the needed information to address it. Only use for active information requests. If there are no active information requests, this should never be called. @@ -91,39 +84,14 @@ async def resolve_information_request( Returns: A message indicating success or failure """ - if self.role is not ConversationRole.COORDINATOR: - # Add more detailed error message with guidance - error_message = ( - "ERROR: Only Coordinator can resolve information requests. As a Team member, you should use " - "create_information_request to send requests to the Coordinator, not try to resolve them yourself. " - "The Coordinator must use resolve_information_request to respond to your requests." - ) - logger.warning( - f"Team member attempted to use resolve_information_request: {request_id}" - ) - return error_message - - share = await ShareManager.get_share(self.context) - if not share: - return "No knowledge package associated with this conversation. Unable to resolve information request." - - ( - success, - information_request, - ) = await InformationRequestManager.resolve_information_request( - context=self.context, request_id=request_id, resolution=resolution - ) - if success and information_request: - return ( - f"Information request '{information_request.title}' has been resolved." - ) - else: - logger.warning( - f"Failed to resolve information request. Invalid ID provided: '{request_id}'" - ) - return ( - f'ERROR: Could not resolve information request with ID "{request_id}".' + try: + information_request = await InformationRequestManager.resolve_information_request( + context=self.context, request_id=request_id, resolution=resolution ) + return f"Information request '{information_request.title}' has been resolved." + except Exception as e: + logger.exception(f"Failed to resolve information request: {e}") + return f"ERROR: Could not resolve information request with ID '{request_id}': {str(e)}" async def delete_information_request(self, request_id: str) -> str: """ @@ -136,18 +104,11 @@ async def delete_information_request(self, request_id: str) -> str: Returns: Message indicating success or failure """ - if self.role is not ConversationRole.TEAM: - return "This tool is only available to Team members." - - success, message = await InformationRequestManager.delete_information_request( - context=self.context, request_id=request_id - ) - return ( - message - if message - else ( - "Request deleted successfully." - if success - else "Failed to delete request." + try: + message = await InformationRequestManager.delete_information_request( + context=self.context, request_id=request_id ) - ) + return message + except Exception as e: + logger.exception(f"Failed to delete information request: {e}") + return f"Failed to delete information request: {str(e)}" diff --git a/assistants/knowledge-transfer-assistant/assistant/tools/learning_objectives.py b/assistants/knowledge-transfer-assistant/assistant/tools/learning_objectives.py index bdce598ce..a578e5652 100644 --- a/assistants/knowledge-transfer-assistant/assistant/tools/learning_objectives.py +++ b/assistants/knowledge-transfer-assistant/assistant/tools/learning_objectives.py @@ -6,8 +6,8 @@ from typing import List -from assistant.data import ConversationRole from assistant.domain import LearningObjectivesManager +from assistant.logging import logger from .base import ToolsBase @@ -15,9 +15,7 @@ class LearningObjectiveTools(ToolsBase): """Tools for managing learning objectives and outcomes.""" - async def add_learning_objective( - self, objective_name: str, description: str, learning_outcomes: List[str] - ) -> str: + async def add_learning_objective(self, objective_name: str, description: str, learning_outcomes: List[str]) -> str: """ Add a learning objective with measurable learning outcomes. @@ -39,10 +37,6 @@ async def add_learning_objective( Returns: A message indicating success or failure """ - - if self.role is not ConversationRole.COORDINATOR: - return "Only Coordinator can add learning objectives." - objective = await LearningObjectivesManager.add_learning_objective( context=self.context, objective_name=objective_name, @@ -72,25 +66,17 @@ async def update_learning_objective( Returns: A message indicating success or failure """ - if self.role is not ConversationRole.COORDINATOR: - return "Only Coordinator can update learning objectives." - - success, message = await LearningObjectivesManager.update_learning_objective( - context=self.context, - objective_id=objective_id, - objective_name=objective_name if objective_name.strip() else None, - description=description if description.strip() else None, - ) - - return ( - message - if message - else ( - "Learning objective updated successfully." - if success - else "Failed to update learning objective." + try: + message = await LearningObjectivesManager.update_learning_objective( + context=self.context, + objective_id=objective_id, + objective_name=objective_name if objective_name.strip() else None, + description=description if description.strip() else None, ) - ) + return message + except Exception as e: + logger.exception(f"Failed to update learning objective: {e}") + return f"Failed to update learning objective: {str(e)}" async def delete_learning_objective(self, objective_id: str) -> str: """ @@ -110,21 +96,12 @@ async def delete_learning_objective(self, objective_id: str) -> str: Returns: A message indicating success or failure """ - - if self.role is not ConversationRole.COORDINATOR: - return "Only Coordinator can delete learning objectives." - - success, message = await LearningObjectivesManager.delete_learning_objective( - context=self.context, - objective_id=objective_id, - ) - - return ( - message - if message - else ( - "Learning objective deleted successfully." - if success - else "Failed to delete learning objective." + try: + message = await LearningObjectivesManager.delete_learning_objective( + context=self.context, + objective_id=objective_id, ) - ) + return message + except Exception as e: + logger.exception(f"Failed to delete learning objective: {e}") + return f"Failed to delete learning objective: {str(e)}" diff --git a/assistants/knowledge-transfer-assistant/assistant/tools/learning_outcomes.py b/assistants/knowledge-transfer-assistant/assistant/tools/learning_outcomes.py index bc07014b0..510380d24 100644 --- a/assistants/knowledge-transfer-assistant/assistant/tools/learning_outcomes.py +++ b/assistants/knowledge-transfer-assistant/assistant/tools/learning_outcomes.py @@ -4,8 +4,8 @@ Tools for managing individual learning outcomes within objectives. """ -from assistant.data import ConversationRole from assistant.domain import LearningObjectivesManager +from assistant.logging import logger from .base import ToolsBase @@ -13,9 +13,7 @@ class LearningOutcomeTools(ToolsBase): """Tools for managing learning outcomes.""" - async def add_learning_outcome( - self, objective_id: str, outcome_description: str - ) -> str: + async def add_learning_outcome(self, objective_id: str, outcome_description: str) -> str: """ Add a new learning outcome to an existing learning objective. @@ -32,28 +30,18 @@ async def add_learning_outcome( Returns: A message indicating success or failure """ - if self.role is not ConversationRole.COORDINATOR: - return "Only Coordinator can add learning outcomes." - - success, message = await LearningObjectivesManager.add_learning_outcome( - context=self.context, - objective_id=objective_id, - outcome_description=outcome_description, - ) - - return ( - message - if message - else ( - "Learning outcome added successfully." - if success - else "Failed to add learning outcome." + try: + message = await LearningObjectivesManager.add_learning_outcome( + context=self.context, + objective_id=objective_id, + outcome_description=outcome_description, ) - ) + return message + except Exception as e: + logger.exception(f"Failed to add learning outcome: {e}") + return f"Failed to add learning outcome: {str(e)}" - async def update_learning_outcome( - self, outcome_id: str, new_description: str - ) -> str: + async def update_learning_outcome(self, outcome_id: str, new_description: str) -> str: """ Update the description of an existing learning outcome. @@ -70,24 +58,16 @@ async def update_learning_outcome( Returns: A message indicating success or failure """ - if self.role is not ConversationRole.COORDINATOR: - return "Only Coordinator can update learning outcomes." - - success, message = await LearningObjectivesManager.update_learning_outcome( - context=self.context, - outcome_id=outcome_id, - new_description=new_description, - ) - - return ( - message - if message - else ( - "Learning outcome updated successfully." - if success - else "Failed to update learning outcome." + try: + message = await LearningObjectivesManager.update_learning_outcome( + context=self.context, + outcome_id=outcome_id, + new_description=new_description, ) - ) + return message + except Exception as e: + logger.exception(f"Failed to update learning outcome: {e}") + return f"Failed to update learning outcome: {str(e)}" async def delete_learning_outcome(self, outcome_id: str) -> str: """ @@ -107,20 +87,12 @@ async def delete_learning_outcome(self, outcome_id: str) -> str: Returns: A message indicating success or failure """ - if self.role is not ConversationRole.COORDINATOR: - return "Only Coordinator can delete learning outcomes." - - success, message = await LearningObjectivesManager.delete_learning_outcome( - context=self.context, - outcome_id=outcome_id, - ) - - return ( - message - if message - else ( - "Learning outcome deleted successfully." - if success - else "Failed to delete learning outcome." + try: + message = await LearningObjectivesManager.delete_learning_outcome( + context=self.context, + outcome_id=outcome_id, ) - ) + return message + except Exception as e: + logger.exception(f"Failed to delete learning outcome: {e}") + return f"Failed to delete learning outcome: {str(e)}" diff --git a/assistants/knowledge-transfer-assistant/assistant/tools/progress_tracking.py b/assistants/knowledge-transfer-assistant/assistant/tools/progress_tracking.py index bd1be50cf..555abbbfd 100644 --- a/assistants/knowledge-transfer-assistant/assistant/tools/progress_tracking.py +++ b/assistants/knowledge-transfer-assistant/assistant/tools/progress_tracking.py @@ -12,13 +12,14 @@ ) from assistant.data import ( - ConversationRole, InspectorTab, LearningOutcomeAchievement, LogEntryType, ) from assistant.domain import LearningObjectivesManager, ShareManager, TransferManager +from assistant.logging import logger from assistant.notifications import Notifications +from assistant.utils import get_current_user_id from .base import ToolsBase @@ -26,9 +27,7 @@ class ProgressTrackingTools(ToolsBase): """Tools for tracking learning progress.""" - async def mark_learning_outcome_achieved( - self, objective_id: str, outcome_id: str - ) -> str: + async def mark_learning_outcome_achieved(self, objective_id: str, outcome_id: str) -> str: """ Mark a learning outcome as achieved for tracking knowledge transfer progress. @@ -48,108 +47,72 @@ async def mark_learning_outcome_achieved( Returns: A message indicating success or failure """ + try: + share = await ShareManager.get_share(self.context) + brief = share.brief + if not brief: + return "No knowledge brief found." + + # Find the objective by ID + objective = None + for obj in share.learning_objectives: + if obj.id == objective_id: + objective = obj + break + if not objective: + return f"Learning objective with ID '{objective_id}' not found." + + # Find the outcome by ID within the objective + outcome = None + for out in objective.learning_outcomes: + if out.id == outcome_id: + outcome = out + break + + if not outcome: + return f"Learning outcome with ID '{outcome_id}' not found in objective '{objective.name}'." + + conversation_id = str(self.context.id) + + # Check if already achieved by this conversation + if LearningObjectivesManager.is_outcome_achieved_by_conversation(share, outcome.id, conversation_id): + return f"Outcome '{outcome.description}' is already marked as achieved by this team member." + + # Ensure team conversation info exists + if conversation_id not in share.team_conversations: + return "Team conversation not properly registered. Please contact the coordinator." + + # Create achievement record + achievement = LearningOutcomeAchievement( + outcome_id=outcome.id, achieved=True, achieved_at=datetime.now(timezone.utc) + ) + + # Add achievement to team conversation's achievements + share.team_conversations[conversation_id].outcome_achievements.append(achievement) + + # Update team conversation's last active timestamp + share.team_conversations[conversation_id].last_active_at = datetime.now(timezone.utc) - if self.role is not ConversationRole.TEAM: - return "Only Team members can mark criteria as completed." - - # Get share ID - share = await ShareManager.get_share(self.context) - if not share: - return "No knowledge package associated with this conversation. Unable to mark outcome as achieved." - - # Get existing knowledge brief - brief = share.brief - if not brief: - return "No knowledge brief found." - - # Get the knowledge package to access objectives - knowledge_package = await ShareManager.get_share(self.context) - if not knowledge_package or not knowledge_package: - return "No learning objectives found." - - # Find the objective by ID - objective = None - for obj in knowledge_package.learning_objectives: - if obj.id == objective_id: - objective = obj - break - - if not objective: - return f"Learning objective with ID '{objective_id}' not found." - - # Find the outcome by ID within the objective - outcome = None - for out in objective.learning_outcomes: - if out.id == outcome_id: - outcome = out - break - - if not outcome: - return f"Learning outcome with ID '{outcome_id}' not found in objective '{objective.name}'." - - conversation_id = str(self.context.id) - - # Check if already achieved by this conversation - if LearningObjectivesManager.is_outcome_achieved_by_conversation( - knowledge_package, outcome.id, conversation_id - ): - return f"Outcome '{outcome.description}' is already marked as achieved by this team member." - - # Get current user information - participants = await self.context.get_participants() - current_user_id = None - - for participant in participants.participants: - if participant.role == "user": - current_user_id = participant.id - break - - if not current_user_id: - return "Could not identify current user." - - # Ensure team conversation info exists - if conversation_id not in knowledge_package.team_conversations: - return "Team conversation not properly registered. Please contact the coordinator." - - # Create achievement record - achievement = LearningOutcomeAchievement( - outcome_id=outcome.id, achieved=True, achieved_at=datetime.now(timezone.utc) - ) - - # Add achievement to team conversation's achievements - knowledge_package.team_conversations[ - conversation_id - ].outcome_achievements.append(achievement) - - # Update team conversation's last active timestamp - knowledge_package.team_conversations[ - conversation_id - ].last_active_at = datetime.now(timezone.utc) - - # Save the updated knowledge package with the achieved outcome - await ShareManager.set_share(self.context, knowledge_package) - - # Log the outcome achievement - await ShareManager.log_share_event( - context=self.context, - entry_type=LogEntryType.OUTCOME_ATTAINED.value, - message=f"Learning outcome achieved: {outcome.description}", - related_entity_id=None, - metadata={ - "objective_name": objective.name, - "outcome_description": outcome.description, - }, - ) - - # Update knowledge package - if knowledge_package: # Update metadata - knowledge_package.updated_at = datetime.now(timezone.utc) - knowledge_package.updated_by = current_user_id - knowledge_package.version += 1 + current_user_id = await get_current_user_id(self.context) + share.updated_at = datetime.now(timezone.utc) + share.updated_by = current_user_id + share.version += 1 # Save the updated knowledge package - await ShareManager.set_share(self.context, knowledge_package) + await ShareManager.set_share(self.context, share) + + # Log the outcome achievement + await ShareManager.log_share_event( + context=self.context, + entry_type=LogEntryType.OUTCOME_ATTAINED.value, + message=f"Learning outcome achieved: {outcome.description}", + related_entity_id=None, + metadata={ + "objective_name": objective.name, + "outcome_description": outcome.description, + }, + ) # Notify linked conversations with a message await Notifications.notify_all( @@ -165,10 +128,7 @@ async def mark_learning_outcome_achieved( # Check if all outcomes are achieved for transfer completion # Get the knowledge package to check completion status - knowledge_package = await ShareManager.get_share(self.context) - if knowledge_package and TransferManager._is_transfer_complete( - knowledge_package - ): + if TransferManager._is_transfer_complete(share): await self.context.send_messages( NewConversationMessage( content="🎉 All learning outcomes have been achieved! The knowledge transfer has been automatically marked as complete.", @@ -176,14 +136,18 @@ async def mark_learning_outcome_achieved( ) ) - await self.context.send_messages( - NewConversationMessage( - content=f"Learning outcome '{outcome.description}' for objective '{objective.name}' has been marked as achieved.", - message_type=MessageType.notice, + await self.context.send_messages( + NewConversationMessage( + content=f"Learning outcome '{outcome.description}' for objective '{objective.name}' has been marked as achieved.", + message_type=MessageType.notice, + ) ) - ) - return f"Learning outcome '{outcome.description}' for objective '{objective.name}' marked as achieved." + return f"Learning outcome '{outcome.description}' for objective '{objective.name}' marked as achieved." + + except Exception as e: + logger.exception(f"Error marking learning outcome as achieved: {e}") + return "An error occurred while marking the learning outcome as achieved. Please try again later." async def report_transfer_completion(self) -> str: """ @@ -202,62 +166,48 @@ async def report_transfer_completion(self) -> str: Returns: A message indicating success or failure """ + try: + share = await ShareManager.get_share(self.context) + + # Check if all outcomes are achieved + achieved_outcomes, total_outcomes = LearningObjectivesManager.get_overall_completion(share) + if achieved_outcomes < total_outcomes: + remaining = total_outcomes - achieved_outcomes + return ( + f"Cannot complete knowledge transfer - {remaining} learning outcomes are still pending achievement." + ) - if self.role is not ConversationRole.TEAM: - return "Only Team members can report knowledge transfer completion." - - share = await ShareManager.get_share(self.context) - if not share: - return "No knowledge package found. Cannot complete transfer without package information." - - # Check if all outcomes are achieved - achieved_outcomes, total_outcomes = ( - LearningObjectivesManager.get_overall_completion(share) - ) - if achieved_outcomes < total_outcomes: - remaining = total_outcomes - achieved_outcomes - return f"Cannot complete knowledge transfer - {remaining} learning outcomes are still pending achievement." - - # Get current user information - participants = await self.context.get_participants() - current_user_id = None - - for participant in participants.participants: - if participant.role == "user": - current_user_id = participant.id - break - - if not current_user_id: - return "Could not identify current user." - - share.updated_at = datetime.now(timezone.utc) - share.updated_by = current_user_id - share.version += 1 - await ShareManager.set_share(self.context, share) - - # Log the milestone transition - await ShareManager.log_share_event( - context=self.context, - entry_type=LogEntryType.SHARE_COMPLETED.value, - message="Transfer marked as COMPLETED", - metadata={"milestone": "transfer_completed"}, - ) - - # Notify linked conversations with a message - await Notifications.notify_all( - self.context, - share.share_id, - "🎉 **Knowledge Transfer Complete**: Team has reported that all learning objectives have been achieved. The knowledge transfer is now complete.", - ) - await Notifications.notify_all_state_update( - self.context, share.share_id, [InspectorTab.BRIEF] - ) - - await self.context.send_messages( - NewConversationMessage( - content="🎉 **Knowledge Transfer Complete**: All learning objectives have been achieved and the knowledge transfer is now complete. The Coordinator has been notified.", - message_type=MessageType.chat, + current_user_id = await get_current_user_id(self.context) + share.updated_at = datetime.now(timezone.utc) + share.updated_by = current_user_id + share.version += 1 + await ShareManager.set_share(self.context, share) + + # Log the milestone transition + await ShareManager.log_share_event( + context=self.context, + entry_type=LogEntryType.SHARE_COMPLETED.value, + message="Transfer marked as COMPLETED", + metadata={"milestone": "transfer_completed"}, ) - ) - return "Knowledge transfer successfully marked as complete. All participants have been notified." + # Notify linked conversations with a message + await Notifications.notify_all( + self.context, + share.share_id, + "🎉 **Knowledge Transfer Complete**: Team has reported that all learning objectives have been achieved. The knowledge transfer is now complete.", + ) + await Notifications.notify_all_state_update(self.context, share.share_id, [InspectorTab.BRIEF]) + + await self.context.send_messages( + NewConversationMessage( + content="🎉 **Knowledge Transfer Complete**: All learning objectives have been achieved and the knowledge transfer is now complete. The Coordinator has been notified.", + message_type=MessageType.chat, + ) + ) + + return "Knowledge transfer successfully marked as complete. All participants have been notified." + + except Exception as e: + logger.exception(f"Error reporting transfer completion: {e}") + return "An error occurred while reporting transfer completion. Please try again later." diff --git a/assistants/knowledge-transfer-assistant/assistant/tools/share_setup.py b/assistants/knowledge-transfer-assistant/assistant/tools/share_setup.py index dd4bb261f..db0ac7290 100644 --- a/assistants/knowledge-transfer-assistant/assistant/tools/share_setup.py +++ b/assistants/knowledge-transfer-assistant/assistant/tools/share_setup.py @@ -4,9 +4,8 @@ Tools for initializing and configuring knowledge packages. """ -from datetime import datetime +from datetime import datetime, timezone -from assistant.data import ConversationRole from assistant.domain.audience_manager import AudienceManager from assistant.domain.knowledge_brief_manager import KnowledgeBriefManager from assistant.domain.share_manager import ShareManager @@ -27,23 +26,14 @@ async def update_audience(self, audience_description: str) -> str: Returns: A message indicating success or failure """ - if self.role is not ConversationRole.COORDINATOR: - return "Only Coordinator can update the audience description." - - success, message = await AudienceManager.update_audience( - context=self.context, - audience_description=audience_description, - ) - - return ( - message - if message - else ( - "Audience updated successfully." - if success - else "Failed to update audience." + try: + await AudienceManager.update_audience( + context=self.context, + audience_description=audience_description, ) - ) + return "Audience updated successfully" + except Exception as e: + return f"Failed to update audience: {str(e)}" async def set_knowledge_organized(self, is_organized: bool) -> str: """ @@ -59,28 +49,20 @@ async def set_knowledge_organized(self, is_organized: bool) -> str: Returns: A message indicating success or failure """ - if self.role is not ConversationRole.COORDINATOR: - return "Only Coordinator can mark knowledge as organized." + try: + share = await ShareManager.get_share(self.context) + share.knowledge_organized = is_organized + share.updated_at = datetime.now(timezone.utc) + await ShareManager.set_share(self.context, share) - # Get existing knowledge package - share = await ShareManager.get_share(self.context) - if not share: - return "No knowledge package found. Please create a knowledge brief first." + if is_organized: + guidance = "Knowledge is now marked as organized and ready. You can proceed to create your brief and set up learning objectives." + else: + guidance = "Knowledge is now marked as incomplete. Continue organizing your knowledge by uploading files or describing it in conversation." + return f"Knowledge organization status updated successfully. {guidance}" - # Update the knowledge organized flag - share.knowledge_organized = is_organized - share.updated_at = datetime.utcnow() - - # Save the updated package - await ShareManager.set_share(self.context, share) - - # Provide appropriate feedback - if is_organized: - guidance = "Knowledge is now marked as organized and ready. You can proceed to create your brief and set up learning objectives." - else: - guidance = "Knowledge is now marked as incomplete. Continue organizing your knowledge by uploading files or describing it in conversation." - - return f"Knowledge organization status updated successfully. {guidance}" + except Exception as e: + return f"Failed to update knowledge organization status: {str(e)}" async def update_brief(self, title: str, description: str) -> str: """ @@ -93,19 +75,15 @@ async def update_brief(self, title: str, description: str) -> str: Returns: A message indicating success or failure """ - if self.role is not ConversationRole.COORDINATOR: - return "Only Coordinator can create knowledge briefs." - - brief = await KnowledgeBriefManager.update_knowledge_brief( - context=self.context, - title=title, - description=description, - ) - - if brief: - return f"Brief '{title}' updated successfully." - else: - return "Failed to update the brief. Please try again." + try: + await KnowledgeBriefManager.update_knowledge_brief( + context=self.context, + title=title, + description=description, + ) + return "Brief updated successfully." + except Exception as e: + return f"Failed to update brief: {str(e)}" async def set_learning_intention(self, is_for_specific_outcomes: bool) -> str: """ @@ -118,24 +96,19 @@ async def set_learning_intention(self, is_for_specific_outcomes: bool) -> str: Returns: A message indicating success or failure """ - if self.role is not ConversationRole.COORDINATOR: - return "Only Coordinator can set learning intention." - - share = await ShareManager.get_share(self.context) - if not share: - return "No knowledge package found. Please create a knowledge brief first." - - # Update the intention - share.is_intended_to_accomplish_outcomes = is_for_specific_outcomes - share.updated_at = datetime.utcnow() - - # Save the updated package - await ShareManager.set_share(self.context, share) - - # Provide appropriate guidance based on the choice - if is_for_specific_outcomes: - guidance = "This knowledge package is now set for specific learning outcomes. You'll need to add learning objectives with measurable outcomes." - else: - guidance = "This knowledge package is now set for general exploration. No specific learning objectives are required." - - return f"Learning intention updated successfully. {guidance}" + try: + share = await ShareManager.get_share(self.context) + share.is_intended_to_accomplish_outcomes = is_for_specific_outcomes + share.updated_at = datetime.utcnow() + await ShareManager.set_share(self.context, share) + + # Provide appropriate guidance based on the choice + if is_for_specific_outcomes: + guidance = "This knowledge package is now set for specific learning outcomes. You'll need to add learning objectives with measurable outcomes." + else: + guidance = "This knowledge package is now set for general exploration. No specific learning objectives are required." + + return f"Learning intention updated successfully. {guidance}" + + except Exception as e: + return f"Failed to update learning intention: {str(e)}" diff --git a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/brief.py b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/brief.py index cfcf0fa35..536c27ebb 100644 --- a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/brief.py +++ b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/brief.py @@ -35,9 +35,7 @@ def __init__(self, config_provider) -> None: async def is_enabled(self, context: ConversationContext) -> bool: return True - async def get( - self, context: ConversationContext - ) -> AssistantConversationInspectorStateDataModel: + async def get(self, context: ConversationContext) -> AssistantConversationInspectorStateDataModel: """Get brief and status information for display.""" conversation_role = await ShareManager.get_conversation_role(context) @@ -46,22 +44,16 @@ async def get( share = await ShareManager.get_share(context) if not share: return AssistantConversationInspectorStateDataModel( - data={ - "content": "No active knowledge package. Start a conversation to create one." - } + data={"content": "No active knowledge package. Start a conversation to create one."} ) brief = share.brief share_info = await ShareManager.get_share(context) if conversation_role == ConversationRole.COORDINATOR: - markdown = await self._format_coordinator_brief( - share.share_id, brief, share_info, context - ) + markdown = await self._format_coordinator_brief(share.share_id, brief, share_info, context) else: - markdown = await self._format_team_brief( - share.share_id, brief, share_info, context - ) + markdown = await self._format_team_brief(share.share_id, brief, share_info, context) return AssistantConversationInspectorStateDataModel(data={"content": markdown}) @@ -103,9 +95,7 @@ async def _format_coordinator_brief( return "\n".join(lines) - async def _format_team_brief( - self, share_id: str, brief: Any, share_info: Any, context: ConversationContext - ) -> str: + async def _format_team_brief(self, share_id: str, brief: Any, share_info: Any, context: ConversationContext) -> str: """Format brief information for team members.""" lines: List[str] = [] @@ -136,9 +126,7 @@ async def _format_team_brief( else: lines.append("## Knowledge Brief") lines.append("") - lines.append( - "_The coordinator is still setting up the knowledge brief. Check back soon!_" - ) + lines.append("_The coordinator is still setting up the knowledge brief. Check back soon!_") lines.append("") return "\n".join(lines) diff --git a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/common.py b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/common.py index 1c0c2ee5a..9a7267225 100644 --- a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/common.py +++ b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/common.py @@ -51,17 +51,11 @@ def get_stage_label(package: KnowledgePackage, for_coordinator: bool = True) -> return "📋 Organizing Knowledge" elif not package.brief: return "📝 Creating Brief" - elif ( - package.is_intended_to_accomplish_outcomes - and not package.learning_objectives - ): + elif package.is_intended_to_accomplish_outcomes and not package.learning_objectives: return "📚 Adding Objectives" elif not TransferManager.is_ready_for_transfer(package): return "📋 Finalizing Setup" - elif ( - package.is_intended_to_accomplish_outcomes - and TransferManager._is_transfer_complete(package) - ): + elif package.is_intended_to_accomplish_outcomes and TransferManager._is_transfer_complete(package): return "✅ Transfer Complete" elif TransferManager.is_actively_sharing(package): return "📤 Sharing in Progress" diff --git a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/debug.py b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/debug.py index caf1ca4cd..cee1fb7b5 100644 --- a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/debug.py +++ b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/debug.py @@ -29,38 +29,28 @@ def __init__(self, config_provider) -> None: async def is_enabled(self, context: ConversationContext) -> bool: return True - async def get( - self, context: ConversationContext - ) -> AssistantConversationInspectorStateDataModel: + async def get(self, context: ConversationContext) -> AssistantConversationInspectorStateDataModel: """Get debug information for display.""" # Get share information share_id = await ShareManager.get_share_id(context) if not share_id: return AssistantConversationInspectorStateDataModel( - data={ - "content": "No active knowledge package. Start a conversation to create one." - } + data={"content": "No active knowledge package. Start a conversation to create one."} ) markdown = await self._format_debug_info(share_id, context) return AssistantConversationInspectorStateDataModel(data={"content": markdown}) - async def _format_debug_info( - self, share_id: str, context: ConversationContext - ) -> str: + async def _format_debug_info(self, share_id: str, context: ConversationContext) -> str: """Format debug information including knowledge digest.""" lines: List[str] = [] lines.append("## Debug Information") lines.append("") - lines.append( - "This panel shows internal information maintained by the assistant. This data is automatically" - ) - lines.append( - "generated and updated by the assistant and is not directly editable by users." - ) + lines.append("This panel shows internal information maintained by the assistant. This data is automatically") + lines.append("generated and updated by the assistant and is not directly editable by users.") lines.append("") # Get the knowledge digest @@ -69,20 +59,14 @@ async def _format_debug_info( if not digest: lines.append("### Knowledge Digest") lines.append("") - lines.append( - "No knowledge digest has been generated yet. The assistant will create and update this" - ) + lines.append("No knowledge digest has been generated yet. The assistant will create and update this") lines.append("automatically as the conversation develops.") lines.append("") lines.append("## Knowledge Digest") lines.append("") - lines.append( - "The knowledge digest is an internal summary of the conversation that the assistant" - ) - lines.append( - "maintains to help understand the context and key information being shared. It is" - ) + lines.append("The knowledge digest is an internal summary of the conversation that the assistant") + lines.append("maintains to help understand the context and key information being shared. It is") lines.append("automatically updated as the conversation progresses.") lines.append("") @@ -94,9 +78,7 @@ async def _format_debug_info( lines.append("```") lines.append("") else: - lines.append( - "_No knowledge digest has been generated yet. The assistant will create and update_" - ) + lines.append("_No knowledge digest has been generated yet. The assistant will create and update_") lines.append("_this automatically as the conversation develops._") lines.append("") @@ -113,12 +95,8 @@ async def _format_debug_info( lines.append("## Share Metadata") lines.append("") lines.append(f"- **Share ID:** `{share_id}`") - lines.append( - f"- **Created:** {share.created_at.strftime('%Y-%m-%d %H:%M:%S')}" - ) - lines.append( - f"- **Last Updated:** {share.updated_at.strftime('%Y-%m-%d %H:%M:%S')}" - ) + lines.append(f"- **Created:** {share.created_at.strftime('%Y-%m-%d %H:%M:%S')}") + lines.append(f"- **Last Updated:** {share.updated_at.strftime('%Y-%m-%d %H:%M:%S')}") lines.append( f"- **Team Conversations:** {len(share.team_conversations) if share.team_conversations else 0}" ) @@ -126,16 +104,10 @@ async def _format_debug_info( f"- **Learning Objectives:** {len(share.learning_objectives) if share.learning_objectives else 0}" ) lines.append(f"- **Knowledge Organized:** {share.knowledge_organized}") - lines.append( - f"- **Ready for Transfer:** {TransferManager.is_ready_for_transfer(share)}" - ) - lines.append( - f"- **Actively Sharing:** {TransferManager.is_actively_sharing(share)}" - ) + lines.append(f"- **Ready for Transfer:** {TransferManager.is_ready_for_transfer(share)}") + lines.append(f"- **Actively Sharing:** {TransferManager.is_actively_sharing(share)}") if share.coordinator_conversation_id: - lines.append( - f"- **Conversation ID:** `{share.coordinator_conversation_id}`" - ) + lines.append(f"- **Conversation ID:** `{share.coordinator_conversation_id}`") lines.append("") except Exception as e: diff --git a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/learning.py b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/learning.py index 8639737d6..bbaf16b88 100644 --- a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/learning.py +++ b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/learning.py @@ -30,9 +30,7 @@ def __init__(self, config_provider) -> None: async def is_enabled(self, context: ConversationContext) -> bool: return True - async def get( - self, context: ConversationContext - ) -> AssistantConversationInspectorStateDataModel: + async def get(self, context: ConversationContext) -> AssistantConversationInspectorStateDataModel: """Get learning objectives and progress information.""" conversation_role = await ShareManager.get_conversation_role(context) @@ -41,9 +39,7 @@ async def get( share_id = await ShareManager.get_share_id(context) if not share_id: return AssistantConversationInspectorStateDataModel( - data={ - "content": "No active knowledge package. Start a conversation to create one." - } + data={"content": "No active knowledge package. Start a conversation to create one."} ) share = await ShareManager.get_share(context) @@ -55,9 +51,7 @@ async def get( return AssistantConversationInspectorStateDataModel(data={"content": markdown}) - async def _format_coordinator_objectives( - self, share: Any, context: ConversationContext - ) -> str: + async def _format_coordinator_objectives(self, share: Any, context: ConversationContext) -> str: """Format learning objectives for coordinator.""" lines: List[str] = [] @@ -75,22 +69,12 @@ async def _format_coordinator_objectives( lines.append("") # Overall progress summary - total_outcomes = sum( - len(obj.learning_outcomes) - for obj in share.learning_objectives - if obj.learning_outcomes - ) + total_outcomes = sum(len(obj.learning_outcomes) for obj in share.learning_objectives if obj.learning_outcomes) if total_outcomes > 0 and share.team_conversations: for conv_id, team_conv in share.team_conversations.items(): - achieved, total = ( - LearningObjectivesManager.get_completion_for_conversation( - share, conv_id - ) - ) + achieved, total = LearningObjectivesManager.get_completion_for_conversation(share, conv_id) progress_pct = int((achieved / total * 100)) if total > 0 else 0 - lines.append( - f"- **{team_conv.redeemer_name}**: {achieved}/{total} outcomes ({progress_pct}%)" - ) + lines.append(f"- **{team_conv.redeemer_name}**: {achieved}/{total} outcomes ({progress_pct}%)") lines.append("") # Detailed objectives @@ -105,9 +89,7 @@ async def _format_coordinator_objectives( for criterion in objective.learning_outcomes: # Check if any team conversation has achieved this outcome achieved_by_any = any( - LearningObjectivesManager.is_outcome_achieved_by_conversation( - share, criterion.id, conv_id - ) + LearningObjectivesManager.is_outcome_achieved_by_conversation(share, criterion.id, conv_id) for conv_id in share.team_conversations.keys() ) status_emoji = "✅" if achieved_by_any else "⬜" @@ -117,25 +99,19 @@ async def _format_coordinator_objectives( total_team_count = len(share.team_conversations) for conv_id in share.team_conversations.keys(): - if LearningObjectivesManager.is_outcome_achieved_by_conversation( - share, criterion.id, conv_id - ): + if LearningObjectivesManager.is_outcome_achieved_by_conversation(share, criterion.id, conv_id): achieved_count += 1 achievement_info = "" if total_team_count > 0: achievement_info = f" ({achieved_count}/{total_team_count})" - lines.append( - f"- {status_emoji} {criterion.description}{achievement_info}" - ) + lines.append(f"- {status_emoji} {criterion.description}{achievement_info}") lines.append("") return "\n".join(lines) - async def _format_team_objectives( - self, share: Any, context: ConversationContext - ) -> str: + async def _format_team_objectives(self, share: Any, context: ConversationContext) -> str: """Format learning objectives for team members.""" lines: List[str] = [] @@ -154,17 +130,11 @@ async def _format_team_objectives( # Show my personal progress conversation_id = str(context.id) - achieved_outcomes, total_outcomes = ( - LearningObjectivesManager.get_completion_for_conversation( - share, conversation_id - ) - ) - progress_pct = ( - int((achieved_outcomes / total_outcomes * 100)) if total_outcomes > 0 else 0 - ) - lines.append( - f"**My Progress:** {achieved_outcomes}/{total_outcomes} outcomes achieved ({progress_pct}%)" + achieved_outcomes, total_outcomes = LearningObjectivesManager.get_completion_for_conversation( + share, conversation_id ) + progress_pct = int((achieved_outcomes / total_outcomes * 100)) if total_outcomes > 0 else 0 + lines.append(f"**My Progress:** {achieved_outcomes}/{total_outcomes} outcomes achieved ({progress_pct}%)") lines.append("") for objective in share.learning_objectives: @@ -176,32 +146,23 @@ async def _format_team_objectives( lines.append("#### Learning Outcomes") for criterion in objective.learning_outcomes: # Check if I've achieved this outcome - achieved_by_me = ( - LearningObjectivesManager.is_outcome_achieved_by_conversation( - share, criterion.id, conversation_id - ) + achieved_by_me = LearningObjectivesManager.is_outcome_achieved_by_conversation( + share, criterion.id, conversation_id ) status_emoji = "✅" if achieved_by_me else "⬜" completion_info = "" if achieved_by_me: # Find my achievement record - my_achievements = ( - LearningObjectivesManager.get_achievements_for_conversation( - share, conversation_id - ) + my_achievements = LearningObjectivesManager.get_achievements_for_conversation( + share, conversation_id ) for achievement in my_achievements: - if ( - achievement.outcome_id == criterion.id - and achievement.achieved - ): + if achievement.outcome_id == criterion.id and achievement.achieved: completion_info = f" (achieved on {achievement.achieved_at.strftime('%Y-%m-%d')})" break - lines.append( - f"- {status_emoji} {criterion.description}{completion_info}" - ) + lines.append(f"- {status_emoji} {criterion.description}{completion_info}") lines.append("") return "\n".join(lines) diff --git a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/sharing.py b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/sharing.py index b32544c8b..b2140a16e 100644 --- a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/sharing.py +++ b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/sharing.py @@ -36,9 +36,7 @@ def __init__(self, config_provider) -> None: async def is_enabled(self, context: ConversationContext) -> bool: return True - async def get( - self, context: ConversationContext - ) -> AssistantConversationInspectorStateDataModel: + async def get(self, context: ConversationContext) -> AssistantConversationInspectorStateDataModel: """Get information requests for display.""" conversation_role = await ShareManager.get_conversation_role(context) @@ -47,9 +45,7 @@ async def get( share_id = await ShareManager.get_share_id(context) if not share_id: return AssistantConversationInspectorStateDataModel( - data={ - "content": "No active knowledge package. Start a conversation to create one." - } + data={"content": "No active knowledge package. Start a conversation to create one."} ) requests = await InformationRequestManager.get_information_requests(context) @@ -61,9 +57,7 @@ async def get( return AssistantConversationInspectorStateDataModel(data={"content": markdown}) - async def _format_coordinator_requests( - self, requests: List[Any], context: ConversationContext - ) -> str: + async def _format_coordinator_requests(self, requests: List[Any], context: ConversationContext) -> str: """Format sharing information and requests for coordinator.""" lines: List[str] = [] @@ -77,18 +71,12 @@ async def _format_coordinator_requests( lines.append("**Share this link with your team members:**") lines.append(f"[Knowledge Transfer link]({share_url})") lines.append("") - lines.append( - "The link never expires and can be used by multiple team members." - ) + lines.append("The link never expires and can be used by multiple team members.") lines.append("") # Filter requests by status - pending_requests = [ - req for req in requests if req.status != RequestStatus.RESOLVED - ] - resolved_requests = [ - req for req in requests if req.status == RequestStatus.RESOLVED - ] + pending_requests = [req for req in requests if req.status != RequestStatus.RESOLVED] + resolved_requests = [req for req in requests if req.status == RequestStatus.RESOLVED] lines.append("## Information Requests") lines.append("") @@ -119,9 +107,7 @@ async def _format_coordinator_requests( if not pending_requests and not resolved_requests: lines.append("No information requests yet.") lines.append("") - lines.append( - "_Team members can ask questions and create information requests that will appear here._" - ) + lines.append("_Team members can ask questions and create information requests that will appear here._") # Team summary if share and share.team_conversations: @@ -130,28 +116,16 @@ async def _format_coordinator_requests( lines.append("") for conv_id, team_conv in share.team_conversations.items(): - achieved, total = ( - LearningObjectivesManager.get_completion_for_conversation( - share, conv_id - ) - ) + achieved, total = LearningObjectivesManager.get_completion_for_conversation(share, conv_id) progress_pct = int((achieved / total * 100)) if total > 0 else 0 - lines.append( - f"- **{team_conv.redeemer_name}**: {achieved}/{total} outcomes ({progress_pct}%)" - ) - lines.append( - f" Joined: {team_conv.joined_at.strftime('%Y-%m-%d %H:%M')}" - ) - lines.append( - f" Last active: {team_conv.last_active_at.strftime('%Y-%m-%d %H:%M')}" - ) + lines.append(f"- **{team_conv.redeemer_name}**: {achieved}/{total} outcomes ({progress_pct}%)") + lines.append(f" Joined: {team_conv.joined_at.strftime('%Y-%m-%d %H:%M')}") + lines.append(f" Last active: {team_conv.last_active_at.strftime('%Y-%m-%d %H:%M')}") lines.append("") return "\n".join(lines) - async def _format_team_requests( - self, requests: List[Any], context: ConversationContext - ) -> str: + async def _format_team_requests(self, requests: List[Any], context: ConversationContext) -> str: """Format sharing information and requests for team members.""" lines: List[str] = [] diff --git a/assistants/knowledge-transfer-assistant/assistant/utils.py b/assistants/knowledge-transfer-assistant/assistant/utils.py index fa86bdf23..4c4447e45 100644 --- a/assistants/knowledge-transfer-assistant/assistant/utils.py +++ b/assistants/knowledge-transfer-assistant/assistant/utils.py @@ -10,7 +10,7 @@ from semantic_workbench_assistant.assistant_app import ConversationContext -from .logging import logger +from assistant.errors import NoUserException DEFAULT_TEMPLATE_ID = "default" @@ -37,16 +37,7 @@ def load_text_include(filename) -> str: async def get_current_user( context: ConversationContext, -) -> Tuple[Optional[str], Optional[str]]: - """ - Extract the current user ID and name from the conversation context. - - Args: - context: The conversation context to extract user information from - - Returns: - A tuple containing (user_id, user_name), both may be None if no user is found - """ +) -> Tuple[str, Optional[str]]: participants = await context.get_participants() user_id = None user_name = None @@ -57,39 +48,19 @@ async def get_current_user( user_name = participant.name break - return user_id, user_name - + if not user_id: + raise NoUserException -async def get_current_user_id(context: ConversationContext) -> Optional[str]: - """ - Extract just the current user ID from the conversation context. + return user_id, user_name - Args: - context: The conversation context to extract user information from - Returns: - The user ID, or None if no user is found - """ +async def get_current_user_id(context: ConversationContext) -> str: user_id, _ = await get_current_user(context) return user_id -async def require_current_user( - context: ConversationContext, operation_name: str -) -> Optional[str]: - """ - Extract the current user ID and log an error if none is found. - - Args: - context: The conversation context to extract user information from - operation_name: Name of the operation requiring a user, for error logging - - Returns: - The user ID, or None if no user is found (after logging an error) - """ +async def require_current_user(context: ConversationContext, operation_name: str) -> str: user_id = await get_current_user_id(context) - if not user_id: - logger.error(f"Cannot {operation_name}: no user found in conversation") - + raise NoUserException return user_id diff --git a/assistants/knowledge-transfer-assistant/tests/test_artifact_loading.py b/assistants/knowledge-transfer-assistant/tests/test_artifact_loading.py index 27dd271d8..a57887c51 100644 --- a/assistants/knowledge-transfer-assistant/tests/test_artifact_loading.py +++ b/assistants/knowledge-transfer-assistant/tests/test_artifact_loading.py @@ -31,9 +31,7 @@ class TestShareStorage(unittest.IsolatedAsyncioTestCase): async def asyncSetUp(self): # Create a test storage path - self.test_dir = ( - pathlib.Path(__file__).parent.parent / ".data" / "test_project_storage" - ) + self.test_dir = pathlib.Path(__file__).parent.parent / ".data" / "test_project_storage" self.test_dir.mkdir(exist_ok=True, parents=True) # Mock settings to use our test directory @@ -76,9 +74,7 @@ def mock_storage_directory_for_context(context, *args, **kwargs): async def mock_get_share_id(context): return self.share_id - patch2 = unittest.mock.patch.object( - ShareManager, "get_share_id", side_effect=mock_get_share_id - ) + patch2 = unittest.mock.patch.object(ShareManager, "get_share_id", side_effect=mock_get_share_id) self.mock_get_project = patch2.start() self.patches.append(patch2) @@ -141,9 +137,7 @@ def create_test_brief(self): async def test_get_project_brief(self) -> None: """Test that get_project_brief correctly loads the brief from storage""" # Mock the KnowledgeTransferManager to use our test context - with unittest.mock.patch.object( - ShareManager, "get_share_id", return_value=self.share_id - ): + with unittest.mock.patch.object(ShareManager, "get_share_id", return_value=self.share_id): # Using Any here to satisfy type checker with our mock context: Any = self.context @@ -158,9 +152,7 @@ async def test_get_project_brief(self) -> None: # Verify the project goals were loaded correctly self.assertIsNotNone(project, "Should load the project") if project: # Type checking guard - self.assertEqual( - len(project.learning_objectives), 1, "Should have one goal" - ) + self.assertEqual(len(project.learning_objectives), 1, "Should have one goal") self.assertEqual(project.learning_objectives[0].name, "Test Goal") async def test_direct_storage_access(self) -> None: diff --git a/assistants/knowledge-transfer-assistant/tests/test_inspector.py b/assistants/knowledge-transfer-assistant/tests/test_inspector.py index 0f9434e7b..1c5cfd775 100644 --- a/assistants/knowledge-transfer-assistant/tests/test_inspector.py +++ b/assistants/knowledge-transfer-assistant/tests/test_inspector.py @@ -45,9 +45,7 @@ async def test_inspector(): logger.info(f"Testing {inspector_id} inspector...") # Create state event - state_event = AssistantStateEvent( - state_id=inspector_id, event="focus", state=None - ) + state_event = AssistantStateEvent(state_id=inspector_id, event="focus", state=None) # Send event logger.info("Sending state event...") diff --git a/assistants/knowledge-transfer-assistant/tests/test_share_manager.py b/assistants/knowledge-transfer-assistant/tests/test_share_manager.py index 99fcc2469..cbe4e548a 100644 --- a/assistants/knowledge-transfer-assistant/tests/test_share_manager.py +++ b/assistants/knowledge-transfer-assistant/tests/test_share_manager.py @@ -136,9 +136,7 @@ def mock_write_share_info(proj_id, package): nonlocal write_project_info_called assert proj_id == project_id # Verify package was updated - assert ( - package.achieved_outcomes == 0 - ) # Completed criterion was in the deleted goal + assert package.achieved_outcomes == 0 # Completed criterion was in the deleted goal assert package.total_outcomes == 0 # All criteria were in the deleted goal # Note: completion_percentage removed from model assert package.version == 2 # Incremented @@ -189,9 +187,7 @@ async def mock_refresh_all_project_uis(*args, **kwargs): ( success, goal_name_result, - ) = await LearningObjectivesManager.delete_learning_objective( - context, objective_index - ) + ) = await LearningObjectivesManager.delete_learning_objective(context, objective_index) # Verify the result assert success is True @@ -206,9 +202,7 @@ async def mock_refresh_all_project_uis(*args, **kwargs): # DISABLED: delete_project_goal functionality has been removed from the codebase # @pytest.mark.asyncio - async def disabled_test_delete_project_goal_invalid_index( - self, context, monkeypatch - ): + async def disabled_test_delete_project_goal_invalid_index(self, context, monkeypatch): """Test deleting a goal with an invalid index.""" # Setup project_id = "test-project-id" @@ -269,9 +263,7 @@ def mock_read_project(proj_id): ( success, error_message, - ) = await LearningObjectivesManager.delete_learning_objective( - context, objective_index - ) + ) = await LearningObjectivesManager.delete_learning_objective(context, objective_index) # Verify the result indicates failure with appropriate error message assert success is False @@ -296,9 +288,7 @@ async def mock_get_project_id(*args, **kwargs): ( success, error_message, - ) = await LearningObjectivesManager.delete_learning_objective( - context, "test-objective-id" - ) + ) = await LearningObjectivesManager.delete_learning_objective(context, "test-objective-id") # Verify the result indicates failure with appropriate error message assert success is False diff --git a/assistants/knowledge-transfer-assistant/tests/test_share_storage.py b/assistants/knowledge-transfer-assistant/tests/test_share_storage.py index 80fe0aef9..1dd564994 100644 --- a/assistants/knowledge-transfer-assistant/tests/test_share_storage.py +++ b/assistants/knowledge-transfer-assistant/tests/test_share_storage.py @@ -40,9 +40,7 @@ class TestShareStorage(unittest.IsolatedAsyncioTestCase): async def asyncSetUp(self): """Set up test environment.""" # Create a test directory - self.test_dir = ( - pathlib.Path(__file__).parent.parent / ".data" / "test_project_storage" - ) + self.test_dir = pathlib.Path(__file__).parent.parent / ".data" / "test_project_storage" self.test_dir.mkdir(parents=True, exist_ok=True) # Mock settings to use our test directory @@ -82,9 +80,7 @@ async def asyncSetUp(self): # Mock get_participants with the correct structure participants_mock = unittest.mock.MagicMock() participants_mock.participants = [] - self.context.get_participants = unittest.mock.AsyncMock( - return_value=participants_mock - ) + self.context.get_participants = unittest.mock.AsyncMock(return_value=participants_mock) # Patch storage_directory_for_context def mock_storage_directory_for_context(context, *args, **kwargs): @@ -224,22 +220,16 @@ async def test_write_project_log(self): self.assertIsNotNone(log, "Should load the log") if log: # Type checking guard self.assertEqual(len(log.entries), 1) - self.assertEqual( - log.entries[0].entry_type, LogEntryType.SHARE_INFORMATION_UPDATE - ) + self.assertEqual(log.entries[0].entry_type, LogEntryType.SHARE_INFORMATION_UPDATE) self.assertEqual(log.entries[0].message, "Test log entry") async def test_project_directory_structure(self): """Test the project directory structure.""" # Verify project directory exists - self.assertTrue( - self.project_dir.exists(), "KnowledgePackage directory should exist" - ) + self.assertTrue(self.project_dir.exists(), "KnowledgePackage directory should exist") # Verify Coordinator directory exists - self.assertTrue( - self.coordinator_dir.exists(), "Coordinator directory should exist" - ) + self.assertTrue(self.coordinator_dir.exists(), "Coordinator directory should exist") # Verify team directory exists self.assertTrue(self.team_dir.exists(), "Team directory should exist") @@ -306,9 +296,7 @@ async def test_append_coordinator_message(self): storage = ShareStorage.read_coordinator_conversation(self.share_id) # Verify messages were added - self.assertIsNotNone( - storage, "Should create and load the coordinator conversation" - ) + self.assertIsNotNone(storage, "Should create and load the coordinator conversation") if storage: self.assertEqual(len(storage.messages), 2) self.assertEqual(storage.messages[0].content, "First message") @@ -419,15 +407,11 @@ async def test_knowledge_package_info(self): updated_package = ShareStorage.read_share(self.share_id) # Verify updates were saved - self.assertIsNotNone( - updated_package, "Should load updated knowledge package" - ) + self.assertIsNotNone(updated_package, "Should load updated knowledge package") if updated_package: self.assertEqual(updated_package.transfer_notes, "Test status message") # Note: completion_percentage removed from model - self.assertEqual( - updated_package.next_learning_actions, ["Action 1", "Action 2"] - ) + self.assertEqual(updated_package.next_learning_actions, ["Action 1", "Action 2"]) async def test_conversation_tracking_in_json(self): """Test that conversations are tracked in JSON instead of file system.""" @@ -440,26 +424,18 @@ async def test_conversation_tracking_in_json(self): self.assertIsInstance(package.team_conversations, dict) # Verify helper methods work - linked_conversations = await ShareManager.get_linked_conversations( - self.context - ) + linked_conversations = await ShareManager.get_linked_conversations(self.context) self.assertIsInstance(linked_conversations, list) async def test_conversation_association(self): """Test conversation role setting.""" # Mock ShareManager.set_conversation_role - with unittest.mock.patch( - "assistant.domain.share_manager.write_model" - ) as mock_write_model: + with unittest.mock.patch("assistant.domain.share_manager.write_model") as mock_write_model: # Mock conversation role path - conversation_role_file = ( - ShareStorageManager.get_conversation_role_file_path(self.context) - ) + conversation_role_file = ShareStorageManager.get_conversation_role_file_path(self.context) # Call set_conversation_role - await ShareManager.set_conversation_role( - self.context, self.share_id, ConversationRole.COORDINATOR - ) + await ShareManager.set_conversation_role(self.context, self.share_id, ConversationRole.COORDINATOR) # Verify write_model was called mock_write_model.assert_called_once() @@ -500,9 +476,7 @@ async def test_log_project_event(self): for entry in read_log.entries: if entry.message == "Test direct log entry": found_entry = True - self.assertEqual( - entry.entry_type, LogEntryType.SHARE_INFORMATION_UPDATE - ) + self.assertEqual(entry.entry_type, LogEntryType.SHARE_INFORMATION_UPDATE) self.assertEqual(entry.user_id, self.user_id) self.assertEqual(entry.user_name, "Test User") self.assertEqual(entry.related_entity_id, "test-entity-id") diff --git a/assistants/knowledge-transfer-assistant/tests/test_share_tools.py b/assistants/knowledge-transfer-assistant/tests/test_share_tools.py index feea3311c..4da3d92b1 100644 --- a/assistants/knowledge-transfer-assistant/tests/test_share_tools.py +++ b/assistants/knowledge-transfer-assistant/tests/test_share_tools.py @@ -41,20 +41,11 @@ def test_initialization(self, context): # Verify Coordinator-specific functions are registered assert "update_brief" in coordinator_tools.tool_functions.function_map - assert ( - "resolve_information_request" - in coordinator_tools.tool_functions.function_map - ) + assert "resolve_information_request" in coordinator_tools.tool_functions.function_map # Verify Team-specific functions are NOT registered - assert ( - "create_information_request" - not in coordinator_tools.tool_functions.function_map - ) - assert ( - "delete_information_request" - not in coordinator_tools.tool_functions.function_map - ) + assert "create_information_request" not in coordinator_tools.tool_functions.function_map + assert "delete_information_request" not in coordinator_tools.tool_functions.function_map # Test Team role team_tools = ShareTools(context, ConversationRole.TEAM) @@ -67,15 +58,10 @@ def test_initialization(self, context): # Verify Coordinator-specific functions are NOT registered assert "update_brief" not in team_tools.tool_functions.function_map - assert ( - "resolve_information_request" not in team_tools.tool_functions.function_map - ) + assert "resolve_information_request" not in team_tools.tool_functions.function_map # detect_information_request_needs is not exposed as a tool function anymore - assert ( - "detect_information_request_needs" - not in team_tools.tool_functions.function_map - ) + assert "detect_information_request_needs" not in team_tools.tool_functions.function_map @pytest.mark.asyncio async def test_project_tools_with_config(self, context, monkeypatch): @@ -133,15 +119,11 @@ async def check_tools_with_config(context, role): return tools # Get the tools using our function that checks track_progress - project_tools = await check_tools_with_config( - context, ConversationRole.COORDINATOR - ) + project_tools = await check_tools_with_config(context, ConversationRole.COORDINATOR) # Verify basic tools are still available regardless of track_progress setting assert "update_brief" in project_tools.tool_functions.function_map - assert ( - "resolve_information_request" in project_tools.tool_functions.function_map - ) + assert "resolve_information_request" in project_tools.tool_functions.function_map # For team tools team_tools = await check_tools_with_config(context, ConversationRole.TEAM) @@ -162,9 +144,7 @@ async def test_detect_information_request_needs(self, context, monkeypatch): # Setup mock config to be returned from assistant_config.get mock_config = MagicMock() mock_config.track_progress = True - mock_config.service_config = ( - None # Will cause the method to return early with error info - ) + mock_config.service_config = None # Will cause the method to return early with error info async def mock_get_config(*args, **kwargs): return mock_config @@ -172,9 +152,7 @@ async def mock_get_config(*args, **kwargs): # Patch assistant_config.get mock_assistant_config = MagicMock() mock_assistant_config.get = AsyncMock(side_effect=mock_get_config) - monkeypatch.setattr( - "assistant.agentic.analysis.assistant_config", mock_assistant_config - ) + monkeypatch.setattr("assistant.agentic.analysis.assistant_config", mock_assistant_config) # Create a mock message for the message history mock_msg = MagicMock() @@ -270,9 +248,7 @@ async def mock_delete_project_goal(*args, **kwargs): # assert f"Goal '{goal_name}' has been successfully deleted from the project." in result # Verify that context.send_messages was called with appropriate message - expected_message_content = ( - f"Goal '{goal_name}' has been successfully deleted from the project." - ) + expected_message_content = f"Goal '{goal_name}' has been successfully deleted from the project." context.send_messages.assert_called_once() # Get the first positional argument passed to send_messages call_args = context.send_messages.call_args[0][0] diff --git a/assistants/knowledge-transfer-assistant/tests/test_team_mode.py b/assistants/knowledge-transfer-assistant/tests/test_team_mode.py index 3ee7704b9..0111cafbb 100644 --- a/assistants/knowledge-transfer-assistant/tests/test_team_mode.py +++ b/assistants/knowledge-transfer-assistant/tests/test_team_mode.py @@ -89,9 +89,7 @@ def __init__( class InformationRequest(BaseArtifact): - def __init__( - self, title=None, description=None, priority=None, status=None, **kwargs - ): + def __init__(self, title=None, description=None, priority=None, status=None, **kwargs): super().__init__(artifact_type=ArtifactType.INFORMATION_REQUEST, **kwargs) self.title = title or "Test Request" self.description = description or "Test Description" @@ -131,9 +129,7 @@ def __init__(self, id=None, description=None, completed=False, completed_by=None class LearningObjective: - def __init__( - self, id=None, name=None, description=None, priority=1, success_criteria=None - ): + def __init__(self, id=None, name=None, description=None, priority=1, success_criteria=None): self.id = id or "test-goal-id" self.name = name or "Test Goal" self.description = description or "Test Goal Description" @@ -155,9 +151,7 @@ def __init__(self, context): self.context = context self.log_action = AsyncMock() - async def create_information_request( - self, title, description, priority=RequestPriority.MEDIUM - ): + async def create_information_request(self, title, description, priority=RequestPriority.MEDIUM): # Mock implementation request = InformationRequest( title=title, @@ -200,9 +194,7 @@ async def update_project_info(self, progress_percentage, status_message=None): dashboard.transfer_notes = status_message # Call mocked log_action for state change and progress update - await self.log_action( - LogEntryType.MILESTONE_PASSED, "KnowledgePackage is now in progress" - ) + await self.log_action(LogEntryType.MILESTONE_PASSED, "KnowledgePackage is now in progress") await self.log_action( LogEntryType.STATUS_CHANGED, @@ -270,9 +262,7 @@ async def report_project_completion(self, completion_summary): dashboard.transfer_notes = completion_summary # Call mocked log_action - await self.log_action( - LogEntryType.PROJECT_COMPLETED, "KnowledgePackage marked as completed" - ) + await self.log_action(LogEntryType.PROJECT_COMPLETED, "KnowledgePackage marked as completed") # Send notification await self.context.send_messages( @@ -355,9 +345,7 @@ async def test_create_information_request(self, team_handler, mock_context): async def test_update_project_info(self, team_handler, mock_context): """Test updating the project information.""" # Call the method - success, message, dashboard = await team_handler.update_project_info( - 50, "Making progress in the team" - ) + success, message, dashboard = await team_handler.update_project_info(50, "Making progress in the team") # Assertions assert success is True @@ -381,9 +369,7 @@ async def test_mark_criterion_completed(self, team_handler, mock_context): success, message, updated_dashboard, - ) = await team_handler.mark_criterion_completed( - "test-goal-id", "test-criterion-id" - ) + ) = await team_handler.mark_criterion_completed("test-goal-id", "test-criterion-id") # Assertions assert success is True @@ -414,8 +400,7 @@ async def test_report_project_completion(self, team_handler, mock_context): assert dashboard.state == ProjectState.COMPLETED # Note: completion_percentage removed from model assert ( - dashboard.transfer_notes - == "KnowledgePackage has been successfully completed with all objectives achieved." + dashboard.transfer_notes == "KnowledgePackage has been successfully completed with all objectives achieved." ) # Verify that a notification was sent From e448fdb1c9a4500cd5569244a478b5b8c092045a Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Tue, 29 Jul 2025 13:15:56 -0700 Subject: [PATCH 11/21] Remove "package" naming from KTA. --- .../assistant/agentic/coordinator_support.py | 40 +++++++++---------- .../assistant/domain/audience_manager.py | 2 +- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/assistants/knowledge-transfer-assistant/assistant/agentic/coordinator_support.py b/assistants/knowledge-transfer-assistant/assistant/agentic/coordinator_support.py index d59a43063..5e06a9f5d 100644 --- a/assistants/knowledge-transfer-assistant/assistant/agentic/coordinator_support.py +++ b/assistants/knowledge-transfer-assistant/assistant/agentic/coordinator_support.py @@ -24,7 +24,7 @@ async def get_coordinator_next_action_suggestion( Generate next action suggestions for the coordinator based on the knowledge transfer state. This output is passed to the assistant and helps guide the conversation toward completing or improving - the knowledge package in a helpful, structured way. + the knowledge share in a helpful, structured way. Returns: A user-facing suggestion string, or None if no suggestion is needed. @@ -35,12 +35,12 @@ async def get_coordinator_next_action_suggestion( logger.warning("No share ID found for this conversation") return None - package = await ShareManager.get_share(context) - if not package: + share = await ShareManager.get_share(context) + if not share: return None - brief = package.brief - requests = package.requests + brief = share.brief + requests = share.requests active_requests = [r for r in requests if r.status == RequestStatus.NEW] # 1. Unresolved requests come first @@ -49,45 +49,45 @@ async def get_coordinator_next_action_suggestion( return f'There are {len(active_requests)} unanswered questions from team members. One of them is: "{request.title}" Let\'s work on answering it.' # 2. Audience not yet defined - if not package.audience: + if not share.audience: return "Let's start by defining who your audience is. Who is this knowledge for, and what's their background?" # 3. Knowledge not yet organized - if not package.knowledge_organized: + if not share.knowledge_organized: return "Next, let's organize your knowledge. Upload any relevant files or describe the knowledge you want to transfer. When you're ready, I can mark the knowledge as organized." # 4. Brief not yet written if not brief: - return "Your package needs a short introduction that will orient your team. Let's write a knowledge brief next. The knowledge brief helps your team understand the purpose of this knowledge transfer and will be visible to all team members in their side panel." + return "Your knowledge share-out needs a short introduction that will orient your team. Let's write a knowledge brief next. The knowledge brief helps your team understand the purpose of this knowledge transfer and will be visible to all team members in their side panel." # 5. If intended to have outcomes but none defined yet - if package.is_intended_to_accomplish_outcomes and not package.learning_objectives: + if share.is_intended_to_accomplish_outcomes and not share.learning_objectives: return ( "Would you like your team to achieve any specific outcomes? If so, let's define some learning objectives. " - "If not, you can mark this package as 'exploratory' instead." + "If not, you can mark this share-out as 'exploratory' instead." ) # 6. Objectives exist, but missing outcomes - if package.is_intended_to_accomplish_outcomes: - incomplete_objectives = [obj for obj in package.learning_objectives if not obj.learning_outcomes] + if share.is_intended_to_accomplish_outcomes: + incomplete_objectives = [obj for obj in share.learning_objectives if not obj.learning_outcomes] if incomplete_objectives: name = incomplete_objectives[0].name return f"The learning objective '{name}' doesn't have any outcomes yet. Let's define what your team should accomplish to meet it." # 7. Ready for transfer but not yet shared - if TransferManager.is_ready_for_transfer(package) and not TransferManager.is_actively_sharing(package): - return "Your knowledge package is ready to share. Would you like to create a message and generate the invitation link?" + if TransferManager.is_ready_for_transfer(share) and not TransferManager.is_actively_sharing(share): + return "Your knowledge is ready to share. Would you like to create a message and generate the invitation link?" # 8. Actively sharing - monitor and support ongoing transfer - if TransferManager.is_actively_sharing(package): - if package.is_intended_to_accomplish_outcomes and not TransferManager._is_transfer_complete(package): - team_count = len(package.team_conversations) - return f"Great! Your knowledge is being shared with {team_count} team member{'s' if team_count != 1 else ''}. You can continue improving the package or respond to information requests as they come in." + if TransferManager.is_actively_sharing(share): + if share.is_intended_to_accomplish_outcomes and not TransferManager._is_transfer_complete(share): + team_count = len(share.team_conversations) + return f"Great! Your knowledge is being shared with {team_count} team member{'s' if team_count != 1 else ''}. You can continue improving the knowledge share or respond to information requests as they come in." else: - return "Your knowledge transfer is in progress. You can continue improving the package or respond to information requests as they come in." + return "Your knowledge transfer is in progress. You can continue improving the knowledge share or respond to information requests as they come in." # 9. Default: General support - return "Your package is available. You can continue improving it or respond to new information requests as they come in." + return "Your knowledge share is available. You can continue improving it or respond to new information requests as they come in." except Exception as e: logger.exception(f"Error generating next action suggestion: {e}") diff --git a/assistants/knowledge-transfer-assistant/assistant/domain/audience_manager.py b/assistants/knowledge-transfer-assistant/assistant/domain/audience_manager.py index 17d77da51..67533e881 100644 --- a/assistants/knowledge-transfer-assistant/assistant/domain/audience_manager.py +++ b/assistants/knowledge-transfer-assistant/assistant/domain/audience_manager.py @@ -21,7 +21,7 @@ async def update_audience( audience_description: str, ) -> None: """ - Update the target audience description for a knowledge package. + Update the target audience description for a knowledge share. """ share = await ShareManager.get_share(context) share.audience = audience_description.strip() From 021282eaf442ccef93b3cd14bcd3ba786b782de5 Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Wed, 30 Jul 2025 10:44:35 -0700 Subject: [PATCH 12/21] Aggressive linting. --- .../.vscode/settings.json | 3 +- .../assistant/agentic/analysis.py | 10 +- .../assistant/agentic/coordinator_support.py | 53 ++++-- .../assistant/agentic/team_welcome.py | 11 +- .../assistant/assistant.py | 10 +- .../assistant/common.py | 6 +- .../assistant/config.py | 26 +-- .../assistant/conversation_clients.py | 6 +- .../assistant/data.py | 60 +++--- .../assistant/domain/__init__.py | 4 +- .../assistant/domain/audience_manager.py | 4 +- .../domain/information_request_manager.py | 13 +- .../domain/knowledge_brief_manager.py | 6 +- .../domain/knowledge_digest_manager.py | 12 +- .../domain/learning_objectives_manager.py | 43 ++--- .../assistant/domain/share_manager.py | 29 ++- .../assistant/files.py | 13 +- .../assistant/logging.py | 15 +- .../assistant/notifications.py | 12 +- .../assistant/respond.py | 46 +++-- .../assistant/storage.py | 22 +-- .../assistant/string_utils.py | 4 +- .../assistant/text_includes/to_do.md | 175 ++++++++++++++++++ .../assistant/tools/__init__.py | 3 +- .../assistant/tools/information_requests.py | 16 +- .../assistant/tools/learning_objectives.py | 8 +- .../assistant/tools/learning_outcomes.py | 6 +- .../assistant/tools/progress_tracking.py | 31 ++-- .../assistant/tools/share_setup.py | 42 ++--- .../assistant/tools/system_reminders.py | 1 + .../assistant/tools/todo.py | 61 ++++++ .../assistant/ui_tabs/__init__.py | 2 +- .../assistant/ui_tabs/brief.py | 8 +- .../assistant/ui_tabs/debug.py | 8 +- .../assistant/ui_tabs/learning.py | 20 +- .../assistant/ui_tabs/sharing.py | 14 +- .../assistant/utils.py | 3 +- ...nowledge-transfer-assistant.code-workspace | 20 +- .../pyproject.toml | 18 +- .../tests/test_artifact_loading.py | 21 +-- .../tests/test_inspector.py | 3 +- .../tests/test_share_manager.py | 3 +- .../tests/test_share_storage.py | 132 +++++++------ .../tests/test_share_tools.py | 3 +- ruff.toml | 18 ++ 45 files changed, 633 insertions(+), 391 deletions(-) create mode 100644 assistants/knowledge-transfer-assistant/assistant/text_includes/to_do.md create mode 100644 assistants/knowledge-transfer-assistant/assistant/tools/system_reminders.py create mode 100644 assistants/knowledge-transfer-assistant/assistant/tools/todo.py diff --git a/assistants/knowledge-transfer-assistant/.vscode/settings.json b/assistants/knowledge-transfer-assistant/.vscode/settings.json index fc40a34c4..384f29338 100644 --- a/assistants/knowledge-transfer-assistant/.vscode/settings.json +++ b/assistants/knowledge-transfer-assistant/.vscode/settings.json @@ -26,7 +26,6 @@ "editor.defaultFormatter": "esbenp.prettier-vscode", "editor.formatOnSave": true }, - "python.analysis.autoFormatStrings": true, "python.analysis.autoImportCompletions": true, "python.analysis.diagnosticMode": "workspace", "python.analysis.fixAll": ["source.unusedImports"], @@ -42,7 +41,6 @@ "source.formatDocument": "explicit" } }, - "ruff.nativeServer": "on", "search.exclude": { "**/.venv": true, "**/.data": true, @@ -59,6 +57,7 @@ ], "cSpell.words": [ "addopts", + "agentic", "asctime", "asyncio", "Codespaces", diff --git a/assistants/knowledge-transfer-assistant/assistant/agentic/analysis.py b/assistants/knowledge-transfer-assistant/assistant/agentic/analysis.py index 14125accb..429af3fdb 100644 --- a/assistants/knowledge-transfer-assistant/assistant/agentic/analysis.py +++ b/assistants/knowledge-transfer-assistant/assistant/agentic/analysis.py @@ -6,7 +6,7 @@ """ import json -from typing import Any, Dict, List +from typing import Any import openai_client from openai.types.chat import ChatCompletionMessageParam @@ -16,7 +16,7 @@ from assistant.logging import logger -async def detect_information_request_needs(context: ConversationContext, message: str) -> Dict[str, Any]: +async def detect_information_request_needs(context: ConversationContext, message: str) -> dict[str, Any]: """ Analyze a user message in context of recent chat history to detect potential information request needs. Uses an LLM for sophisticated detection. @@ -28,7 +28,7 @@ async def detect_information_request_needs(context: ConversationContext, message Returns: Dict with detection results including is_information_request, confidence, and other metadata """ - debug: Dict[str, Any] = { + debug: dict[str, Any] = { "message": message, "context": context, } @@ -85,7 +85,7 @@ async def detect_information_request_needs(context: ConversationContext, message # Create chat completion with history context async with openai_client.create_client(config.service_config) as client: # Prepare messages array with system prompt and chat history - messages: List[ChatCompletionMessageParam] = [ + messages: list[ChatCompletionMessageParam] = [ { "role": "system", "content": config.prompt_config.share_information_request_detection, @@ -145,7 +145,7 @@ async def detect_information_request_needs(context: ConversationContext, message debug["error"] = str(e) return { "is_information_request": False, - "reason": f"LLM detection error: {str(e)}", + "reason": f"LLM detection error: {e!s}", "confidence": 0.0, "debug": debug, } diff --git a/assistants/knowledge-transfer-assistant/assistant/agentic/coordinator_support.py b/assistants/knowledge-transfer-assistant/assistant/agentic/coordinator_support.py index 5e06a9f5d..c0bdfbafd 100644 --- a/assistants/knowledge-transfer-assistant/assistant/agentic/coordinator_support.py +++ b/assistants/knowledge-transfer-assistant/assistant/agentic/coordinator_support.py @@ -4,8 +4,6 @@ Provides next action suggestions and other coordinator utilities. """ -from typing import Optional - from semantic_workbench_assistant.assistant_app import ConversationContext from assistant.data import RequestStatus @@ -19,7 +17,7 @@ class CoordinatorSupport: @staticmethod async def get_coordinator_next_action_suggestion( context: ConversationContext, - ) -> Optional[str]: + ) -> str | None: """ Generate next action suggestions for the coordinator based on the knowledge transfer state. @@ -46,24 +44,37 @@ async def get_coordinator_next_action_suggestion( # 1. Unresolved requests come first if active_requests: request = active_requests[0] - return f'There are {len(active_requests)} unanswered questions from team members. One of them is: "{request.title}" Let\'s work on answering it.' + return ( + f"There are {len(active_requests)} unanswered questions from team members. " + f'One of them is: "{request.title}" Let\'s work on answering it.' + ) # 2. Audience not yet defined if not share.audience: - return "Let's start by defining who your audience is. Who is this knowledge for, and what's their background?" + return ( + "Let's start by defining who your audience is. " + "Who is this knowledge for, and what's their background?" + ) # 3. Knowledge not yet organized if not share.knowledge_organized: - return "Next, let's organize your knowledge. Upload any relevant files or describe the knowledge you want to transfer. When you're ready, I can mark the knowledge as organized." + return ( + "Next, let's organize your knowledge. Upload any relevant files or describe the knowledge " + "you want to transfer. When you're ready, I can mark the knowledge as organized." + ) # 4. Brief not yet written if not brief: - return "Your knowledge share-out needs a short introduction that will orient your team. Let's write a knowledge brief next. The knowledge brief helps your team understand the purpose of this knowledge transfer and will be visible to all team members in their side panel." + return ( + "Your knowledge share-out needs a short introduction that will orient your team. " + "Let's write a knowledge brief next. The knowledge brief helps your team understand " + "the purpose of this knowledge transfer and will be visible to all team members in their side panel." # noqa: E501 + ) # 5. If intended to have outcomes but none defined yet if share.is_intended_to_accomplish_outcomes and not share.learning_objectives: return ( - "Would you like your team to achieve any specific outcomes? If so, let's define some learning objectives. " + "Would you like your team to achieve any specific outcomes? If so, let's define some learning objectives. " # noqa: E501 "If not, you can mark this share-out as 'exploratory' instead." ) @@ -72,22 +83,38 @@ async def get_coordinator_next_action_suggestion( incomplete_objectives = [obj for obj in share.learning_objectives if not obj.learning_outcomes] if incomplete_objectives: name = incomplete_objectives[0].name - return f"The learning objective '{name}' doesn't have any outcomes yet. Let's define what your team should accomplish to meet it." + return ( + f"The learning objective '{name}' doesn't have any outcomes yet. " + f"Let's define what your team should accomplish to meet it." + ) # 7. Ready for transfer but not yet shared if TransferManager.is_ready_for_transfer(share) and not TransferManager.is_actively_sharing(share): - return "Your knowledge is ready to share. Would you like to create a message and generate the invitation link?" + return ( + "Your knowledge is ready to share. " + "Would you like to create a message and generate the invitation link?" + ) # 8. Actively sharing - monitor and support ongoing transfer if TransferManager.is_actively_sharing(share): if share.is_intended_to_accomplish_outcomes and not TransferManager._is_transfer_complete(share): team_count = len(share.team_conversations) - return f"Great! Your knowledge is being shared with {team_count} team member{'s' if team_count != 1 else ''}. You can continue improving the knowledge share or respond to information requests as they come in." + return ( + f"Great! Your knowledge is being shared with {team_count} team member" + f"{'s' if team_count != 1 else ''}. You can continue improving the knowledge share or " + f"respond to information requests as they come in." + ) else: - return "Your knowledge transfer is in progress. You can continue improving the knowledge share or respond to information requests as they come in." + return ( + "Your knowledge transfer is in progress. You can continue improving the knowledge share or " + "respond to information requests as they come in." + ) # 9. Default: General support - return "Your knowledge share is available. You can continue improving it or respond to new information requests as they come in." + return ( + "Your knowledge share is available. You can continue improving it or " + "respond to new information requests as they come in." + ) except Exception as e: logger.exception(f"Error generating next action suggestion: {e}") diff --git a/assistants/knowledge-transfer-assistant/assistant/agentic/team_welcome.py b/assistants/knowledge-transfer-assistant/assistant/agentic/team_welcome.py index d3b539822..0b0c78185 100644 --- a/assistants/knowledge-transfer-assistant/assistant/agentic/team_welcome.py +++ b/assistants/knowledge-transfer-assistant/assistant/agentic/team_welcome.py @@ -6,7 +6,7 @@ """ from textwrap import dedent -from typing import Any, Dict, List +from typing import Any import openai_client from openai.types.chat import ChatCompletionMessageParam @@ -23,7 +23,7 @@ async def generate_team_welcome_message( """ Generates a welcome message for the team based on the knowledge transfer information. """ - debug: Dict[str, Any] = {} + debug: dict[str, Any] = {} config = await assistant_config.get(context.assistant) @@ -66,8 +66,9 @@ async def generate_team_welcome_message( if knowledge_digest and knowledge_digest.content: knowledge_digest_text = dedent(f""" ### ASSISTANT KNOWLEDGE DIGEST - KEY KNOWLEDGE SHARE INFORMATION - The knowledge digest contains critical knowledge share information that has been automatically extracted from previous conversations. - It serves as a persistent memory of important facts, decisions, and context that you should reference when responding. + The knowledge digest contains critical knowledge share information that has been automatically + extracted from previous conversations. It serves as a persistent memory of important facts, + decisions, and context that you should reference when responding. Key characteristics of this knowledge digest: - It contains the most essential information about the knowledge share that should be readily available @@ -94,7 +95,7 @@ async def generate_team_welcome_message( share_info = "\n\n## KNOWLEDGE SHARE INFORMATION\n\n" + "\n".join(share_data.values()) instructions = f"{config.prompt_config.welcome_message_generation}\n\n{share_info}" - messages: List[ChatCompletionMessageParam] = [ + messages: list[ChatCompletionMessageParam] = [ {"role": "system", "content": instructions}, ] diff --git a/assistants/knowledge-transfer-assistant/assistant/assistant.py b/assistants/knowledge-transfer-assistant/assistant/assistant.py index a531ccdd4..2b54fb2c3 100644 --- a/assistants/knowledge-transfer-assistant/assistant/assistant.py +++ b/assistants/knowledge-transfer-assistant/assistant/assistant.py @@ -126,7 +126,7 @@ 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)}" + welcome_message = f"I'm having trouble setting up your knowledge transfer. Please try again or contact support if the issue persists. {e!s}" # noqa: E501 await context.send_messages( NewConversationMessage( @@ -214,7 +214,7 @@ async def on_conversation_updated(context: ConversationContext) -> None: 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}'" + f"Updated conversation {shared_conversation_id} title from '{target_conversation.title}' to '{conversation.title}'" # noqa: E501 ) else: logger.debug(f"Conversation {shared_conversation_id} title already matches: '{conversation.title}'") @@ -279,13 +279,15 @@ async def on_message_created( # If the message is from a Coordinator, update the digest in the # background if role == ConversationRole.COORDINATOR and message.message_type == MessageType.chat: - asyncio.create_task(KnowledgeDigestManager.auto_update_knowledge_digest(context)) + task = asyncio.create_task(KnowledgeDigestManager.auto_update_knowledge_digest(context)) + # Fire and forget - we don't need to await this background task + task.add_done_callback(lambda t: t.exception() if t.done() and t.exception() else None) except Exception as e: logger.exception(f"Error handling message: {e}") await context.send_messages( NewConversationMessage( - content=f"Error: {str(e)}", + content=f"Error: {e!s}", message_type=MessageType.notice, metadata={"generated_content": False, **metadata}, ) diff --git a/assistants/knowledge-transfer-assistant/assistant/common.py b/assistants/knowledge-transfer-assistant/assistant/common.py index f711c82d1..71d566d47 100644 --- a/assistants/knowledge-transfer-assistant/assistant/common.py +++ b/assistants/knowledge-transfer-assistant/assistant/common.py @@ -5,8 +5,6 @@ helping to reduce code duplication and maintain consistency. """ -from typing import Dict, Optional - from semantic_workbench_assistant.assistant_app import ConversationContext from .data import LogEntryType @@ -17,8 +15,8 @@ async def log_transfer_action( context: ConversationContext, entry_type: LogEntryType, message: str, - related_entity_id: Optional[str] = None, - additional_metadata: Optional[Dict] = None, + related_entity_id: str | None = None, + additional_metadata: dict | None = None, ) -> None: """ Log an action to the knowledge transfer log. diff --git a/assistants/knowledge-transfer-assistant/assistant/config.py b/assistants/knowledge-transfer-assistant/assistant/config.py index f84d51d1d..6b4edb8b3 100644 --- a/assistants/knowledge-transfer-assistant/assistant/config.py +++ b/assistants/knowledge-transfer-assistant/assistant/config.py @@ -87,7 +87,7 @@ class PromptConfig(BaseModel): str, Field( title="Coordinator Instructions", - description="The instructions to give the coordinator assistant. This is added to the prompt when in coordinator mode.", + description="The instructions to give the coordinator assistant. This is added to the prompt when in coordinator mode.", # noqa: E501 ), UISchema(widget="textarea"), ] = load_text_include("coordinator_instructions.txt") @@ -105,7 +105,7 @@ class PromptConfig(BaseModel): str, Field( title="Team Instructions", - description="The instructions to give the team assistant. This is added to the prompt when in team member mode.", + description="The instructions to give the team assistant. This is added to the prompt when in team member mode.", # noqa: E501 ), UISchema(widget="textarea"), ] = load_text_include("team_instructions.txt") @@ -150,23 +150,27 @@ class CoordinatorConfig(BaseModel): str, Field( title="Coordinator Welcome Message", - description="The message to display when a coordinator starts a new knowledge transfer. {share_url} will be replaced with the actual URL.", + description="The message to display when a coordinator starts a new knowledge transfer. {share_url} will be replaced with the actual URL.", # noqa: E501 ), UISchema(widget="textarea"), ] = """# Welcome to Knowledge Transfer -Welcome! I'm here to help you capture and share knowledge in a way that others can easily explore and understand. Think of me as your personal knowledge bridge - I'll help you: +Welcome! I'm here to help you capture and share knowledge in a way that others can easily explore and understand. +Think of me as your personal knowledge bridge - I'll help you: - 📚 Organize your thoughts - whether from documents, code, research papers, or brainstorming sessions - 🔄 Establish shared understanding - I'll ask questions to ensure we're aligned on what matters most - 🎯 Define learning objectives - so we can track progress and outcomes -- 🔍 Make your knowledge interactive - so others can explore the "why" behind decisions, alternatives considered, and deeper context +- 🔍 Make your knowledge interactive - so others can explore the "why" behind decisions, alternatives considered, + and deeper context -Simply share your content or ideas, tell me who needs to understand them, and what aspects you want to highlight. I'll capture what knowledge you give me so it can be shared with your team members for them to explore at their own pace. +Simply share your content or ideas, tell me who needs to understand them, and what aspects you want to highlight. +I'll capture what knowledge you give me so it can be shared with your team members for them to explore at their own pace. -In the side panel, you can see your "knowledge brief". This brief will be shared with your team members and will help them understand the content of your knowledge transfer. You can ask me to update it at any time. +In the side panel, you can see your "knowledge brief". This brief will be shared with your team members and will +help them understand the content of your knowledge transfer. You can ask me to update it at any time. -To get started, let's discuss your audience. Who are you going to be sharing your knowledge with?""" +To get started, let's discuss your audience. Who are you going to be sharing your knowledge with?""" # noqa: E501 max_digest_tokens: Annotated[ int, @@ -189,10 +193,10 @@ class TeamConfig(BaseModel): str, Field( title="Team Welcome Message", - description="The message to display when a user joins a knowledge transfer as a Team member. Shown after successfully joining a knowledge transfer.", + description="The message to display when a user joins a knowledge transfer as a Team member. Shown after successfully joining a knowledge transfer.", # noqa: E501 ), UISchema(widget="textarea"), - ] = "# Welcome to Your Team Conversation\n\nYou've joined as a team member. This is your personal conversation for exploring the knowledge share. You can communicate with the assistant, make information requests, and track your progress here." + ] = "# Welcome to Your Team Conversation\n\nYou've joined as a team member. This is your personal conversation for exploring the knowledge share. You can communicate with the assistant, make information requests, and track your progress here." # noqa: E501 # Base Assistant Configuration - shared by all templates @@ -250,7 +254,7 @@ class AssistantConfigModel(BaseModel): bool, Field( title="Track Progress", - description="Track knowledge transfer progress with learning objectives, outcome completion, and overall transfer state.", + description="Track knowledge transfer progress with learning objectives, outcome completion, and overall transfer state.", # noqa: E501 ), ] = True diff --git a/assistants/knowledge-transfer-assistant/assistant/conversation_clients.py b/assistants/knowledge-transfer-assistant/assistant/conversation_clients.py index e390d1e7e..846caaf03 100644 --- a/assistants/knowledge-transfer-assistant/assistant/conversation_clients.py +++ b/assistants/knowledge-transfer-assistant/assistant/conversation_clients.py @@ -6,7 +6,7 @@ contexts and accessing other conversations. """ -from typing import Any, Optional, Tuple +from typing import Any from semantic_workbench_api_model.workbench_service_client import ConversationAPIClient from semantic_workbench_assistant.assistant_app import ConversationContext @@ -35,7 +35,7 @@ def get_conversation_client(context: ConversationContext, conversation_id: str) @staticmethod async def get_coordinator_client_for_share( context: ConversationContext, share_id: str - ) -> Tuple[Optional[Any], Optional[str]]: + ) -> tuple[Any | None, str | None]: """ Gets a client for accessing the Coordinator conversation for a knowledge transfer. """ @@ -70,7 +70,7 @@ async def get_coordinator_client_for_share( @staticmethod async def create_temporary_context_for_conversation( source_context: ConversationContext, target_conversation_id: str - ) -> Optional[ConversationContext]: + ) -> ConversationContext | None: """ Creates a temporary context for the target conversation ID. """ diff --git a/assistants/knowledge-transfer-assistant/assistant/data.py b/assistants/knowledge-transfer-assistant/assistant/data.py index e1cd1e847..5bd5edb28 100644 --- a/assistants/knowledge-transfer-assistant/assistant/data.py +++ b/assistants/knowledge-transfer-assistant/assistant/data.py @@ -8,7 +8,7 @@ import uuid from datetime import datetime from enum import Enum -from typing import Any, Dict, List, Optional +from typing import Any from pydantic import BaseModel, Field @@ -68,7 +68,7 @@ class TeamConversationInfo(BaseModel): redeemer_name: str joined_at: datetime = Field(default_factory=datetime.utcnow) last_active_at: datetime = Field(default_factory=datetime.utcnow) - outcome_achievements: List[LearningOutcomeAchievement] = Field(default_factory=list) + outcome_achievements: list[LearningOutcomeAchievement] = Field(default_factory=list) class LearningOutcome(BaseModel): @@ -81,13 +81,13 @@ class LearningObjective(BaseModel): name: str description: str priority: int = 1 - learning_outcomes: List[LearningOutcome] = Field(default_factory=list) + learning_outcomes: list[LearningOutcome] = Field(default_factory=list) class KnowledgeBrief(BaseEntity): title: str content: str - timeline: Optional[str] = None + timeline: str | None = None class KnowledgeDigest(BaseEntity): @@ -101,13 +101,13 @@ class InformationRequest(BaseEntity): description: str priority: RequestPriority = RequestPriority.MEDIUM status: RequestStatus = RequestStatus.NEW - related_objective_ids: List[str] = Field(default_factory=list) - resolution: Optional[str] = None - resolved_at: Optional[datetime] = None - resolved_by: Optional[str] = None + related_objective_ids: list[str] = Field(default_factory=list) + resolution: str | None = None + resolved_at: datetime | None = None + resolved_by: str | None = None # History of status updates and comments - updates: List[Dict[str, Any]] = Field(default_factory=list) + updates: list[dict[str, Any]] = Field(default_factory=list) class LogEntryType(str, Enum): @@ -154,46 +154,46 @@ class LogEntry(BaseModel): entry_type: LogEntryType user_id: str user_name: str - related_entity_id: Optional[str] = None - entity_type: Optional[str] = None + related_entity_id: str | None = None + entity_type: str | None = None message: str - metadata: Optional[Dict] = None + metadata: dict | None = None class KnowledgePackageLog(BaseModel): - entries: List[LogEntry] = Field(default_factory=list) # Chronological list of log entries + entries: list[LogEntry] = Field(default_factory=list) # Chronological list of log entries class KnowledgePackage(BaseModel): share_id: str - coordinator_conversation_id: Optional[str] = None - shared_conversation_id: Optional[str] = None - team_conversations: Dict[str, TeamConversationInfo] = Field(default_factory=dict) - share_url: Optional[str] = None + coordinator_conversation_id: str | None = None + shared_conversation_id: str | None = None + team_conversations: dict[str, TeamConversationInfo] = Field(default_factory=dict) + share_url: str | None = None version: int = 1 created_at: datetime = Field(default_factory=datetime.utcnow) updated_at: datetime = Field(default_factory=datetime.utcnow) - updated_by: Optional[str] = None + updated_by: str | None = None # Package components - audience: Optional[str] = None - brief: Optional[KnowledgeBrief] - learning_objectives: List[LearningObjective] = Field(default_factory=list) - takeaways: List[str] = Field(default_factory=list) - preferred_communication_style: Optional[str] = None - transfer_notes: Optional[str] = None - digest: Optional[KnowledgeDigest] + audience: str | None = None + brief: KnowledgeBrief | None + learning_objectives: list[LearningObjective] = Field(default_factory=list) + takeaways: list[str] = Field(default_factory=list) + preferred_communication_style: str | None = None + transfer_notes: str | None = None + digest: KnowledgeDigest | None # Lifecycle is_intended_to_accomplish_outcomes: bool = True - next_learning_actions: List[str] = Field(default_factory=list) - transfer_lifecycle: Dict[str, Any] = Field(default_factory=dict) + next_learning_actions: list[str] = Field(default_factory=list) + transfer_lifecycle: dict[str, Any] = Field(default_factory=dict) knowledge_organized: bool = False archived: bool = False - requests: List[InformationRequest] = Field(default_factory=list) + requests: list[InformationRequest] = Field(default_factory=list) - log: Optional[KnowledgePackageLog] = Field(default_factory=lambda: KnowledgePackageLog()) + log: KnowledgePackageLog | None = Field(default_factory=lambda: KnowledgePackageLog()) class CoordinatorConversationMessage(BaseModel): @@ -207,4 +207,4 @@ class CoordinatorConversationMessage(BaseModel): class CoordinatorConversationMessages(BaseModel): knowledge_share_id: str last_updated: datetime = Field(default_factory=datetime.utcnow) - messages: List[CoordinatorConversationMessage] = Field(default_factory=list) + messages: list[CoordinatorConversationMessage] = Field(default_factory=list) diff --git a/assistants/knowledge-transfer-assistant/assistant/domain/__init__.py b/assistants/knowledge-transfer-assistant/assistant/domain/__init__.py index a9e4ce8ad..23fa75fa1 100644 --- a/assistants/knowledge-transfer-assistant/assistant/domain/__init__.py +++ b/assistants/knowledge-transfer-assistant/assistant/domain/__init__.py @@ -13,11 +13,11 @@ from .transfer_manager import TransferManager __all__ = [ - "TransferManager", + "AudienceManager", "InformationRequestManager", "KnowledgeBriefManager", "KnowledgeDigestManager", "LearningObjectivesManager", - "AudienceManager", "ShareManager", + "TransferManager", ] diff --git a/assistants/knowledge-transfer-assistant/assistant/domain/audience_manager.py b/assistants/knowledge-transfer-assistant/assistant/domain/audience_manager.py index 67533e881..a8274379b 100644 --- a/assistants/knowledge-transfer-assistant/assistant/domain/audience_manager.py +++ b/assistants/knowledge-transfer-assistant/assistant/domain/audience_manager.py @@ -4,7 +4,7 @@ Handles knowledge transfer state updates, completion, and lifecycle operations. """ -from datetime import datetime, timezone +from datetime import UTC, datetime from semantic_workbench_assistant.assistant_app import ConversationContext @@ -25,7 +25,7 @@ async def update_audience( """ share = await ShareManager.get_share(context) share.audience = audience_description.strip() - share.updated_at = datetime.now(timezone.utc) + share.updated_at = datetime.now(UTC) await ShareManager.set_share(context, share) await ShareManager.log_share_event( diff --git a/assistants/knowledge-transfer-assistant/assistant/domain/information_request_manager.py b/assistants/knowledge-transfer-assistant/assistant/domain/information_request_manager.py index 74a4d21cf..4098ccec3 100644 --- a/assistants/knowledge-transfer-assistant/assistant/domain/information_request_manager.py +++ b/assistants/knowledge-transfer-assistant/assistant/domain/information_request_manager.py @@ -4,8 +4,7 @@ Handles information request creation, resolution, and retrieval. """ -from datetime import datetime, timezone -from typing import List, Optional +from datetime import UTC, datetime from semantic_workbench_assistant.assistant_app import ConversationContext @@ -30,7 +29,7 @@ class InformationRequestManager: @staticmethod async def get_information_requests( context: ConversationContext, - ) -> List[InformationRequest]: + ) -> list[InformationRequest]: """Gets all information requests for the current conversation's share.""" share_id = await ShareManager.get_share_id(context) return ShareStorage.get_all_information_requests(share_id) @@ -41,7 +40,7 @@ async def create_information_request( title: str, description: str, priority: RequestPriority = RequestPriority.MEDIUM, - related_objective_ids: Optional[List[str]] = None, + related_objective_ids: list[str] | None = None, ) -> InformationRequest: share_id = await ShareManager.get_share_id(context) @@ -106,19 +105,19 @@ async def resolve_information_request( # Update the request information_request.status = RequestStatus.RESOLVED information_request.resolution = resolution - information_request.resolved_at = datetime.now(timezone.utc) + information_request.resolved_at = datetime.now(UTC) information_request.resolved_by = current_user_id # Add to history information_request.updates.append({ - "timestamp": datetime.now(timezone.utc).isoformat(), + "timestamp": datetime.now(UTC).isoformat(), "user_id": current_user_id, "message": f"Request resolved: {resolution}", "status": RequestStatus.RESOLVED.value, }) # Update metadata - information_request.updated_at = datetime.now(timezone.utc) + information_request.updated_at = datetime.now(UTC) information_request.updated_by = current_user_id information_request.version += 1 diff --git a/assistants/knowledge-transfer-assistant/assistant/domain/knowledge_brief_manager.py b/assistants/knowledge-transfer-assistant/assistant/domain/knowledge_brief_manager.py index a4bab5a6b..0fa1d3827 100644 --- a/assistants/knowledge-transfer-assistant/assistant/domain/knowledge_brief_manager.py +++ b/assistants/knowledge-transfer-assistant/assistant/domain/knowledge_brief_manager.py @@ -4,8 +4,6 @@ Handles knowledge brief creation, updates, and retrieval. """ -from typing import Optional - from semantic_workbench_assistant.assistant_app import ConversationContext from assistant.data import InspectorTab, KnowledgeBrief, LogEntryType @@ -22,7 +20,7 @@ class KnowledgeBriefManager: @staticmethod async def get_knowledge_brief( context: ConversationContext, - ) -> Optional[KnowledgeBrief]: + ) -> KnowledgeBrief | None: share_id = await ShareManager.get_share_id(context) return ShareStorage.read_knowledge_brief(share_id) @@ -31,7 +29,7 @@ async def update_knowledge_brief( context: ConversationContext, title: str, description: str, - timeline: Optional[str] = None, + timeline: str | None = None, ) -> KnowledgeBrief: share_id = await ShareManager.get_share_id(context) current_user_id = await require_current_user(context, "update brief") diff --git a/assistants/knowledge-transfer-assistant/assistant/domain/knowledge_digest_manager.py b/assistants/knowledge-transfer-assistant/assistant/domain/knowledge_digest_manager.py index 1299f364e..d70fa7f77 100644 --- a/assistants/knowledge-transfer-assistant/assistant/domain/knowledge_digest_manager.py +++ b/assistants/knowledge-transfer-assistant/assistant/domain/knowledge_digest_manager.py @@ -5,8 +5,7 @@ """ import re -from datetime import datetime, timezone -from typing import Optional +from datetime import UTC, datetime import openai_client from semantic_workbench_api_model.workbench_model import ParticipantRole @@ -27,7 +26,7 @@ class KnowledgeDigestManager: @staticmethod async def get_knowledge_digest( context: ConversationContext, - ) -> Optional[KnowledgeDigest]: + ) -> KnowledgeDigest | None: share_id = await ShareManager.get_share_id(context) if not share_id: return None @@ -61,7 +60,7 @@ async def update_knowledge_digest( digest.content = content digest.is_auto_generated = is_auto_generated - digest.updated_at = datetime.now(timezone.utc) + digest.updated_at = datetime.now(UTC) digest.updated_by = current_user_id digest.version += 1 ShareStorage.write_knowledge_digest(share_id, digest) @@ -133,10 +132,7 @@ async def auto_update_knowledge_digest( content = completion.choices[0].message.content or "" digest_content = "" match = re.search(r"(.*?)", content, re.DOTALL) - if match: - digest_content = match.group(1).strip() - else: - digest_content = content.strip() + digest_content = match.group(1).strip() if match else content.strip() if not digest_content: raise ValueError("No content extracted from knowledge digest LLM analysis") diff --git a/assistants/knowledge-transfer-assistant/assistant/domain/learning_objectives_manager.py b/assistants/knowledge-transfer-assistant/assistant/domain/learning_objectives_manager.py index 95fdfbe56..53a339cc1 100644 --- a/assistants/knowledge-transfer-assistant/assistant/domain/learning_objectives_manager.py +++ b/assistants/knowledge-transfer-assistant/assistant/domain/learning_objectives_manager.py @@ -4,8 +4,6 @@ Handles learning objectives, outcomes creation, updates, and deletion. """ -from typing import List, Optional, Tuple - from semantic_workbench_assistant.assistant_app import ConversationContext from assistant.data import ( @@ -29,9 +27,9 @@ async def add_learning_objective( context: ConversationContext, objective_name: str, description: str, - outcomes: Optional[List[str]] = None, + outcomes: list[str] | None = None, priority: int = 1, - ) -> Optional[LearningObjective]: + ) -> LearningObjective | None: share_id = await ShareManager.get_share_id(context) criterion_objects = [] @@ -67,8 +65,8 @@ async def add_learning_objective( async def update_learning_objective( context: ConversationContext, objective_id: str, - objective_name: Optional[str] = None, - description: Optional[str] = None, + objective_name: str | None = None, + description: str | None = None, ) -> str: """ Update an existing learning objective's name or description. @@ -88,10 +86,7 @@ async def update_learning_objective( break if not objective: - available_ids = [obj.id for obj in share.learning_objectives] - raise ValueError( - f"Learning objective with ID '{objective_id}' not found. Available objective IDs: {', '.join(available_ids[:3]) + ('...' if len(available_ids) > 3 else '')}" - ) + raise ValueError("Learning objective not found") original_name = objective.name changes_made = [] @@ -156,10 +151,7 @@ async def delete_learning_objective( break if not objective: - available_ids = [obj.id for obj in share.learning_objectives] - raise ValueError( - f"Learning objective with ID '{objective_id}' not found. Available objective IDs: {', '.join(available_ids[:3]) + ('...' if len(available_ids) > 3 else '')}" - ) + raise ValueError("Learning objective not found.") objective_name = objective.name @@ -200,7 +192,7 @@ async def delete_learning_objective( @staticmethod async def get_learning_outcomes( context: ConversationContext, - ) -> List[LearningOutcome]: + ) -> list[LearningOutcome]: share = await ShareManager.get_share(context) objectives = share.learning_objectives @@ -234,10 +226,7 @@ async def add_learning_outcome( break if objective is None: - available_ids = [obj.id for obj in share.learning_objectives] - raise ValueError( - f"Learning objective with ID '{objective_id}' not found. Available objective IDs: {', '.join(available_ids[:3]) + ('...' if len(available_ids) > 3 else '')}" - ) + raise ValueError("Learning objective not found") # Create the new outcome new_outcome = LearningOutcome(description=outcome_description.strip()) @@ -307,9 +296,7 @@ async def update_learning_outcome( for obj in share.learning_objectives: for out in obj.learning_outcomes: available_outcome_ids.append(out.id) - raise ValueError( - f"Learning outcome with ID '{outcome_id}' not found. Available outcome IDs: {', '.join(available_outcome_ids[:3]) + ('...' if len(available_outcome_ids) > 3 else '')}" - ) + raise ValueError("Learning outcome not found.") old_description = outcome.description @@ -323,7 +310,7 @@ async def update_learning_outcome( await ShareManager.log_share_event( context=context, entry_type=LogEntryType.LEARNING_OBJECTIVE_UPDATED.value, - message=f"Updated learning outcome in objective '{objective.name}': '{old_description}' → '{new_description}'", + message="Updated learning outcome.", metadata={ "objective_id": objective.id, "objective_name": objective.name, @@ -378,9 +365,7 @@ async def delete_learning_outcome( for obj in share.learning_objectives: for out in obj.learning_outcomes: available_outcome_ids.append(out.id) - raise ValueError( - f"Learning outcome with ID '{outcome_id}' not found. Available outcome IDs: {', '.join(available_outcome_ids[:3]) + ('...' if len(available_outcome_ids) > 3 else '')}" - ) + raise ValueError("Learning outcome not found.") deleted_description = outcome_to_delete.description @@ -425,12 +410,12 @@ async def delete_learning_outcome( @staticmethod def get_achievements_for_conversation( package: KnowledgePackage, conversation_id: str - ) -> List[LearningOutcomeAchievement]: + ) -> list[LearningOutcomeAchievement]: team_conv = package.team_conversations.get(conversation_id) return team_conv.outcome_achievements if team_conv else [] @staticmethod - def get_completion_for_conversation(package: KnowledgePackage, conversation_id: str) -> Tuple[int, int]: + def get_completion_for_conversation(package: KnowledgePackage, conversation_id: str) -> tuple[int, int]: achievements = LearningObjectivesManager.get_achievements_for_conversation(package, conversation_id) achieved_outcome_ids = {a.outcome_id for a in achievements if a.achieved} @@ -445,7 +430,7 @@ def is_outcome_achieved_by_conversation(package: KnowledgePackage, outcome_id: s return any(a.outcome_id == outcome_id and a.achieved for a in achievements) @staticmethod - def get_overall_completion(package: KnowledgePackage) -> Tuple[int, int]: + def get_overall_completion(package: KnowledgePackage) -> tuple[int, int]: """ Get overall completion across all team conversations. Returns: diff --git a/assistants/knowledge-transfer-assistant/assistant/domain/share_manager.py b/assistants/knowledge-transfer-assistant/assistant/domain/share_manager.py index 51dd5178a..cd4c10b85 100644 --- a/assistants/knowledge-transfer-assistant/assistant/domain/share_manager.py +++ b/assistants/knowledge-transfer-assistant/assistant/domain/share_manager.py @@ -6,8 +6,7 @@ import uuid from csv import Error -from datetime import datetime, timezone -from typing import List, Optional +from datetime import UTC, datetime from semantic_workbench_api_model.workbench_model import ( ConversationPermission, @@ -98,7 +97,7 @@ async def set_conversation_role(context: ConversationContext, share_id: str, rol @staticmethod async def get_conversation_role( context: ConversationContext, - ) -> Optional[ConversationRole]: + ) -> ConversationRole | None: """ Gets the role of a conversation in a knowledge transfer. """ @@ -174,7 +173,7 @@ async def create_shareable_team_conversation(context: ConversationContext, share if knowledge_package: knowledge_package.shared_conversation_id = str(conversation.id) knowledge_package.share_url = share_url - knowledge_package.updated_at = datetime.now(timezone.utc) + knowledge_package.updated_at = datetime.now(UTC) ShareStorage.write_share(share_id, knowledge_package) else: raise ValueError(f"KnowledgePackage info not found for share ID: {share_id}") @@ -182,7 +181,7 @@ async def create_shareable_team_conversation(context: ConversationContext, share return share_url @staticmethod - async def get_shared_conversation_id(context: ConversationContext) -> Optional[str]: + async def get_shared_conversation_id(context: ConversationContext) -> str | None: """ Retrieves the share ID and finds the associated shareable template conversation ID. """ @@ -229,7 +228,7 @@ async def get_share_id(context: ConversationContext) -> str: else: raise NoShareException except Error as e: - raise NoShareFoundError(str(e)) + raise NoShareFoundError(str(e)) from e @staticmethod async def get_share(context: ConversationContext) -> KnowledgePackage: @@ -245,7 +244,7 @@ async def get_share(context: ConversationContext) -> KnowledgePackage: else: raise NoShareException except Error as e: - raise NoShareFoundError(str(e)) + raise NoShareFoundError(str(e)) from e @staticmethod async def set_share(context: ConversationContext, share: KnowledgePackage) -> None: @@ -262,7 +261,7 @@ async def set_share(context: ConversationContext, share: KnowledgePackage) -> No ShareStorage.write_share_log(share_id, share.log) @staticmethod - async def get_linked_conversations(context: ConversationContext) -> List[str]: + async def get_linked_conversations(context: ConversationContext) -> list[str]: """ Gets all conversations linked to this one through the same knowledge transfer share. """ @@ -279,7 +278,7 @@ async def get_linked_conversations(context: ConversationContext) -> List[str]: conversations.append(share.shared_conversation_id) # Add all team conversations - for conversation_id in share.team_conversations.keys(): + for conversation_id in share.team_conversations: conversations.append(conversation_id) return [] @@ -341,7 +340,7 @@ async def _capture_redeemer_info(context: ConversationContext, share_id: str) -> @staticmethod async def get_share_log( context: ConversationContext, - ) -> Optional[KnowledgePackageLog]: + ) -> KnowledgePackageLog | None: """Gets the knowledge transfer log for the current conversation's share.""" try: share_id = await ShareManager.get_share_id(context) @@ -357,7 +356,7 @@ async def append_coordinator_message( content: str, sender_name: str, is_assistant: bool = False, - timestamp: Optional[datetime] = None, + timestamp: datetime | None = None, ) -> None: """ Appends a message to the coordinator conversation log. @@ -372,7 +371,7 @@ async def append_coordinator_message( content=content, sender_name=sender_name, is_assistant=is_assistant, - timestamp=timestamp or datetime.now(timezone.utc), + timestamp=timestamp or datetime.now(UTC), ) except Exception as e: logger.error(f"Error appending coordinator message: {e}") @@ -380,7 +379,7 @@ async def append_coordinator_message( @staticmethod async def get_coordinator_conversation( context: ConversationContext, - ) -> Optional[CoordinatorConversationMessages]: + ) -> CoordinatorConversationMessages | None: """ Gets the coordinator conversation. """ @@ -394,8 +393,8 @@ async def log_share_event( context: ConversationContext, entry_type: str, message: str, - related_entity_id: Optional[str] = None, - metadata: Optional[dict] = None, + related_entity_id: str | None = None, + metadata: dict | None = None, ) -> None: """ Logs an event to the knowledge transfer log. diff --git a/assistants/knowledge-transfer-assistant/assistant/files.py b/assistants/knowledge-transfer-assistant/assistant/files.py index 2f4bb246b..a8ff1792d 100644 --- a/assistants/knowledge-transfer-assistant/assistant/files.py +++ b/assistants/knowledge-transfer-assistant/assistant/files.py @@ -9,7 +9,6 @@ import io import pathlib from datetime import datetime -from typing import Dict, List, Optional from pydantic import BaseModel, Field from semantic_workbench_api_model import workbench_model @@ -60,7 +59,7 @@ class ShareFile(BaseModel): class ShareCollection(BaseModel): """Collection of file metadata for a share.""" - files: List[ShareFile] = Field(default_factory=list) + files: list[ShareFile] = Field(default_factory=list) class ShareFilesManager: @@ -178,7 +177,7 @@ async def copy_file_to_share_storage( # Write the file to share storage file_path = ShareFilesManager.get_file_path(share_id, file.filename) try: - with open(file_path, "wb") as f: + with file_path.open("wb") as f: f.write(buffer.getvalue()) # Verify file was written @@ -391,7 +390,7 @@ async def copy_file_to_conversation( # Read the file content try: - with open(file_path, "rb") as f: + with file_path.open("rb") as f: file_bytes = f.read() if not file_bytes: @@ -443,7 +442,7 @@ async def copy_file_to_conversation( return False @staticmethod - async def get_team_conversations(context: ConversationContext, share_id: str) -> List[str]: + async def get_team_conversations(context: ConversationContext, share_id: str) -> list[str]: """ Gets all Team conversation IDs for a share. """ @@ -470,7 +469,7 @@ async def get_team_conversations(context: ConversationContext, share_id: str) -> @staticmethod async def create_temporary_context( source_context: ConversationContext, target_conversation_id: str - ) -> Optional[ConversationContext]: + ) -> ConversationContext | None: """ Creates a temporary context for a target conversation. """ @@ -580,7 +579,7 @@ async def synchronize_files_to_team_conversation( ) @staticmethod - async def get_shared_files(context: ConversationContext, share_id: str) -> Dict[str, ShareFile]: + async def get_shared_files(context: ConversationContext, share_id: str) -> dict[str, ShareFile]: """ Gets all shared files for a share with filename as key. """ diff --git a/assistants/knowledge-transfer-assistant/assistant/logging.py b/assistants/knowledge-transfer-assistant/assistant/logging.py index 6a50e1156..2e865454a 100644 --- a/assistants/knowledge-transfer-assistant/assistant/logging.py +++ b/assistants/knowledge-transfer-assistant/assistant/logging.py @@ -7,11 +7,10 @@ import json import logging -import os import platform from datetime import datetime from pathlib import Path -from typing import Any, Dict, Optional +from typing import Any from uuid import UUID from pydantic import BaseModel @@ -121,7 +120,7 @@ def format(self, record) -> str: return json.dumps(simple_record) -def setup_file_logging(log_dir: Optional[str] = None) -> Path: +def setup_file_logging(log_dir: str | None = None) -> Path: """ Set up file logging with JSON formatting. @@ -183,8 +182,8 @@ def setup_file_logging(log_dir: Optional[str] = None) -> Path: try: import stat - os.chmod(log_file, stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IROTH) - os.chmod(line_log_file, stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IROTH) + Path(log_file).chmod(stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IROTH) + Path(line_log_file).chmod(stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IROTH) except Exception as e: logger.warning(f"Could not set log file permissions: {e}") print(f"Permission error: {e}") @@ -199,7 +198,7 @@ def setup_file_logging(log_dir: Optional[str] = None) -> Path: current_file = Path(__file__) share_dir = current_file.parent.parent fallback_dir = share_dir / ".data" / "fallback_logs" - os.makedirs(fallback_dir, exist_ok=True) + Path(fallback_dir).mkdir(parents=True, exist_ok=True) log_file = Path(fallback_dir) / f"log_{timestamp}.json" line_log_file = Path(fallback_dir) / f"log_{timestamp}.log" @@ -222,7 +221,7 @@ def setup_file_logging(log_dir: Optional[str] = None) -> Path: return log_file -def extra_data(data: Any) -> Dict[str, Any]: +def extra_data(data: Any) -> dict[str, Any]: """ Helper function to prepare extra data for log messages. @@ -250,4 +249,4 @@ def extra_data(data: Any) -> Dict[str, Any]: # Make extra_data available for import -__all__ = ["setup_file_logging", "extra_data", "logger"] +__all__ = ["extra_data", "logger", "setup_file_logging"] diff --git a/assistants/knowledge-transfer-assistant/assistant/notifications.py b/assistants/knowledge-transfer-assistant/assistant/notifications.py index 365c20afb..e2e8ab422 100644 --- a/assistants/knowledge-transfer-assistant/assistant/notifications.py +++ b/assistants/knowledge-transfer-assistant/assistant/notifications.py @@ -1,5 +1,3 @@ -from typing import List, Optional - from semantic_workbench_api_model.workbench_model import ( AssistantStateEvent, MessageType, @@ -30,7 +28,7 @@ async def notify_self_and_other( context: ConversationContext, share_id: str, message: str, - other_conversation_id: Optional[str] = None, + other_conversation_id: str | None = None, ) -> None: """ Send text message notification to current conversation and one other. @@ -101,7 +99,7 @@ async def notify_all(context: ConversationContext, share_id: str, message: str) logger.error(f"Failed to notify coordinator conversation: {e}") # Notify all team conversations - for conv_id in knowledge_package.team_conversations.keys(): + for conv_id in knowledge_package.team_conversations: if conv_id != current_id and conv_id != knowledge_package.coordinator_conversation_id: try: client = ConversationClientManager.get_conversation_client(context, conv_id) @@ -117,7 +115,7 @@ async def notify_all(context: ConversationContext, share_id: str, message: str) # State Update Notifications (UI refreshes) @staticmethod - async def notify_state_update(context: ConversationContext, tabs: List[InspectorTab]) -> None: + async def notify_state_update(context: ConversationContext, tabs: list[InspectorTab]) -> None: """Send state update notifications to refresh UI in current conversation only.""" for tab in tabs: state_event = AssistantStateEvent( @@ -128,7 +126,7 @@ async def notify_state_update(context: ConversationContext, tabs: List[Inspector await context.send_conversation_state_event(state_event) @staticmethod - async def notify_all_state_update(context: ConversationContext, share_id: str, tabs: List[InspectorTab]) -> None: + async def notify_all_state_update(context: ConversationContext, share_id: str, tabs: list[InspectorTab]) -> None: """Send state update notifications to refresh UI across all share conversations.""" # Refresh current conversation first @@ -166,7 +164,7 @@ async def notify_all_state_update(context: ConversationContext, share_id: str, t logger.error(f"Failed to refresh coordinator conversation UI: {e}") # Refresh all team conversations - for conv_id in knowledge_package.team_conversations.keys(): + for conv_id in knowledge_package.team_conversations: if conv_id != current_id and conv_id != knowledge_package.coordinator_conversation_id: try: client = ConversationClientManager.get_conversation_client(context, conv_id) diff --git a/assistants/knowledge-transfer-assistant/assistant/respond.py b/assistants/knowledge-transfer-assistant/assistant/respond.py index 6268539ad..23a78b872 100644 --- a/assistants/knowledge-transfer-assistant/assistant/respond.py +++ b/assistants/knowledge-transfer-assistant/assistant/respond.py @@ -1,7 +1,7 @@ import re import time from textwrap import dedent -from typing import Any, Dict, List +from typing import Any, ClassVar import openai_client from assistant_extensions.attachments import AttachmentsExtension @@ -15,7 +15,7 @@ from openai_client import num_tokens_from_messages from openai_client.completion import message_content_from_completion from openai_client.tools import complete_with_tool_calls -from pydantic import Field +from pydantic import ConfigDict, Field from semantic_workbench_api_model.workbench_model import ( ConversationMessage, ConversationParticipantList, @@ -58,18 +58,16 @@ class CoordinatorOutput(BaseModel): Attributes: response: The response from the assistant. next_step_suggestion: Help for the coordinator to understand what to do next. A great way to progressively reveal the knowledge transfer process. - """ + """ # noqa: E501 response: str = Field( - description="The response from the assistant. The response should not duplicate information from the excerpt but may refer to it.", + description="The response from the assistant. The response should not duplicate information from the excerpt but may refer to it.", # noqa: E501 ) next_step_suggestion: str = Field( - description="Help for the coordinator to understand what to do next. A great way to progressively reveal the knowledge transfer process. The audience is the coordinator, so this should be a suggestion for them to take action. Do NOT use this field to communicate what you, the assistant, are going to do next. Assume the coordinator has not yet used this assistant before and make sure to explain concepts such as the knowledge brief and learning outcomes clearly the first time you mention them.", + description="Help for the coordinator to understand what to do next. A great way to progressively reveal the knowledge transfer process. The audience is the coordinator, so this should be a suggestion for them to take action. Do NOT use this field to communicate what you, the assistant, are going to do next. Assume the coordinator has not yet used this assistant before and make sure to explain concepts such as the knowledge brief and learning outcomes clearly the first time you mention them.", # noqa: E501 ) - model_config = { - "extra": "forbid" # This sets additionalProperties=false in the schema - } + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") class TeamOutput(BaseModel): @@ -78,31 +76,29 @@ class TeamOutput(BaseModel): citations: A list of citations from which the response is generated. There should always be at least one citation, but it can be empty if the assistant has no relevant information to cite. excerpt: A verbatim excerpt from one of the cited works that illustrates why this response was given. It should have enough context to get a good idea of what's in that part of the cited work. DO NOT excerpt from CONVERSATION or DIGEST, only from attachments. If there is no relevant excerpt, this will be None. If there is special formatting in the excerpt, remove it as the excerpt will be displayed in quotes in a chat message and should not contain any formatting that would not be supported in a chat message (e.g. markdown). next_step_suggestion: Suggest more areas to explore using content from the knowledge digest to ensure your conversation covers all of the relevant information. - """ + """ # noqa: E501 citations: list[str] = Field( - description="A list of citations from which the response is generated. There should always be at least one citation, but it can be empty if the assistant has no relevant information to cite.", + description="A list of citations from which the response is generated. There should always be at least one citation, but it can be empty if the assistant has no relevant information to cite.", # noqa: E501 ) excerpt: str | None = Field( - description="A verbatim excerpt from one of the cited works that illustrates why this response was given. It should have enough context to get a good idea of what's in that part of the cited work. DO NOT excerpt from CONVERSATION or KNOWLEDGE_DIGEST, only from attachments. If there is no relevant excerpt, this will be None. If there is special formatting in the excerpt, remove it as the excerpt will be displayed in quotes in a chat message and should not contain any formatting that would not be supported in a chat message (e.g. markdown).", + description="A verbatim excerpt from one of the cited works that illustrates why this response was given. It should have enough context to get a good idea of what's in that part of the cited work. DO NOT excerpt from CONVERSATION or KNOWLEDGE_DIGEST, only from attachments. If there is no relevant excerpt, this will be None. If there is special formatting in the excerpt, remove it as the excerpt will be displayed in quotes in a chat message and should not contain any formatting that would not be supported in a chat message (e.g. markdown).", # noqa: E501 ) response: str = Field( - description="The response from the assistant. The response should not duplicate information from the excerpt but may refer to it.", + description="The response from the assistant. The response should not duplicate information from the excerpt but may refer to it.", # noqa: E501 ) next_step_suggestion: str = Field( - description="Suggest more areas to explore using content from the knowledge digest to ensure your conversation covers all of the relevant information. For example: 'Would you like to explore ... next?'.", + description="Suggest more areas to explore using content from the knowledge digest to ensure your conversation covers all of the relevant information. For example: 'Would you like to explore ... next?'.", # noqa: E501 ) - model_config = { - "extra": "forbid" # This sets additionalProperties=false in the schema - } + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") async def respond_to_conversation( context: ConversationContext, new_message: ConversationMessage, attachments_extension: AttachmentsExtension, - metadata: Dict[str, Any], + metadata: dict[str, Any], ) -> None: """ Respond to a conversation message. @@ -168,7 +164,7 @@ async def respond_to_conversation( context_strategy=ContextStrategy.MULTI, ) if role == ConversationRole.TEAM: - prompt.output_format = "Respond as JSON with your response in the `response` field and all citations in the `citations` field. In the `next_step_suggestion` field, suggest more areas to explore using content from the assistant whiteboard to ensure your conversation covers all of the relevant information." + prompt.output_format = "Respond as JSON with your response in the `response` field and all citations in the `citations` field. In the `next_step_suggestion` field, suggest more areas to explore using content from the assistant whiteboard to ensure your conversation covers all of the relevant information." # noqa: E501 ### ### Context @@ -210,7 +206,7 @@ async def respond_to_conversation( if role == ConversationRole.COORDINATOR and share and share.audience: audience_context = share.audience if not share.is_intended_to_accomplish_outcomes: - audience_context += "\n\n**Note:** This knowledge package is intended for general exploration, not specific learning outcomes." + audience_context += "\n\n**Note:** This knowledge package is intended for general exploration, not specific learning outcomes." # noqa: E501 prompt.contexts.append( Context( @@ -238,7 +234,7 @@ async def respond_to_conversation( achieved_personal, total_personal = LearningObjectivesManager.get_completion_for_conversation( share, conversation_id ) - progress_pct = int((achieved_personal / total_personal * 100)) if total_personal > 0 else 0 + progress_pct = int(achieved_personal / total_personal * 100) if total_personal > 0 else 0 learning_objectives_text += ( f"My Progress: {achieved_personal}/{total_personal} outcomes achieved ({progress_pct}%)\n\n" ) @@ -251,7 +247,7 @@ async def respond_to_conversation( # Show if achieved by any team member achieved_by_any = any( LearningObjectivesManager.is_outcome_achieved_by_conversation(share, criterion.id, conv_id) - for conv_id in share.team_conversations.keys() + for conv_id in share.team_conversations ) check = "✅" if achieved_by_any else "⬜" else: @@ -360,7 +356,7 @@ async def respond_to_conversation( if coordinator_conversation: # Limit messages to the configured max token count. total_coordinator_conversation_tokens = 0 - selected_coordinator_conversation_messages: List[CoordinatorConversationMessage] = [] + selected_coordinator_conversation_messages: list[CoordinatorConversationMessage] = [] for msg in reversed(coordinator_conversation.messages): tokens = openai_client.num_tokens_from_string(msg.model_dump_json(), model=model) if ( @@ -373,7 +369,7 @@ async def respond_to_conversation( # Create a new coordinator conversation system message with the selected messages. class CoordinatorMessageList(BaseModel): - messages: List[CoordinatorConversationMessage] = Field(default_factory=list) + messages: list[CoordinatorConversationMessage] = Field(default_factory=list) selected_coordinator_conversation_messages.reverse() coordinator_message_list = CoordinatorMessageList(messages=selected_coordinator_conversation_messages) @@ -400,7 +396,7 @@ class CoordinatorMessageList(BaseModel): # in the proper flow of the conversation rather than as . # Generate the attachment messages. - attachment_messages: List[ChatCompletionMessageParam] = openai_client.convert_from_completion_messages( + attachment_messages: list[ChatCompletionMessageParam] = openai_client.convert_from_completion_messages( await attachments_extension.get_completion_messages_for_attachments( context, config=config.attachments_config, @@ -486,7 +482,7 @@ class CoordinatorMessageList(BaseModel): ) if history_token_budget.fits(current_message_tokens): - history_messages = [current_message] + history_messages + history_messages = [current_message, *history_messages] history_token_budget.add(current_message_tokens) else: under_budget = False diff --git a/assistants/knowledge-transfer-assistant/assistant/storage.py b/assistants/knowledge-transfer-assistant/assistant/storage.py index 8b553dd02..2e8eea99d 100644 --- a/assistants/knowledge-transfer-assistant/assistant/storage.py +++ b/assistants/knowledge-transfer-assistant/assistant/storage.py @@ -6,7 +6,7 @@ import pathlib from datetime import datetime -from typing import Any, Dict, List, Optional +from typing import Any from semantic_workbench_assistant import settings from semantic_workbench_assistant.assistant_app import ConversationContext @@ -89,7 +89,7 @@ class ShareStorage: """Unified storage operations for knowledge transfer share data.""" @staticmethod - def read_share(share_id: str) -> Optional[KnowledgePackage]: + def read_share(share_id: str) -> KnowledgePackage | None: """Reads the complete KnowledgePackage data.""" path = ShareStorageManager.get_share_path(share_id) return read_model(path, KnowledgePackage) @@ -102,7 +102,7 @@ def write_share(share_id: str, package: KnowledgePackage) -> pathlib.Path: return path @staticmethod - def read_knowledge_brief(share_id: str) -> Optional[KnowledgeBrief]: + def read_knowledge_brief(share_id: str) -> KnowledgeBrief | None: """Reads the knowledge brief from the main share data.""" package = ShareStorage.read_share(share_id) return package.brief if package else None @@ -124,7 +124,7 @@ def write_knowledge_brief(share_id: str, brief: KnowledgeBrief) -> pathlib.Path: return ShareStorage.write_share(share_id, package) @staticmethod - def read_share_log(share_id: str) -> Optional[KnowledgePackageLog]: + def read_share_log(share_id: str) -> KnowledgePackageLog | None: path = ShareStorageManager.get_share_log_path(share_id) return read_model(path, KnowledgePackageLog) @@ -135,14 +135,14 @@ def write_share_log(share_id: str, log: KnowledgePackageLog) -> pathlib.Path: return path @staticmethod - def read_knowledge_digest(share_id: str) -> Optional[KnowledgeDigest]: + def read_knowledge_digest(share_id: str) -> KnowledgeDigest | None: package = ShareStorage.read_share(share_id) return package.digest if package else None @staticmethod def read_coordinator_conversation( share_id: str, - ) -> Optional[CoordinatorConversationMessages]: + ) -> CoordinatorConversationMessages | None: path = ShareStorageManager.get_coordinator_conversation_path(share_id) return read_model(path, CoordinatorConversationMessages) @@ -159,7 +159,7 @@ def append_coordinator_message( content: str, sender_name: str, is_assistant: bool = False, - timestamp: Optional[datetime] = None, + timestamp: datetime | None = None, ) -> None: """ Appends a message to the Coordinator conversation storage. @@ -209,7 +209,7 @@ def write_knowledge_digest(share_id: str, digest: KnowledgeDigest) -> pathlib.Pa return ShareStorage.write_share(share_id, package) @staticmethod - def read_information_request(share_id: str, request_id: str) -> Optional[InformationRequest]: + def read_information_request(share_id: str, request_id: str) -> InformationRequest | None: """Reads an information request from the main share data.""" package = ShareStorage.read_share(share_id) if not package or not package.requests: @@ -255,7 +255,7 @@ def write_information_request(share_id: str, request: InformationRequest) -> pat return ShareStorage.write_share(share_id, package) @staticmethod - def get_all_information_requests(share_id: str) -> List[InformationRequest]: + def get_all_information_requests(share_id: str) -> list[InformationRequest]: """Gets all information requests from the main share data.""" package = ShareStorage.read_share(share_id) if not package: @@ -272,8 +272,8 @@ async def log_share_event( share_id: str, entry_type: str, message: str, - related_entity_id: Optional[str] = None, - metadata: Optional[Dict[str, Any]] = None, + related_entity_id: str | None = None, + metadata: dict[str, Any] | None = None, ) -> bool: """ Logs an event to the knowledge transfer log. diff --git a/assistants/knowledge-transfer-assistant/assistant/string_utils.py b/assistants/knowledge-transfer-assistant/assistant/string_utils.py index a9bcb7892..08ca97a82 100644 --- a/assistants/knowledge-transfer-assistant/assistant/string_utils.py +++ b/assistants/knowledge-transfer-assistant/assistant/string_utils.py @@ -1,6 +1,6 @@ from dataclasses import dataclass, field from enum import Enum -from typing import Any, List +from typing import Any from liquid import Template from openai.types.chat import ChatCompletionMessageParam @@ -91,7 +91,7 @@ class Prompt: output_format: str | None = None reasoning_steps: str | None = None examples: str | None = None - contexts: List[Context] = field(default_factory=list) + contexts: list[Context] = field(default_factory=list) context_strategy: ContextStrategy = ContextStrategy.SINGLE final_instructions: str | None = None diff --git a/assistants/knowledge-transfer-assistant/assistant/text_includes/to_do.md b/assistants/knowledge-transfer-assistant/assistant/text_includes/to_do.md new file mode 100644 index 000000000..49e5a84d4 --- /dev/null +++ b/assistants/knowledge-transfer-assistant/assistant/text_includes/to_do.md @@ -0,0 +1,175 @@ +Use this tool to create and manage a structured task list for your current coding session. This helps you track progress, organize complex tasks, and demonstrate thoroughness to the user. +It also helps the user understand the progress of the task and overall progress of their requests. + +## When to Use This Tool +Use this tool proactively in these scenarios: + +1. Complex multi-step tasks - When a task requires 3 or more distinct steps or actions +2. Non-trivial and complex tasks - Tasks that require careful planning or multiple operations +3. User explicitly requests todo list - When the user directly asks you to use the todo list +4. User provides multiple tasks - When users provide a list of things to be done (numbered or comma-separated) +5. After receiving new instructions - Immediately capture user requirements as todos +6. When you start working on a task - Mark it as in_progress BEFORE beginning work. Ideally you should only have one todo as in_progress at a time +7. After completing a task - Mark it as completed and add any new follow-up tasks discovered during implementation + +## When NOT to Use This Tool + +Skip using this tool when: +1. There is only a single, straightforward task +2. The task is trivial and tracking it provides no organizational benefit +3. The task can be completed in less than 3 trivial steps +4. The task is purely conversational or informational + +NOTE that you should not use this tool if there is only one trivial task to do. In this case you are better off just doing the task directly. + +## Examples of When to Use the Todo List + + +User: I want to add a dark mode toggle to the application settings. Make sure you run the tests and build when you're done! +Assistant: I'll help add a dark mode toggle to your application settings. Let me create a todo list to track this implementation. +*Creates todo list with the following items:* +1. Create dark mode toggle component in Settings page +2. Add dark mode state management (context/store) +3. Implement CSS-in-JS styles for dark theme +4. Update existing components to support theme switching +5. Run tests and build process, addressing any failures or errors that occur +*Begins working on the first task* + + +The assistant used the todo list because: +1. Adding dark mode is a multi-step feature requiring UI, state management, and styling changes +2. The user explicitly requested tests and build be run afterward +3. The assistant inferred that tests and build need to pass by adding "Ensure tests and build succeed" as the final task + + + + +User: Help me rename the function getCwd to getCurrentWorkingDirectory across my project +Assistant: Let me first search through your codebase to find all occurrences of 'getCwd'. +*Uses grep or search tools to locate all instances of getCwd in the codebase* +Assistant: I've found 15 instances of 'getCwd' across 8 different files. Let me create a todo list to track these changes. +*Creates todo list with specific items for each file that needs updating* + + +The assistant used the todo list because: +1. First, the assistant searched to understand the scope of the task +2. Upon finding multiple occurrences across different files, it determined this was a complex task with multiple steps +3. The todo list helps ensure every instance is tracked and updated systematically +4. This approach prevents missing any occurrences and maintains code consistency + + + + + +User: I need to implement these features for my e-commerce site: user registration, product catalog, shopping cart, and checkout flow. +Assistant: I'll help implement these features. First, let's add all the features to the todo list. +*Creates a todo list breaking down each feature into specific tasks based on the project architecture* +Assistant: Let's start with user registration. This will involve creating database models, API endpoints, and frontend forms that integrate with your existing authentication system. + + +The assistant used the todo list because: +1. The user provided multiple complex features to implement in a comma separated list +2. The todo list helps organize these large features into manageable tasks +3. This approach allows for tracking progress across the entire implementation + + + + +User: Can you help optimize my React application? It's rendering slowly and has performance issues. +Assistant: I'll help optimize your React application. First, let me examine your codebase to identify potential performance bottlenecks. +*Reviews component structure, render patterns, state management, and data fetching* +Assistant: After analyzing your codebase, I've identified several performance issues. Let me create a todo list to track our optimization efforts. +*Creates todo list with items like: 1) Implement memoization for expensive calculations in ProductList, 2) Add virtualization for long lists in Dashboard, 3) Optimize image loading in Gallery component, 4) Fix state update loops in ShoppingCart, 5) Review bundle size and implement code splitting* +Let's start by implementing memoization for the expensive calculations in your ProductList component. + + +The assistant used the todo list because: +1. First, the assistant examined the codebase to identify specific performance issues +2. Based on this analysis, it identified multiple optimization opportunities +3. Performance optimization is a non-trivial task requiring multiple steps +4. The todo list helps methodically track improvements across different components +5. This systematic approach ensures all performance bottlenecks are addressed + + + +## Examples of When NOT to Use the Todo List + + +User: How do I print 'Hello World' in Python? +Assistant: In Python, you can print "Hello World" with this simple code: + +python +print("Hello World") + +This will output the text "Hello World" to the console when executed. + + +The assistant did not use the todo list because this is a single, trivial task that can be completed in one step. There's no need to track multiple tasks or steps for such a straightforward request. + + + + +User: What does the git status command do? +Assistant: The git status command shows the current state of your working directory and staging area. It displays which changes have been staged, which haven't, and which files aren't being tracked by Git. This is a fundamental command for understanding the current state of your repository before making commits. + + +The assistant did not use the todo list because this is an informational request with no actual coding task to complete. The user is simply asking for an explanation, not for the assistant to perform multiple steps or tasks. + + + + +User: Can you add a comment to the calculateTotal function to explain what it does? +Assistant: Sure, let me add a comment to the calculateTotal function to explain what it does. +* Uses the ${YO} tool to add a comment to the calculateTotal function * + + +The assistant did not use the todo list because this is a single, straightforward task confined to one location in the code. Adding a comment doesn't require tracking multiple steps or systematic organization. + + + + +User: Run npm install for me and tell me what happens. +Assistant: I'll run the npm install command for you. + +*Executes: npm install* + +The command completed successfully. Here's the output: +[Output of npm install command] + +All dependencies have been installed according to your package.json file. + + +The assistant did not use the todo list because this is a single command execution with immediate results. There are no multiple steps to track or organize, making the todo list unnecessary for this straightforward task. + + + +## Task States and Management + +1. **Task States**: Use these states to track progress: + - pending: Task not yet started + - in_progress: Currently working on (limit to ONE task at a time) + - completed: Task finished successfully + +2. **Task Management**: + - Update task status in real-time as you work + - Mark tasks complete IMMEDIATELY after finishing (don't batch completions) + - Only have ONE task in_progress at any time + - Complete current tasks before starting new ones + - Remove tasks that are no longer relevant from the list entirely + +3. **Task Completion Requirements**: + - ONLY mark a task as completed when you have FULLY accomplished it + - If you encounter errors, blockers, or cannot finish, keep the task as in_progress + - When blocked, create a new task describing what needs to be resolved + - Never mark a task as completed if: + - Tests are failing + - Implementation is partial + - You encountered unresolved errors + - You couldn't find necessary files or dependencies + +4. **Task Breakdown**: + - Create specific, actionable items + - Break complex tasks into smaller, manageable steps + - Use clear, descriptive task names + +When in doubt, use this tool. Being proactive with task management demonstrates attentiveness and ensures you complete all requirements successfully. \ No newline at end of file diff --git a/assistants/knowledge-transfer-assistant/assistant/tools/__init__.py b/assistants/knowledge-transfer-assistant/assistant/tools/__init__.py index 398a58c15..35d9832ee 100644 --- a/assistants/knowledge-transfer-assistant/assistant/tools/__init__.py +++ b/assistants/knowledge-transfer-assistant/assistant/tools/__init__.py @@ -8,7 +8,8 @@ from openai_client.tools import ToolFunctions from semantic_workbench_assistant.assistant_app import ConversationContext -from ..data import ConversationRole +from assistant.data import ConversationRole + from .information_requests import InformationRequestTools from .learning_objectives import LearningObjectiveTools from .learning_outcomes import LearningOutcomeTools diff --git a/assistants/knowledge-transfer-assistant/assistant/tools/information_requests.py b/assistants/knowledge-transfer-assistant/assistant/tools/information_requests.py index 4443ce8ac..b5d50376b 100644 --- a/assistants/knowledge-transfer-assistant/assistant/tools/information_requests.py +++ b/assistants/knowledge-transfer-assistant/assistant/tools/information_requests.py @@ -45,7 +45,7 @@ async def create_information_request( Returns: A message indicating success or failure - """ + """ # noqa: E501 priority_map = { "low": RequestPriority.LOW, @@ -65,17 +65,13 @@ async def create_information_request( return f"Information request '{title}' created successfully. The Coordinator has been notified." except Exception as e: logger.exception(f"Failed to create information request: {e}") - return f"Failed to create information request: {str(e)}" + return f"Failed to create information request: {e!s}" async def resolve_information_request(self, request_id: str, resolution: str) -> str: """ Resolve an information request when you have the needed information to address it. Only use for active information requests. If there are no active information requests, this should never be called. - WHEN TO USE: - - When you have information that directly answers a team member's request - - When the user has supplied information that resolves a pending request - - When you've gathered enough details to unblock a team member - - When a request is no longer relevant and should be closed with explanation + WHEN TO USE: - When you have information that directly answers a team member's request - When the user has supplied information that resolves a pending request - When you've gathered enough details to unblock a team member - When a request is no longer relevant and should be closed with explanation Args: request_id: IMPORTANT! Use the exact Request ID (looks like "012345-abcd-67890"), NOT the title of the request @@ -83,7 +79,7 @@ async def resolve_information_request(self, request_id: str, resolution: str) -> Returns: A message indicating success or failure - """ + """ # noqa: E501 try: information_request = await InformationRequestManager.resolve_information_request( context=self.context, request_id=request_id, resolution=resolution @@ -91,7 +87,7 @@ async def resolve_information_request(self, request_id: str, resolution: str) -> return f"Information request '{information_request.title}' has been resolved." except Exception as e: logger.exception(f"Failed to resolve information request: {e}") - return f"ERROR: Could not resolve information request with ID '{request_id}': {str(e)}" + return f"ERROR: Could not resolve information request with ID '{request_id}': {e!s}" async def delete_information_request(self, request_id: str) -> str: """ @@ -111,4 +107,4 @@ async def delete_information_request(self, request_id: str) -> str: return message except Exception as e: logger.exception(f"Failed to delete information request: {e}") - return f"Failed to delete information request: {str(e)}" + return f"Failed to delete information request: {e!s}" diff --git a/assistants/knowledge-transfer-assistant/assistant/tools/learning_objectives.py b/assistants/knowledge-transfer-assistant/assistant/tools/learning_objectives.py index a578e5652..1cc90cf68 100644 --- a/assistants/knowledge-transfer-assistant/assistant/tools/learning_objectives.py +++ b/assistants/knowledge-transfer-assistant/assistant/tools/learning_objectives.py @@ -4,8 +4,6 @@ Tools for creating, updating, and managing learning objectives and outcomes. """ -from typing import List - from assistant.domain import LearningObjectivesManager from assistant.logging import logger @@ -15,7 +13,7 @@ class LearningObjectiveTools(ToolsBase): """Tools for managing learning objectives and outcomes.""" - async def add_learning_objective(self, objective_name: str, description: str, learning_outcomes: List[str]) -> str: + async def add_learning_objective(self, objective_name: str, description: str, learning_outcomes: list[str]) -> str: """ Add a learning objective with measurable learning outcomes. @@ -76,7 +74,7 @@ async def update_learning_objective( return message except Exception as e: logger.exception(f"Failed to update learning objective: {e}") - return f"Failed to update learning objective: {str(e)}" + return f"Failed to update learning objective: {e!s}" async def delete_learning_objective(self, objective_id: str) -> str: """ @@ -104,4 +102,4 @@ async def delete_learning_objective(self, objective_id: str) -> str: return message except Exception as e: logger.exception(f"Failed to delete learning objective: {e}") - return f"Failed to delete learning objective: {str(e)}" + return f"Failed to delete learning objective: {e!s}" diff --git a/assistants/knowledge-transfer-assistant/assistant/tools/learning_outcomes.py b/assistants/knowledge-transfer-assistant/assistant/tools/learning_outcomes.py index 510380d24..30bf065ad 100644 --- a/assistants/knowledge-transfer-assistant/assistant/tools/learning_outcomes.py +++ b/assistants/knowledge-transfer-assistant/assistant/tools/learning_outcomes.py @@ -39,7 +39,7 @@ async def add_learning_outcome(self, objective_id: str, outcome_description: str return message except Exception as e: logger.exception(f"Failed to add learning outcome: {e}") - return f"Failed to add learning outcome: {str(e)}" + return f"Failed to add learning outcome: {e!s}" async def update_learning_outcome(self, outcome_id: str, new_description: str) -> str: """ @@ -67,7 +67,7 @@ async def update_learning_outcome(self, outcome_id: str, new_description: str) - return message except Exception as e: logger.exception(f"Failed to update learning outcome: {e}") - return f"Failed to update learning outcome: {str(e)}" + return f"Failed to update learning outcome: {e!s}" async def delete_learning_outcome(self, outcome_id: str) -> str: """ @@ -95,4 +95,4 @@ async def delete_learning_outcome(self, outcome_id: str) -> str: return message except Exception as e: logger.exception(f"Failed to delete learning outcome: {e}") - return f"Failed to delete learning outcome: {str(e)}" + return f"Failed to delete learning outcome: {e!s}" diff --git a/assistants/knowledge-transfer-assistant/assistant/tools/progress_tracking.py b/assistants/knowledge-transfer-assistant/assistant/tools/progress_tracking.py index 555abbbfd..3373c7c77 100644 --- a/assistants/knowledge-transfer-assistant/assistant/tools/progress_tracking.py +++ b/assistants/knowledge-transfer-assistant/assistant/tools/progress_tracking.py @@ -4,7 +4,7 @@ Tools for tracking learning progress and completing knowledge transfer activities. """ -from datetime import datetime, timezone +from datetime import UTC, datetime from semantic_workbench_api_model.workbench_model import ( MessageType, @@ -37,8 +37,7 @@ async def mark_learning_outcome_achieved(self, objective_id: str, outcome_id: st - When a milestone for one of the learning objectives has been achieved - When tracking progress and updating the transfer status - Each completed outcome moves the knowledge transfer closer to completion. When all outcomes - are achieved, the transfer can be marked as complete. + Each completed outcome moves the knowledge transfer closer to completion. When all outcomes are achieved, the transfer can be marked as complete. Args: objective_id: The UUID of the learning objective @@ -46,7 +45,7 @@ async def mark_learning_outcome_achieved(self, objective_id: str, outcome_id: st Returns: A message indicating success or failure - """ + """ # noqa: E501 try: share = await ShareManager.get_share(self.context) brief = share.brief @@ -84,18 +83,18 @@ async def mark_learning_outcome_achieved(self, objective_id: str, outcome_id: st # Create achievement record achievement = LearningOutcomeAchievement( - outcome_id=outcome.id, achieved=True, achieved_at=datetime.now(timezone.utc) + outcome_id=outcome.id, achieved=True, achieved_at=datetime.now(UTC) ) # Add achievement to team conversation's achievements share.team_conversations[conversation_id].outcome_achievements.append(achievement) # Update team conversation's last active timestamp - share.team_conversations[conversation_id].last_active_at = datetime.now(timezone.utc) + share.team_conversations[conversation_id].last_active_at = datetime.now(UTC) # Update metadata current_user_id = await get_current_user_id(self.context) - share.updated_at = datetime.now(timezone.utc) + share.updated_at = datetime.now(UTC) share.updated_by = current_user_id share.version += 1 @@ -118,7 +117,7 @@ async def mark_learning_outcome_achieved(self, objective_id: str, outcome_id: st await Notifications.notify_all( self.context, share.share_id, - f"Learning outcome '{outcome.description}' for objective '{objective.name}' has been marked as achieved.", + f"Learning outcome '{outcome.description}' for objective '{objective.name}' has been achieved.", ) await Notifications.notify_all_state_update( self.context, @@ -131,14 +130,14 @@ async def mark_learning_outcome_achieved(self, objective_id: str, outcome_id: st if TransferManager._is_transfer_complete(share): await self.context.send_messages( NewConversationMessage( - content="🎉 All learning outcomes have been achieved! The knowledge transfer has been automatically marked as complete.", + content="🎉 All learning outcomes have been achieved! The knowledge transfer has been automatically marked as complete.", # noqa: E501 message_type=MessageType.notice, ) ) await self.context.send_messages( NewConversationMessage( - content=f"Learning outcome '{outcome.description}' for objective '{objective.name}' has been marked as achieved.", + content=f"Learning outcome '{outcome.description}' for objective '{objective.name}' has been marked as achieved.", # noqa: E501 message_type=MessageType.notice, ) ) @@ -159,13 +158,11 @@ async def report_transfer_completion(self) -> str: - When the learning objectives have been fully achieved - When it's time to formally conclude the knowledge transfer - This is a significant milestone that indicates the knowledge transfer has successfully - achieved all its learning objectives. Before using this tool, verify that all learning outcomes - have been marked as achieved. + This is a significant milestone that indicates the knowledge transfer has successfully achieved all its learning objectives. Before using this tool, verify that all learning outcomes have been marked as achieved. Returns: A message indicating success or failure - """ + """ # noqa: E501 try: share = await ShareManager.get_share(self.context) @@ -178,7 +175,7 @@ async def report_transfer_completion(self) -> str: ) current_user_id = await get_current_user_id(self.context) - share.updated_at = datetime.now(timezone.utc) + share.updated_at = datetime.now(UTC) share.updated_by = current_user_id share.version += 1 await ShareManager.set_share(self.context, share) @@ -195,13 +192,13 @@ async def report_transfer_completion(self) -> str: await Notifications.notify_all( self.context, share.share_id, - "🎉 **Knowledge Transfer Complete**: Team has reported that all learning objectives have been achieved. The knowledge transfer is now complete.", + "🎉 **Knowledge Transfer Complete**: Team has reported that all learning objectives have been achieved. The knowledge transfer is now complete.", # noqa: E501 ) await Notifications.notify_all_state_update(self.context, share.share_id, [InspectorTab.BRIEF]) await self.context.send_messages( NewConversationMessage( - content="🎉 **Knowledge Transfer Complete**: All learning objectives have been achieved and the knowledge transfer is now complete. The Coordinator has been notified.", + content="🎉 **Knowledge Transfer Complete**: All learning objectives have been achieved and the knowledge transfer is now complete. The Coordinator has been notified.", # noqa: E501 message_type=MessageType.chat, ) ) diff --git a/assistants/knowledge-transfer-assistant/assistant/tools/share_setup.py b/assistants/knowledge-transfer-assistant/assistant/tools/share_setup.py index db0ac7290..cf97a28cc 100644 --- a/assistants/knowledge-transfer-assistant/assistant/tools/share_setup.py +++ b/assistants/knowledge-transfer-assistant/assistant/tools/share_setup.py @@ -4,7 +4,7 @@ Tools for initializing and configuring knowledge packages. """ -from datetime import datetime, timezone +from datetime import UTC, datetime from assistant.domain.audience_manager import AudienceManager from assistant.domain.knowledge_brief_manager import KnowledgeBriefManager @@ -33,44 +33,44 @@ async def update_audience(self, audience_description: str) -> str: ) return "Audience updated successfully" except Exception as e: - return f"Failed to update audience: {str(e)}" + return f"Failed to update audience: {e!s}" async def set_knowledge_organized(self, is_organized: bool) -> str: """ Mark that all necessary knowledge has been captured and organized for transfer. - This indicates that the coordinator has uploaded files, shared information through conversation, - and confirmed that all necessary knowledge for the transfer has been captured. This is required - before the knowledge package can move to the "Ready for Transfer" state. + This indicates that the coordinator has uploaded files, shared information through conversation, and confirmed that all necessary knowledge for the transfer has been captured. This is required before the knowledge package can move to the "Ready for Transfer" state. Args: - is_organized: True if knowledge is organized and ready, False to mark as currently unorganized + is_organized: True if knowledge is organized and ready, False to + mark as currently unorganized Returns: A message indicating success or failure - """ + """ # noqa: E501 try: share = await ShareManager.get_share(self.context) share.knowledge_organized = is_organized - share.updated_at = datetime.now(timezone.utc) + share.updated_at = datetime.now(UTC) await ShareManager.set_share(self.context, share) if is_organized: - guidance = "Knowledge is now marked as organized and ready. You can proceed to create your brief and set up learning objectives." + guidance = "Knowledge is now marked as organized and ready. You can proceed to create your brief and set up learning objectives." # noqa: E501 else: - guidance = "Knowledge is now marked as incomplete. Continue organizing your knowledge by uploading files or describing it in conversation." + guidance = "Knowledge is now marked as incomplete. Continue organizing your knowledge by uploading files or describing it in conversation." # noqa: E501 return f"Knowledge organization status updated successfully. {guidance}" except Exception as e: - return f"Failed to update knowledge organization status: {str(e)}" + return f"Failed to update knowledge organization status: {e!s}" async def update_brief(self, title: str, description: str) -> str: """ - Update a brief with a title and description. The brief should avoid filler words and unnecessary content. + Update a brief with a title and description. The brief should avoid + filler words and unnecessary content. Args: - title: The title of the brief - description: A description of the knowledge share to be given to recipients as context. + title: The title of the brief description: A description of the + knowledge share to be given to recipients as context. Returns: A message indicating success or failure @@ -83,19 +83,19 @@ async def update_brief(self, title: str, description: str) -> str: ) return "Brief updated successfully." except Exception as e: - return f"Failed to update brief: {str(e)}" + return f"Failed to update brief: {e!s}" async def set_learning_intention(self, is_for_specific_outcomes: bool) -> str: """ Set or update whether this knowledge package is intended for specific learning outcomes or general exploration. If intended for learning and an objective or outcome was provided, you should run the add_learning_objective function next (don't wait). Args: - is_for_specific_outcomes: True if this package should have learning objectives and outcomes, - False if this is for general exploration + is_for_specific_outcomes: True if this package should have learning + objectives and outcomes, False if this is for general exploration Returns: A message indicating success or failure - """ + """ # noqa: E501 try: share = await ShareManager.get_share(self.context) share.is_intended_to_accomplish_outcomes = is_for_specific_outcomes @@ -104,11 +104,11 @@ async def set_learning_intention(self, is_for_specific_outcomes: bool) -> str: # Provide appropriate guidance based on the choice if is_for_specific_outcomes: - guidance = "This knowledge package is now set for specific learning outcomes. You'll need to add learning objectives with measurable outcomes." + guidance = "This knowledge package is now set for specific learning outcomes. You'll need to add learning objectives with measurable outcomes." # noqa: E501 else: - guidance = "This knowledge package is now set for general exploration. No specific learning objectives are required." + guidance = "This knowledge package is now set for general exploration. No specific learning objectives are required." # noqa: E501 return f"Learning intention updated successfully. {guidance}" except Exception as e: - return f"Failed to update learning intention: {str(e)}" + return f"Failed to update learning intention: {e!s}" diff --git a/assistants/knowledge-transfer-assistant/assistant/tools/system_reminders.py b/assistants/knowledge-transfer-assistant/assistant/tools/system_reminders.py new file mode 100644 index 000000000..adc6fd92c --- /dev/null +++ b/assistants/knowledge-transfer-assistant/assistant/tools/system_reminders.py @@ -0,0 +1 @@ +kick_todo_usage = "The TodoWrite tool hasn't been used recently. If you're working on tasks that would benefit from tracking progress, consider using the TodoWrite tool to track progress. Only use it if it's relevant to the current work." # noqa: E501 diff --git a/assistants/knowledge-transfer-assistant/assistant/tools/todo.py b/assistants/knowledge-transfer-assistant/assistant/tools/todo.py new file mode 100644 index 000000000..672b9fe2b --- /dev/null +++ b/assistants/knowledge-transfer-assistant/assistant/tools/todo.py @@ -0,0 +1,61 @@ +import uuid +from enum import Enum + +from pydantic import BaseModel, Field + + +class ToDoItemStatus(Enum): + """ + Enum for the status of a to-do item. + """ + + PENDING = "pending" + IN_PROGRESS = "in_progress" + COMPLETED = "completed" + + +class ToDoItemPriority(Enum): + """ + Enum for the priority of a to-do item. + """ + + HIGH = "high" + MEDIUM = "medium" + LOW = "low" + + +class ToDoItem(BaseModel): + """ + A class to represent a single to-do item. + """ + + id: str = Field(default_factory=lambda: str(uuid.uuid4())) # Unique identifier + content: str + priority: str = "medium" # Default priority is 'medium' + status: str = "pending" # Default status is 'pending' + + +class ToDoList(BaseModel): + """ + A class to represent a to-do list. + """ + + items: list[ToDoItem] = Field(default_factory=list) + + def add(self, todo: ToDoItem) -> None: + """ + Add a new to-do item. + """ + self.items.append(todo) + + def remove(self, id: str) -> None: + """ + Remove a to-do item. + """ + self.items = [item for item in self.items if item.id != id] + + def list(self) -> list[ToDoItem]: + """ + List all to-do items. + """ + return self.items diff --git a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/__init__.py b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/__init__.py index dca47fda2..b3a486502 100644 --- a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/__init__.py +++ b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/__init__.py @@ -10,4 +10,4 @@ from .learning import LearningInspector from .sharing import SharingInspector -__all__ = ["BriefInspector", "LearningInspector", "SharingInspector", "DebugInspector"] +__all__ = ["BriefInspector", "DebugInspector", "LearningInspector", "SharingInspector"] diff --git a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/brief.py b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/brief.py index 536c27ebb..b75a3cb12 100644 --- a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/brief.py +++ b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/brief.py @@ -2,7 +2,7 @@ Brief inspector for knowledge transfer status and brief information. """ -from typing import Any, List +from typing import Any from semantic_workbench_assistant.assistant_app import ( AssistantConversationInspectorStateDataModel, @@ -15,7 +15,7 @@ from .common import get_stage_label # Default instructional text to show when no brief has been created -DEFAULT_BRIEF_INSTRUCTION = "_This knowledge brief is displayed in the side panel of all of your team members' conversations, too. Before you share links to your team, ask your assistant to update the brief with whatever details you'd like here. What will help your teammates get off to a good start as they explore the knowledge you are sharing?_" +DEFAULT_BRIEF_INSTRUCTION = "_This knowledge brief is displayed in the side panel of all of your team members' conversations, too. Before you share links to your team, ask your assistant to update the brief with whatever details you'd like here. What will help your teammates get off to a good start as they explore the knowledge you are sharing?_" # noqa: E501 class BriefInspector: @@ -62,7 +62,7 @@ async def _format_coordinator_brief( ) -> str: """Format brief information for coordinator.""" - lines: List[str] = [] + lines: list[str] = [] lines.append("**Role:** Coordinator") @@ -98,7 +98,7 @@ async def _format_coordinator_brief( async def _format_team_brief(self, share_id: str, brief: Any, share_info: Any, context: ConversationContext) -> str: """Format brief information for team members.""" - lines: List[str] = [] + lines: list[str] = [] lines.append("**Role:** Team") diff --git a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/debug.py b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/debug.py index cee1fb7b5..88e795fae 100644 --- a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/debug.py +++ b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/debug.py @@ -2,8 +2,6 @@ Debug inspector for internal assistant state and knowledge digest. """ -from typing import List - from semantic_workbench_assistant.assistant_app import ( AssistantConversationInspectorStateDataModel, ConversationContext, @@ -45,7 +43,7 @@ async def get(self, context: ConversationContext) -> AssistantConversationInspec async def _format_debug_info(self, share_id: str, context: ConversationContext) -> str: """Format debug information including knowledge digest.""" - lines: List[str] = [] + lines: list[str] = [] lines.append("## Debug Information") lines.append("") @@ -85,7 +83,7 @@ async def _format_debug_info(self, share_id: str, context: ConversationContext) except Exception as e: lines.append("## Knowledge Digest") lines.append("") - lines.append(f"**Error retrieving knowledge digest:** {str(e)}") + lines.append(f"**Error retrieving knowledge digest:** {e!s}") lines.append("") # Add share metadata for debugging @@ -113,7 +111,7 @@ async def _format_debug_info(self, share_id: str, context: ConversationContext) except Exception as e: lines.append("## Share Metadata") lines.append("") - lines.append(f"**Error retrieving share metadata:** {str(e)}") + lines.append(f"**Error retrieving share metadata:** {e!s}") lines.append("") return "\n".join(lines) diff --git a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/learning.py b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/learning.py index bbaf16b88..8782cbbc1 100644 --- a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/learning.py +++ b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/learning.py @@ -2,7 +2,7 @@ Learning inspector for learning objectives and progress tracking. """ -from typing import Any, List +from typing import Any from semantic_workbench_assistant.assistant_app import ( AssistantConversationInspectorStateDataModel, @@ -54,13 +54,13 @@ async def get(self, context: ConversationContext) -> AssistantConversationInspec async def _format_coordinator_objectives(self, share: Any, context: ConversationContext) -> str: """Format learning objectives for coordinator.""" - lines: List[str] = [] + lines: list[str] = [] if not share or not share.learning_objectives: lines.append("## Learning Objectives") lines.append("") lines.append( - "_No learning objectives have been set up yet. When shared, the assistant will help your recipients explore the knowledge in a more open way, helping them discover the important aspects of the knowledge without specific objectives or outcomes. If you would like to have a more formal process, ask your assistant to help you create learning objectives and outcomes._" + "_No learning objectives have been set up yet. When shared, the assistant will help your recipients explore the knowledge in a more open way, helping them discover the important aspects of the knowledge without specific objectives or outcomes. If you would like to have a more formal process, ask your assistant to help you create learning objectives and outcomes._" # noqa: E501 ) lines.append("") return "\n".join(lines) @@ -73,7 +73,7 @@ async def _format_coordinator_objectives(self, share: Any, context: Conversation if total_outcomes > 0 and share.team_conversations: for conv_id, team_conv in share.team_conversations.items(): achieved, total = LearningObjectivesManager.get_completion_for_conversation(share, conv_id) - progress_pct = int((achieved / total * 100)) if total > 0 else 0 + progress_pct = int(achieved / total * 100) if total > 0 else 0 lines.append(f"- **{team_conv.redeemer_name}**: {achieved}/{total} outcomes ({progress_pct}%)") lines.append("") @@ -90,7 +90,7 @@ async def _format_coordinator_objectives(self, share: Any, context: Conversation # Check if any team conversation has achieved this outcome achieved_by_any = any( LearningObjectivesManager.is_outcome_achieved_by_conversation(share, criterion.id, conv_id) - for conv_id in share.team_conversations.keys() + for conv_id in share.team_conversations ) status_emoji = "✅" if achieved_by_any else "⬜" @@ -98,7 +98,7 @@ async def _format_coordinator_objectives(self, share: Any, context: Conversation achieved_count = 0 total_team_count = len(share.team_conversations) - for conv_id in share.team_conversations.keys(): + for conv_id in share.team_conversations: if LearningObjectivesManager.is_outcome_achieved_by_conversation(share, criterion.id, conv_id): achieved_count += 1 @@ -114,13 +114,15 @@ async def _format_coordinator_objectives(self, share: Any, context: Conversation async def _format_team_objectives(self, share: Any, context: ConversationContext) -> str: """Format learning objectives for team members.""" - lines: List[str] = [] + lines: list[str] = [] if not share or not share.learning_objectives: lines.append("## Learning Objectives") lines.append("") lines.append( - "_The coordinator hasn't set up specific learning objectives for this shared knowledge. Enjoy exploring at your own pace! The assistant will guide you towards important information as you go._" + "_The coordinator hasn't set up specific learning objectives for this shared knowledge. " + "Enjoy exploring at your own pace! " + "The assistant will guide you towards important information as you go._" ) lines.append("") return "\n".join(lines) @@ -133,7 +135,7 @@ async def _format_team_objectives(self, share: Any, context: ConversationContext achieved_outcomes, total_outcomes = LearningObjectivesManager.get_completion_for_conversation( share, conversation_id ) - progress_pct = int((achieved_outcomes / total_outcomes * 100)) if total_outcomes > 0 else 0 + progress_pct = int(achieved_outcomes / total_outcomes * 100) if total_outcomes > 0 else 0 lines.append(f"**My Progress:** {achieved_outcomes}/{total_outcomes} outcomes achieved ({progress_pct}%)") lines.append("") diff --git a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/sharing.py b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/sharing.py index b2140a16e..33c9d0d53 100644 --- a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/sharing.py +++ b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/sharing.py @@ -2,7 +2,7 @@ Sharing inspector for information requests and responses. """ -from typing import Any, List +from typing import Any from semantic_workbench_assistant.assistant_app import ( AssistantConversationInspectorStateDataModel, @@ -57,10 +57,10 @@ async def get(self, context: ConversationContext) -> AssistantConversationInspec return AssistantConversationInspectorStateDataModel(data={"content": markdown}) - async def _format_coordinator_requests(self, requests: List[Any], context: ConversationContext) -> str: + async def _format_coordinator_requests(self, requests: list[Any], context: ConversationContext) -> str: """Format sharing information and requests for coordinator.""" - lines: List[str] = [] + lines: list[str] = [] # Share URL section at the top share = await ShareManager.get_share(context) @@ -117,7 +117,7 @@ async def _format_coordinator_requests(self, requests: List[Any], context: Conve for conv_id, team_conv in share.team_conversations.items(): achieved, total = LearningObjectivesManager.get_completion_for_conversation(share, conv_id) - progress_pct = int((achieved / total * 100)) if total > 0 else 0 + progress_pct = int(achieved / total * 100) if total > 0 else 0 lines.append(f"- **{team_conv.redeemer_name}**: {achieved}/{total} outcomes ({progress_pct}%)") lines.append(f" Joined: {team_conv.joined_at.strftime('%Y-%m-%d %H:%M')}") lines.append(f" Last active: {team_conv.last_active_at.strftime('%Y-%m-%d %H:%M')}") @@ -125,10 +125,10 @@ async def _format_coordinator_requests(self, requests: List[Any], context: Conve return "\n".join(lines) - async def _format_team_requests(self, requests: List[Any], context: ConversationContext) -> str: + async def _format_team_requests(self, requests: list[Any], context: ConversationContext) -> str: """Format sharing information and requests for team members.""" - lines: List[str] = [] + lines: list[str] = [] # Filter to my requests only my_requests = [r for r in requests if r.conversation_id == str(context.id)] @@ -161,7 +161,7 @@ async def _format_team_requests(self, requests: List[Any], context: Conversation lines.append("You haven't created any information requests yet.") lines.append("") lines.append( - "_Your assistant will help you create information requests to the knowledge coordinator if it is unable to answer your questions directly._" + "_Your assistant will help you create information requests to the knowledge coordinator if it is unable to answer your questions directly._" # noqa: E501 ) return "\n".join(lines) diff --git a/assistants/knowledge-transfer-assistant/assistant/utils.py b/assistants/knowledge-transfer-assistant/assistant/utils.py index 4c4447e45..376b0e122 100644 --- a/assistants/knowledge-transfer-assistant/assistant/utils.py +++ b/assistants/knowledge-transfer-assistant/assistant/utils.py @@ -6,7 +6,6 @@ """ import pathlib -from typing import Optional, Tuple from semantic_workbench_assistant.assistant_app import ConversationContext @@ -37,7 +36,7 @@ def load_text_include(filename) -> str: async def get_current_user( context: ConversationContext, -) -> Tuple[str, Optional[str]]: +) -> tuple[str, str | None]: participants = await context.get_participants() user_id = None user_name = None diff --git a/assistants/knowledge-transfer-assistant/knowledge-transfer-assistant.code-workspace b/assistants/knowledge-transfer-assistant/knowledge-transfer-assistant.code-workspace index 70645b445..abe0ae5e7 100644 --- a/assistants/knowledge-transfer-assistant/knowledge-transfer-assistant.code-workspace +++ b/assistants/knowledge-transfer-assistant/knowledge-transfer-assistant.code-workspace @@ -105,14 +105,18 @@ ] }, "settings": { - "cSpell.words": ["agentic"], - "cSpell.customDictionaries": { - "custom-dictionary-workspace": { - "name": "custom-dictionary-workspace", - "path": "${workspaceFolder:docs}/.cspell/custom-dictionary-workspace.txt", - "addWords": true, - "scope": "workspace" - } + "markdown.validate.enabled": true, + "ruff.configuration": "${workspaceFolder:.multi-root-tools}/../ruff.toml", + "ruff.interpreter": ["${workspaceFolder:.multi-root-tools}/.venv"], + "ruff.importStrategy": "useBundled", + "ruff.nativeServer": "on", + "ruff.configurationPreference": "filesystemFirst", + "[python]": { + "editor.codeActionsOnSave": { + "source.organizeImports.ruff": "always" + }, + "editor.defaultFormatter": "charliermarsh.ruff", + "editor.formatOnSave": true } } } diff --git a/assistants/knowledge-transfer-assistant/pyproject.toml b/assistants/knowledge-transfer-assistant/pyproject.toml index 5d77f8357..731498ad0 100644 --- a/assistants/knowledge-transfer-assistant/pyproject.toml +++ b/assistants/knowledge-transfer-assistant/pyproject.toml @@ -52,14 +52,14 @@ asyncio_default_fixture_loop_scope = "function" # but include import sorting in the lint section. # Leaving them all here for debugging temporarily. -[tool.ruff] -line-length = 120 -target-version = "py311" +# [tool.ruff] +# line-length = 120 +# target-version = "py311" -[tool.ruff.lint] -select = ["E4", "E7", "E9", "F", "I"] +# [tool.ruff.lint] +# select = ["E4", "E7", "E9", "F", "I"] -[tool.ruff.format] -docstring-code-format = true -line-ending = "lf" -preview = true +# [tool.ruff.format] +# docstring-code-format = true +# line-ending = "lf" +# preview = true diff --git a/assistants/knowledge-transfer-assistant/tests/test_artifact_loading.py b/assistants/knowledge-transfer-assistant/tests/test_artifact_loading.py index a57887c51..454308d09 100644 --- a/assistants/knowledge-transfer-assistant/tests/test_artifact_loading.py +++ b/assistants/knowledge-transfer-assistant/tests/test_artifact_loading.py @@ -10,8 +10,6 @@ import uuid from typing import Any, TypeVar -from semantic_workbench_assistant import settings - from assistant.data import ( ConversationRole, KnowledgeBrief, @@ -21,6 +19,7 @@ ) from assistant.domain import KnowledgeBriefManager, ShareManager from assistant.storage import ShareStorage, ShareStorageManager +from semantic_workbench_assistant import settings # Type variable for better type annotations T = TypeVar("T") @@ -144,16 +143,16 @@ async def test_get_project_brief(self) -> None: brief = await KnowledgeBriefManager.get_knowledge_brief(context) project = ShareStorage.read_share(self.share_id) - self.assertIsNotNone(brief, "Should load the brief") + assert brief is not None, "Should load the brief" if brief: # Type checking guard - self.assertEqual(brief.title, self.title) - self.assertEqual(brief.conversation_id, self.conversation_id) + assert brief.title == self.title + assert brief.conversation_id == self.conversation_id # Verify the project goals were loaded correctly - self.assertIsNotNone(project, "Should load the project") + assert project is not None, "Should load the project" if project: # Type checking guard - self.assertEqual(len(project.learning_objectives), 1, "Should have one goal") - self.assertEqual(project.learning_objectives[0].name, "Test Goal") + assert len(project.learning_objectives) == 1, "Should have one goal" + assert project.learning_objectives[0].name == "Test Goal" async def test_direct_storage_access(self) -> None: """Test direct access to project storage""" @@ -161,9 +160,9 @@ async def test_direct_storage_access(self) -> None: brief = ShareStorage.read_knowledge_brief(self.share_id) # Verify we got the correct brief - self.assertIsNotNone(brief, "Should load the brief directly") + assert brief is not None, "Should load the brief directly" if brief: # Type checking guard - self.assertEqual(brief.title, self.title) + assert brief.title == self.title # Test updating the brief using consolidated storage brief.title = "Updated KnowledgePackageTitle" @@ -172,7 +171,7 @@ async def test_direct_storage_access(self) -> None: # Read it back to verify the update updated_brief = ShareStorage.read_knowledge_brief(self.share_id) if updated_brief: # Type checking guard - self.assertEqual(updated_brief.title, "Updated KnowledgePackageTitle") + assert updated_brief.title == "Updated KnowledgePackageTitle" if __name__ == "__main__": diff --git a/assistants/knowledge-transfer-assistant/tests/test_inspector.py b/assistants/knowledge-transfer-assistant/tests/test_inspector.py index 1c5cfd775..3478cb0ed 100644 --- a/assistants/knowledge-transfer-assistant/tests/test_inspector.py +++ b/assistants/knowledge-transfer-assistant/tests/test_inspector.py @@ -6,11 +6,10 @@ import logging from unittest.mock import AsyncMock, MagicMock +from assistant.assistant import assistant from semantic_workbench_api_model.workbench_model import AssistantStateEvent from semantic_workbench_assistant.assistant_app import ConversationContext -from assistant.assistant import assistant - # Set up logging logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) diff --git a/assistants/knowledge-transfer-assistant/tests/test_share_manager.py b/assistants/knowledge-transfer-assistant/tests/test_share_manager.py index cbe4e548a..3184d9f8e 100644 --- a/assistants/knowledge-transfer-assistant/tests/test_share_manager.py +++ b/assistants/knowledge-transfer-assistant/tests/test_share_manager.py @@ -5,14 +5,13 @@ from unittest.mock import AsyncMock, MagicMock import pytest -from semantic_workbench_assistant.assistant_app import ConversationContext - from assistant.data import ( KnowledgePackage, LearningObjective, LearningOutcome, ) from assistant.domain import LearningObjectivesManager +from semantic_workbench_assistant.assistant_app import ConversationContext class TestKnowledgeTransferManager: diff --git a/assistants/knowledge-transfer-assistant/tests/test_share_storage.py b/assistants/knowledge-transfer-assistant/tests/test_share_storage.py index 1dd564994..f4d2ec1d8 100644 --- a/assistants/knowledge-transfer-assistant/tests/test_share_storage.py +++ b/assistants/knowledge-transfer-assistant/tests/test_share_storage.py @@ -9,9 +9,6 @@ import uuid from datetime import datetime -from semantic_workbench_api_model.workbench_model import AssistantStateEvent -from semantic_workbench_assistant import settings - from assistant.data import ( ConversationRole, CoordinatorConversationMessage, @@ -32,6 +29,8 @@ from assistant.domain.share_manager import ShareManager from assistant.notifications import Notifications from assistant.storage import ShareStorage, ShareStorageManager +from semantic_workbench_api_model.workbench_model import AssistantStateEvent +from semantic_workbench_assistant import settings class TestShareStorage(unittest.IsolatedAsyncioTestCase): @@ -166,33 +165,33 @@ async def test_read_project_brief(self): project = ShareStorage.read_share(self.share_id) # Verify the brief was loaded correctly - self.assertIsNotNone(brief, "Should load the brief") + assert brief is not None, "Should load the brief" if brief: # Type checking guard - self.assertEqual(brief.title, "Test KnowledgePackage") - self.assertEqual(brief.content, "Test project description") + assert brief.title == "Test KnowledgePackage" + assert brief.content == "Test project description" # Verify the project was loaded with goals correctly - self.assertIsNotNone(project, "Should load the project") + assert project is not None, "Should load the project" if project: # Type checking guard - self.assertEqual(len(project.learning_objectives), 1) - self.assertEqual(project.learning_objectives[0].name, "Test Goal") + assert len(project.learning_objectives) == 1 + assert project.learning_objectives[0].name == "Test Goal" async def test_read_information_request(self): """Test reading an information request.""" # First get all requests to find the request ID requests = ShareStorage.get_all_information_requests(self.share_id) - self.assertEqual(len(requests), 1, "Should find one request") + assert len(requests) == 1, "Should find one request" request_id = requests[0].request_id # Read the request using ShareStorage request = ShareStorage.read_information_request(self.share_id, request_id) # Verify the request was loaded correctly - self.assertIsNotNone(request, "Should load the request") + assert request is not None, "Should load the request" if request: # Type checking guard - self.assertEqual(request.title, "Test Request") - self.assertEqual(request.description, "This is a test request") - self.assertEqual(request.priority, RequestPriority.HIGH) + assert request.title == "Test Request" + assert request.description == "This is a test request" + assert request.priority == RequestPriority.HIGH async def test_write_project_log(self): """Test writing a project log.""" @@ -217,22 +216,22 @@ async def test_write_project_log(self): log = ShareStorage.read_share_log(self.share_id) # Verify the log was saved and loaded correctly - self.assertIsNotNone(log, "Should load the log") + assert log is not None, "Should load the log" if log: # Type checking guard - self.assertEqual(len(log.entries), 1) - self.assertEqual(log.entries[0].entry_type, LogEntryType.SHARE_INFORMATION_UPDATE) - self.assertEqual(log.entries[0].message, "Test log entry") + assert len(log.entries) == 1 + assert log.entries[0].entry_type == LogEntryType.SHARE_INFORMATION_UPDATE + assert log.entries[0].message == "Test log entry" async def test_project_directory_structure(self): """Test the project directory structure.""" # Verify project directory exists - self.assertTrue(self.project_dir.exists(), "KnowledgePackage directory should exist") + assert self.project_dir.exists(), "KnowledgePackage directory should exist" # Verify Coordinator directory exists - self.assertTrue(self.coordinator_dir.exists(), "Coordinator directory should exist") + assert self.coordinator_dir.exists(), "Coordinator directory should exist" # Verify team directory exists - self.assertTrue(self.team_dir.exists(), "Team directory should exist") + assert self.team_dir.exists(), "Team directory should exist" async def test_coordinator_conversation_storage(self): """Test the coordinator conversation storage functionality.""" @@ -264,14 +263,14 @@ async def test_coordinator_conversation_storage(self): read_storage = ShareStorage.read_coordinator_conversation(self.share_id) # Verify data was saved correctly - self.assertIsNotNone(read_storage, "Should load the coordinator conversation") + assert read_storage is not None, "Should load the coordinator conversation" if read_storage: - self.assertEqual(read_storage.knowledge_share_id, self.share_id) - self.assertEqual(len(read_storage.messages), 2) - self.assertEqual(read_storage.messages[0].content, "Test message 1") - self.assertEqual(read_storage.messages[1].content, "Test message 2") - self.assertFalse(read_storage.messages[0].is_assistant) - self.assertTrue(read_storage.messages[1].is_assistant) + assert read_storage.knowledge_share_id == self.share_id + assert len(read_storage.messages) == 2 + assert read_storage.messages[0].content == "Test message 1" + assert read_storage.messages[1].content == "Test message 2" + assert not read_storage.messages[0].is_assistant + assert read_storage.messages[1].is_assistant async def test_append_coordinator_message(self): """Test appending a message to coordinator conversation storage.""" @@ -296,13 +295,13 @@ async def test_append_coordinator_message(self): storage = ShareStorage.read_coordinator_conversation(self.share_id) # Verify messages were added - self.assertIsNotNone(storage, "Should create and load the coordinator conversation") + assert storage is not None, "Should create and load the coordinator conversation" if storage: - self.assertEqual(len(storage.messages), 2) - self.assertEqual(storage.messages[0].content, "First message") - self.assertEqual(storage.messages[1].content, "Second message") - self.assertFalse(storage.messages[0].is_assistant) - self.assertTrue(storage.messages[1].is_assistant) + assert len(storage.messages) == 2 + assert storage.messages[0].content == "First message" + assert storage.messages[1].content == "Second message" + assert not storage.messages[0].is_assistant + assert storage.messages[1].is_assistant async def test_message_limit_in_coordinator_conversation(self): """Test that coordinator conversation storage limits to the most recent messages.""" @@ -319,13 +318,13 @@ async def test_message_limit_in_coordinator_conversation(self): storage = ShareStorage.read_coordinator_conversation(self.share_id) # Verify only the most recent 50 messages are kept - self.assertIsNotNone(storage, "Should load the coordinator conversation") + assert storage is not None, "Should load the coordinator conversation" if storage: - self.assertEqual(len(storage.messages), 50, "Should limit to 50 messages") + assert len(storage.messages) == 50, "Should limit to 50 messages" # First message should be the 11th message (since we keep the last 50 of 60) - self.assertEqual(storage.messages[0].content, "Message 11") + assert storage.messages[0].content == "Message 11" # Last message should be the 60th message - self.assertEqual(storage.messages[49].content, "Message 60") + assert storage.messages[49].content == "Message 60" async def test_knowledge_digest(self): """Test reading and writing knowledge digest.""" @@ -345,13 +344,10 @@ async def test_knowledge_digest(self): read_digest = ShareStorage.read_knowledge_digest(self.share_id) # Verify knowledge digest was saved correctly - self.assertIsNotNone(read_digest, "Should load the knowledge digest") + assert read_digest is not None, "Should load the knowledge digest" if read_digest: - self.assertEqual( - read_digest.content, - "# Test Knowledge Digest\n\nThis is a test knowledge digest.", - ) - self.assertTrue(read_digest.is_auto_generated) + assert read_digest.content == "# Test Knowledge Digest\n\nThis is a test knowledge digest." + assert read_digest.is_auto_generated async def test_refresh_current_ui(self): """Test refreshing the current UI inspector.""" @@ -367,7 +363,7 @@ async def test_refresh_current_ui(self): ) # Verify that send_conversation_state_event was called 4 times (once per inspector tab) - self.assertEqual(self.context.send_conversation_state_event.call_count, 4) + assert self.context.send_conversation_state_event.call_count == 4 # Get all the calls calls = self.context.send_conversation_state_event.call_args_list @@ -377,13 +373,13 @@ async def test_refresh_current_ui(self): # Verify each call has the correct parameters for call_args in calls: called_event = call_args[0][0] - self.assertIsInstance(called_event, AssistantStateEvent) - self.assertEqual(called_event.event, "updated") - self.assertIsNone(called_event.state) - self.assertIn(called_event.state_id, expected_state_ids) + assert isinstance(called_event, AssistantStateEvent) + assert called_event.event == "updated" + assert called_event.state is None + assert called_event.state_id in expected_state_ids # Verify all expected state IDs were called - self.assertEqual(set(actual_state_ids), set(expected_state_ids)) + assert set(actual_state_ids) == set(expected_state_ids) async def test_knowledge_package_info(self): """Test reading and writing knowledge package info.""" @@ -391,9 +387,9 @@ async def test_knowledge_package_info(self): package = ShareStorage.read_share(self.share_id) # Verify it was loaded correctly - self.assertIsNotNone(package, "Should load knowledge package") + assert package is not None, "Should load knowledge package" if package: - self.assertEqual(package.share_id, self.share_id) + assert package.share_id == self.share_id # Update knowledge package info package.transfer_notes = "Test status message" @@ -407,25 +403,25 @@ async def test_knowledge_package_info(self): updated_package = ShareStorage.read_share(self.share_id) # Verify updates were saved - self.assertIsNotNone(updated_package, "Should load updated knowledge package") + assert updated_package is not None, "Should load updated knowledge package" if updated_package: - self.assertEqual(updated_package.transfer_notes, "Test status message") + assert updated_package.transfer_notes == "Test status message" # Note: completion_percentage removed from model - self.assertEqual(updated_package.next_learning_actions, ["Action 1", "Action 2"]) + assert updated_package.next_learning_actions == ["Action 1", "Action 2"] async def test_conversation_tracking_in_json(self): """Test that conversations are tracked in JSON instead of file system.""" # Load knowledge package package = ShareStorage.read_share(self.share_id) - self.assertIsNotNone(package) + assert package is not None if package: # Verify team_conversations dict exists (even if empty) - self.assertIsInstance(package.team_conversations, dict) + assert isinstance(package.team_conversations, dict) # Verify helper methods work linked_conversations = await ShareManager.get_linked_conversations(self.context) - self.assertIsInstance(linked_conversations, list) + assert isinstance(linked_conversations, list) async def test_conversation_association(self): """Test conversation role setting.""" @@ -442,11 +438,11 @@ async def test_conversation_association(self): # Verify the file path in the call call_args = mock_write_model.call_args[0] - self.assertEqual(call_args[0], conversation_role_file) + assert call_args[0] == conversation_role_file # Verify the ConversationShareInfo object created - self.assertEqual(call_args[1].share_id, self.share_id) - self.assertEqual(call_args[1].role, ConversationRole.COORDINATOR) + assert call_args[1].share_id == self.share_id + assert call_args[1].role == ConversationRole.COORDINATOR async def test_log_project_event(self): """Test logging a project event.""" @@ -469,19 +465,19 @@ async def test_log_project_event(self): # Read the log back read_log = ShareStorage.read_share_log(self.share_id) - self.assertIsNotNone(read_log, "Should load the log") + assert read_log is not None, "Should load the log" if read_log: # Find our test entry found_entry = False for entry in read_log.entries: if entry.message == "Test direct log entry": found_entry = True - self.assertEqual(entry.entry_type, LogEntryType.SHARE_INFORMATION_UPDATE) - self.assertEqual(entry.user_id, self.user_id) - self.assertEqual(entry.user_name, "Test User") - self.assertEqual(entry.related_entity_id, "test-entity-id") - self.assertEqual(entry.metadata, {"test": "metadata"}) - self.assertTrue(found_entry, "Should find the added log entry") + assert entry.entry_type == LogEntryType.SHARE_INFORMATION_UPDATE + assert entry.user_id == self.user_id + assert entry.user_name == "Test User" + assert entry.related_entity_id == "test-entity-id" + assert entry.metadata == {"test": "metadata"} + assert found_entry, "Should find the added log entry" if __name__ == "__main__": diff --git a/assistants/knowledge-transfer-assistant/tests/test_share_tools.py b/assistants/knowledge-transfer-assistant/tests/test_share_tools.py index 4da3d92b1..6c79a0682 100644 --- a/assistants/knowledge-transfer-assistant/tests/test_share_tools.py +++ b/assistants/knowledge-transfer-assistant/tests/test_share_tools.py @@ -7,11 +7,10 @@ import openai_client import pytest -from semantic_workbench_assistant.assistant_app import ConversationContext - from assistant.agentic.analysis import detect_information_request_needs from assistant.data import ConversationRole from assistant.tools import ShareTools +from semantic_workbench_assistant.assistant_app import ConversationContext # Use pytest for all tests for consistency diff --git a/ruff.toml b/ruff.toml index a44141547..bc486d94c 100644 --- a/ruff.toml +++ b/ruff.toml @@ -5,3 +5,21 @@ target-version = "py311" docstring-code-format = true line-ending = "lf" preview = true + +[lint] +select = [ + "F", # pyflakes + "E", # pycodestyle + "I", # isort + "N", # pep8-naming + "UP", # pyupgrade + "RUF", # ruff + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "ISC", # flake8-implicit-str-concat + "PIE", # flake8-pie + "PT", # flake-pytest-style + "PTH", # flake8-use-pathlib + "SIM", # flake8-simplify + "TID", # flake8-tidy-imports +] From 5e373ad29471fea906d77bc357bd9f9fb9b44b7b Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Wed, 30 Jul 2025 11:23:51 -0700 Subject: [PATCH 13/21] Updates data model: KnowledgePackage -> Share --- .../assistant/data.py | 8 +- .../domain/learning_objectives_manager.py | 42 ++-- .../assistant/domain/share_manager.py | 56 ++---- .../assistant/domain/transfer_manager.py | 33 +-- .../assistant/errors.py | 2 +- .../assistant/notifications.py | 45 ++--- .../assistant/storage.py | 188 +++++++----------- .../assistant/tools/share_setup.py | 2 +- .../assistant/ui_tabs/__init__.py | 6 +- .../assistant/ui_tabs/common.py | 32 +-- .../tests/test_artifact_loading.py | 4 +- .../tests/test_share_manager.py | 6 +- .../tests/test_share_storage.py | 10 +- 13 files changed, 163 insertions(+), 271 deletions(-) diff --git a/assistants/knowledge-transfer-assistant/assistant/data.py b/assistants/knowledge-transfer-assistant/assistant/data.py index 5bd5edb28..d37dd707b 100644 --- a/assistants/knowledge-transfer-assistant/assistant/data.py +++ b/assistants/knowledge-transfer-assistant/assistant/data.py @@ -160,11 +160,11 @@ class LogEntry(BaseModel): metadata: dict | None = None -class KnowledgePackageLog(BaseModel): +class ShareLog(BaseModel): entries: list[LogEntry] = Field(default_factory=list) # Chronological list of log entries -class KnowledgePackage(BaseModel): +class Share(BaseModel): share_id: str coordinator_conversation_id: str | None = None shared_conversation_id: str | None = None @@ -176,7 +176,7 @@ class KnowledgePackage(BaseModel): updated_at: datetime = Field(default_factory=datetime.utcnow) updated_by: str | None = None - # Package components + # Knowledge Package components audience: str | None = None brief: KnowledgeBrief | None learning_objectives: list[LearningObjective] = Field(default_factory=list) @@ -193,7 +193,7 @@ class KnowledgePackage(BaseModel): archived: bool = False requests: list[InformationRequest] = Field(default_factory=list) - log: KnowledgePackageLog | None = Field(default_factory=lambda: KnowledgePackageLog()) + log: ShareLog | None = Field(default_factory=lambda: ShareLog()) class CoordinatorConversationMessage(BaseModel): diff --git a/assistants/knowledge-transfer-assistant/assistant/domain/learning_objectives_manager.py b/assistants/knowledge-transfer-assistant/assistant/domain/learning_objectives_manager.py index 53a339cc1..146d816a6 100644 --- a/assistants/knowledge-transfer-assistant/assistant/domain/learning_objectives_manager.py +++ b/assistants/knowledge-transfer-assistant/assistant/domain/learning_objectives_manager.py @@ -8,11 +8,11 @@ from assistant.data import ( InspectorTab, - KnowledgePackage, LearningObjective, LearningOutcome, LearningOutcomeAchievement, LogEntryType, + Share, ) from assistant.notifications import Notifications @@ -187,7 +187,7 @@ async def delete_learning_objective( context, share.share_id, [InspectorTab.LEARNING, InspectorTab.BRIEF] ) - return f"Learning objective '{objective_name}' has been successfully deleted from the knowledge package." + return f"Learning objective '{objective_name}' has been successfully deleted from the knowledge share." @staticmethod async def get_learning_outcomes( @@ -218,7 +218,6 @@ async def add_learning_outcome( if not share.learning_objectives: raise ValueError("No learning objectives found. Please add objectives before adding outcomes.") - # Find the objective by ID objective = None for obj in share.learning_objectives: if obj.id == objective_id: @@ -228,16 +227,10 @@ async def add_learning_outcome( if objective is None: raise ValueError("Learning objective not found") - # Create the new outcome new_outcome = LearningOutcome(description=outcome_description.strip()) - - # Add the outcome to the objective objective.learning_outcomes.append(new_outcome) - - # Save the updated knowledge package await ShareManager.set_share(context, share) - # Log the outcome addition await ShareManager.log_share_event( context=context, entry_type=LogEntryType.LEARNING_OBJECTIVE_UPDATED.value, @@ -250,7 +243,6 @@ async def add_learning_outcome( }, ) - # Notify linked conversations await Notifications.notify_all( context, share.share_id, @@ -300,13 +292,9 @@ async def update_learning_outcome( old_description = outcome.description - # Update the outcome description outcome.description = new_description.strip() - - # Save the updated knowledge package await ShareManager.set_share(context, share) - # Log the outcome update await ShareManager.log_share_event( context=context, entry_type=LogEntryType.LEARNING_OBJECTIVE_UPDATED.value, @@ -320,7 +308,6 @@ async def update_learning_outcome( }, ) - # Notify linked conversations await Notifications.notify_all( context, share.share_id, f"Learning outcome '{new_description}' has been updated" ) @@ -378,10 +365,8 @@ async def delete_learning_outcome( achievement for achievement in team_info.outcome_achievements if achievement.outcome_id != outcome_id ] - # Save the updated knowledge package await ShareManager.set_share(context, share) - # Log the outcome deletion await ShareManager.log_share_event( context=context, entry_type=LogEntryType.LEARNING_OBJECTIVE_UPDATED.value, @@ -395,7 +380,6 @@ async def delete_learning_outcome( }, ) - # Notify linked conversations await Notifications.notify_all( context, share.share_id, @@ -408,38 +392,36 @@ async def delete_learning_outcome( return f"Learning outcome deleted successfully from objective '{objective.name}': {deleted_description}" @staticmethod - def get_achievements_for_conversation( - package: KnowledgePackage, conversation_id: str - ) -> list[LearningOutcomeAchievement]: - team_conv = package.team_conversations.get(conversation_id) + def get_achievements_for_conversation(share: Share, conversation_id: str) -> list[LearningOutcomeAchievement]: + team_conv = share.team_conversations.get(conversation_id) return team_conv.outcome_achievements if team_conv else [] @staticmethod - def get_completion_for_conversation(package: KnowledgePackage, conversation_id: str) -> tuple[int, int]: - achievements = LearningObjectivesManager.get_achievements_for_conversation(package, conversation_id) + def get_completion_for_conversation(share: Share, conversation_id: str) -> tuple[int, int]: + achievements = LearningObjectivesManager.get_achievements_for_conversation(share, conversation_id) achieved_outcome_ids = {a.outcome_id for a in achievements if a.achieved} - total_outcomes = sum(len(obj.learning_outcomes) for obj in package.learning_objectives) + total_outcomes = sum(len(obj.learning_outcomes) for obj in share.learning_objectives) achieved_outcomes = len(achieved_outcome_ids) return achieved_outcomes, total_outcomes @staticmethod - def is_outcome_achieved_by_conversation(package: KnowledgePackage, outcome_id: str, conversation_id: str) -> bool: - achievements = LearningObjectivesManager.get_achievements_for_conversation(package, conversation_id) + def is_outcome_achieved_by_conversation(share: Share, outcome_id: str, conversation_id: str) -> bool: + achievements = LearningObjectivesManager.get_achievements_for_conversation(share, conversation_id) return any(a.outcome_id == outcome_id and a.achieved for a in achievements) @staticmethod - def get_overall_completion(package: KnowledgePackage) -> tuple[int, int]: + def get_overall_completion(share: Share) -> tuple[int, int]: """ Get overall completion across all team conversations. Returns: Tuple of (unique_achieved_outcomes, total_outcomes) across all team conversations """ all_achieved_outcomes = set() - for team_conv in package.team_conversations.values(): + for team_conv in share.team_conversations.values(): achieved_ids = {a.outcome_id for a in team_conv.outcome_achievements if a.achieved} all_achieved_outcomes.update(achieved_ids) - total_outcomes = sum(len(obj.learning_outcomes) for obj in package.learning_objectives) + total_outcomes = sum(len(obj.learning_outcomes) for obj in share.learning_objectives) return len(all_achieved_outcomes), total_outcomes diff --git a/assistants/knowledge-transfer-assistant/assistant/domain/share_manager.py b/assistants/knowledge-transfer-assistant/assistant/domain/share_manager.py index cd4c10b85..0066c58f6 100644 --- a/assistants/knowledge-transfer-assistant/assistant/domain/share_manager.py +++ b/assistants/knowledge-transfer-assistant/assistant/domain/share_manager.py @@ -20,8 +20,8 @@ ConversationRole, ConversationShareInfo, CoordinatorConversationMessages, - KnowledgePackage, - KnowledgePackageLog, + Share, + ShareLog, TeamConversationInfo, ) from assistant.errors import NoShareException, NoShareFoundError @@ -62,17 +62,15 @@ async def create_share(context: ConversationContext) -> str: share_dir = ShareStorageManager.get_share_dir(share_id) logger.debug(f"Created share directory: {share_dir}") - # Create and save the initial knowledge package - knowledge_package = KnowledgePackage( + share = Share( share_id=share_id, coordinator_conversation_id=str(context.id), brief=None, digest=None, ) - # Save the knowledge package - ShareStorage.write_share(share_id, knowledge_package) - logger.debug(f"Created and saved knowledge package: {knowledge_package}") + ShareStorage.write_share(share_id, share) + logger.debug(f"Created and saved knowledge share: {share}") # Associate the conversation with the share logger.debug(f"Associating conversation {context.id} with share {share_id}") @@ -81,8 +79,6 @@ async def create_share(context: ConversationContext) -> str: # No need to set conversation role in share storage, as we use metadata logger.debug(f"Conversation {context.id} is Coordinator for share {share_id}") - # Note: Conversation linking is now handled via JSON data, no directory needed - return share_id @staticmethod @@ -168,16 +164,11 @@ async def create_shareable_team_conversation(context: ConversationContext, share share_url = f"/conversation-share/{share.id}/redeem" - # Store shared conversation info in KnowledgePackage - knowledge_package = await ShareManager.get_share(context) - if knowledge_package: - knowledge_package.shared_conversation_id = str(conversation.id) - knowledge_package.share_url = share_url - knowledge_package.updated_at = datetime.now(UTC) - ShareStorage.write_share(share_id, knowledge_package) - else: - raise ValueError(f"KnowledgePackage info not found for share ID: {share_id}") - + share = await ShareManager.get_share(context) + share.shared_conversation_id = str(conversation.id) + share.share_url = share_url + share.updated_at = datetime.now(UTC) + ShareStorage.write_share(share_id, share) return share_url @staticmethod @@ -231,7 +222,7 @@ async def get_share_id(context: ConversationContext) -> str: raise NoShareFoundError(str(e)) from e @staticmethod - async def get_share(context: ConversationContext) -> KnowledgePackage: + async def get_share(context: ConversationContext) -> Share: """Gets the share information for the current conversation's share.""" try: share_id = await ShareManager.get_share_id(context) @@ -247,7 +238,7 @@ async def get_share(context: ConversationContext) -> KnowledgePackage: raise NoShareFoundError(str(e)) from e @staticmethod - async def set_share(context: ConversationContext, share: KnowledgePackage) -> None: + async def set_share(context: ConversationContext, share: Share) -> None: """ Sets the share information for the current conversation's share. @@ -290,24 +281,24 @@ async def get_linked_conversations(context: ConversationContext) -> list[str]: @staticmethod async def _capture_redeemer_info(context: ConversationContext, share_id: str) -> None: """ - Captures the redeemer (first non-assistant participant) information and stores it in the knowledge package. + Captures the redeemer (first non-assistant participant) information and stores it in the knowledge share. Only captures info for actual team member conversations, not coordinator or shared conversations. """ try: - knowledge_package = ShareStorage.read_share(share_id) - if not knowledge_package: - logger.warning(f"Could not load knowledge package {share_id} to capture redeemer info") + share = ShareStorage.read_share(share_id) + if not share: + logger.warning(f"Could not load knowledge share {share_id} to capture redeemer info") return conversation_id = str(context.id) # Skip if this is the coordinator conversation - if conversation_id == knowledge_package.coordinator_conversation_id: + if conversation_id == share.coordinator_conversation_id: logger.debug(f"Skipping redeemer capture for coordinator conversation {conversation_id}") return # Skip if this is the shared conversation template - if conversation_id == knowledge_package.shared_conversation_id: + if conversation_id == share.shared_conversation_id: logger.debug(f"Skipping redeemer capture for shared conversation template {conversation_id}") return @@ -319,18 +310,13 @@ async def _capture_redeemer_info(context: ConversationContext, share_id: str) -> logger.warning(f"Could not identify redeemer for conversation {conversation_id}") return - # Create team conversation info team_conversation_info = TeamConversationInfo( conversation_id=conversation_id, redeemer_user_id=user_id, redeemer_name=user_name, ) - - # Add to knowledge package - knowledge_package.team_conversations[conversation_id] = team_conversation_info - - # Save the updated knowledge package - ShareStorage.write_share(share_id, knowledge_package) + share.team_conversations[conversation_id] = team_conversation_info + ShareStorage.write_share(share_id, share) logger.debug(f"Captured redeemer info for team conversation {conversation_id}: {user_name} ({user_id})") except Exception as e: @@ -340,7 +326,7 @@ async def _capture_redeemer_info(context: ConversationContext, share_id: str) -> @staticmethod async def get_share_log( context: ConversationContext, - ) -> KnowledgePackageLog | None: + ) -> ShareLog | None: """Gets the knowledge transfer log for the current conversation's share.""" try: share_id = await ShareManager.get_share_id(context) diff --git a/assistants/knowledge-transfer-assistant/assistant/domain/transfer_manager.py b/assistants/knowledge-transfer-assistant/assistant/domain/transfer_manager.py index 04cb9ba53..c2460e94d 100644 --- a/assistants/knowledge-transfer-assistant/assistant/domain/transfer_manager.py +++ b/assistants/knowledge-transfer-assistant/assistant/domain/transfer_manager.py @@ -1,47 +1,34 @@ -""" -Knowledge Package Service for business logic operations. - -Provides business logic methods for KnowledgePackage state assessment, -presentation, achievement tracking, and conversation management. -""" - -from assistant.data import KnowledgePackage +from assistant.data import Share from .learning_objectives_manager import LearningObjectivesManager class TransferManager: - """Manager class for KnowledgePackage business logic operations.""" - @staticmethod - def is_ready_for_transfer(package: KnowledgePackage) -> bool: - has_basic_requirements = ( - package.knowledge_organized and package.brief is not None and package.audience is not None - ) + def is_ready_for_transfer(share: Share) -> bool: + has_basic_requirements = share.knowledge_organized and share.brief is not None and share.audience is not None if not has_basic_requirements: return False - if not package.is_intended_to_accomplish_outcomes: + if not share.is_intended_to_accomplish_outcomes: return True - return bool(package.learning_objectives) and any( - bool(obj.learning_outcomes) for obj in package.learning_objectives - ) + return bool(share.learning_objectives) and any(bool(obj.learning_outcomes) for obj in share.learning_objectives) @staticmethod - def is_actively_sharing(package: KnowledgePackage) -> bool: - return TransferManager.is_ready_for_transfer(package) and len(package.team_conversations) > 0 + def is_actively_sharing(share: Share) -> bool: + return TransferManager.is_ready_for_transfer(share) and len(share.team_conversations) > 0 @staticmethod - def _is_transfer_complete(package: KnowledgePackage) -> bool: + def _is_transfer_complete(share: Share) -> bool: """ Check if knowledge transfer is complete (all outcomes achieved by at least one team member). Returns: True if all learning outcomes have been achieved by at least one team conversation """ - if not package.is_intended_to_accomplish_outcomes: + if not share.is_intended_to_accomplish_outcomes: return False - achieved_outcomes, total_outcomes = LearningObjectivesManager.get_overall_completion(package) + achieved_outcomes, total_outcomes = LearningObjectivesManager.get_overall_completion(share) return total_outcomes > 0 and achieved_outcomes == total_outcomes diff --git a/assistants/knowledge-transfer-assistant/assistant/errors.py b/assistants/knowledge-transfer-assistant/assistant/errors.py index 97cd8f431..711a98950 100644 --- a/assistants/knowledge-transfer-assistant/assistant/errors.py +++ b/assistants/knowledge-transfer-assistant/assistant/errors.py @@ -1,6 +1,6 @@ # Define an Exception class for no share found class NoShareFoundError(Exception): - """Exception raised when no active knowledge package is found.""" + """Exception raised when no active knowledge share is found.""" def __init__(self, message: str): super().__init__(message) diff --git a/assistants/knowledge-transfer-assistant/assistant/notifications.py b/assistants/knowledge-transfer-assistant/assistant/notifications.py index e2e8ab422..7d3f6b32d 100644 --- a/assistants/knowledge-transfer-assistant/assistant/notifications.py +++ b/assistants/knowledge-transfer-assistant/assistant/notifications.py @@ -39,8 +39,8 @@ async def notify_self_and_other( # Always notify current conversation await Notifications.notify(context, message) - knowledge_package = await ShareManager.get_share(context) - if not knowledge_package: + share = await ShareManager.get_share(context) + if not share: return current_id = str(context.id) @@ -48,11 +48,8 @@ async def notify_self_and_other( # Determine the other conversation to notify if other_conversation_id: target_id = other_conversation_id - elif ( - knowledge_package.coordinator_conversation_id - and knowledge_package.coordinator_conversation_id != current_id - ): - target_id = knowledge_package.coordinator_conversation_id + elif share.coordinator_conversation_id and share.coordinator_conversation_id != current_id: + target_id = share.coordinator_conversation_id else: return @@ -71,8 +68,8 @@ async def notify_self_and_other( async def notify_all(context: ConversationContext, share_id: str, message: str) -> None: """Send text message notification to all knowledge transfer conversations.""" - knowledge_package = await ShareManager.get_share(context) - if not knowledge_package: + share = await ShareManager.get_share(context) + if not share: return # Always notify current conversation @@ -81,14 +78,9 @@ async def notify_all(context: ConversationContext, share_id: str, message: str) current_id = str(context.id) # Notify coordinator conversation - if ( - knowledge_package.coordinator_conversation_id - and knowledge_package.coordinator_conversation_id != current_id - ): + if share.coordinator_conversation_id and share.coordinator_conversation_id != current_id: try: - client = ConversationClientManager.get_conversation_client( - context, knowledge_package.coordinator_conversation_id - ) + client = ConversationClientManager.get_conversation_client(context, share.coordinator_conversation_id) await client.send_messages( NewConversationMessage( content=message, @@ -99,8 +91,8 @@ async def notify_all(context: ConversationContext, share_id: str, message: str) logger.error(f"Failed to notify coordinator conversation: {e}") # Notify all team conversations - for conv_id in knowledge_package.team_conversations: - if conv_id != current_id and conv_id != knowledge_package.coordinator_conversation_id: + for conv_id in share.team_conversations: + if conv_id != current_id and conv_id != share.coordinator_conversation_id: try: client = ConversationClientManager.get_conversation_client(context, conv_id) await client.send_messages( @@ -133,22 +125,17 @@ async def notify_all_state_update(context: ConversationContext, share_id: str, t await Notifications.notify_state_update(context, tabs) # Refresh other conversations - knowledge_package = await ShareManager.get_share(context) - if not knowledge_package: + share = await ShareManager.get_share(context) + if not share: return current_id = str(context.id) assistant_id = context.assistant.id # Refresh coordinator conversation - if ( - knowledge_package.coordinator_conversation_id - and knowledge_package.coordinator_conversation_id != current_id - ): + if share.coordinator_conversation_id and share.coordinator_conversation_id != current_id: try: - client = ConversationClientManager.get_conversation_client( - context, knowledge_package.coordinator_conversation_id - ) + client = ConversationClientManager.get_conversation_client(context, share.coordinator_conversation_id) for tab in tabs: state_event = AssistantStateEvent( @@ -164,8 +151,8 @@ async def notify_all_state_update(context: ConversationContext, share_id: str, t logger.error(f"Failed to refresh coordinator conversation UI: {e}") # Refresh all team conversations - for conv_id in knowledge_package.team_conversations: - if conv_id != current_id and conv_id != knowledge_package.coordinator_conversation_id: + for conv_id in share.team_conversations: + if conv_id != current_id and conv_id != share.coordinator_conversation_id: try: client = ConversationClientManager.get_conversation_client(context, conv_id) diff --git a/assistants/knowledge-transfer-assistant/assistant/storage.py b/assistants/knowledge-transfer-assistant/assistant/storage.py index 2e8eea99d..459e8395e 100644 --- a/assistants/knowledge-transfer-assistant/assistant/storage.py +++ b/assistants/knowledge-transfer-assistant/assistant/storage.py @@ -1,9 +1,3 @@ -""" -KnowledgePackage storage management module. - -Provides direct access to knowledge transfer data with a clean, simple storage approach. -""" - import pathlib from datetime import datetime from typing import Any @@ -15,6 +9,9 @@ ) from semantic_workbench_assistant.storage import read_model, write_model +from assistant.errors import NoShareException +from assistant.logging import logger + # Import inside functions to avoid circular imports from .data import ( CoordinatorConversationMessage, @@ -22,10 +19,10 @@ InformationRequest, KnowledgeBrief, KnowledgeDigest, - KnowledgePackage, - KnowledgePackageLog, LogEntry, LogEntryType, + Share, + ShareLog, ) from .utils import get_current_user @@ -67,7 +64,7 @@ def get_coordinator_conversation_path(share_id: str) -> pathlib.Path: @staticmethod def get_share_path(share_id: str) -> pathlib.Path: - """Gets the path to the complete KnowledgePackage data file.""" + """Gets the path to the complete data file.""" share_dir = ShareStorageManager.get_share_dir(share_id) return share_dir / ShareStorageManager.SHARE_FILE @@ -86,58 +83,45 @@ def get_conversation_role_file_path(context: ConversationContext) -> pathlib.Pat class ShareStorage: - """Unified storage operations for knowledge transfer share data.""" - @staticmethod - def read_share(share_id: str) -> KnowledgePackage | None: - """Reads the complete KnowledgePackage data.""" + def read_share(share_id: str) -> Share | None: path = ShareStorageManager.get_share_path(share_id) - return read_model(path, KnowledgePackage) + return read_model(path, Share) @staticmethod - def write_share(share_id: str, package: KnowledgePackage) -> pathlib.Path: - """Writes the complete KnowledgePackage data.""" + def write_share(share_id: str, share: Share) -> pathlib.Path: path = ShareStorageManager.get_share_path(share_id) - write_model(path, package) + write_model(path, share) return path @staticmethod def read_knowledge_brief(share_id: str) -> KnowledgeBrief | None: - """Reads the knowledge brief from the main share data.""" - package = ShareStorage.read_share(share_id) - return package.brief if package else None + share = ShareStorage.read_share(share_id) + return share.brief if share else None @staticmethod def write_knowledge_brief(share_id: str, brief: KnowledgeBrief) -> pathlib.Path: - """Writes the knowledge brief to the main share data.""" - package = ShareStorage.read_share(share_id) - if not package: - # Create a new package if it doesn't exist - package = KnowledgePackage( - share_id=share_id, - brief=brief, - digest=None, - ) - else: - package.brief = brief - - return ShareStorage.write_share(share_id, package) + share = ShareStorage.read_share(share_id) + if not share: + raise NoShareException + share.brief = brief + return ShareStorage.write_share(share_id, share) @staticmethod - def read_share_log(share_id: str) -> KnowledgePackageLog | None: + def read_share_log(share_id: str) -> ShareLog | None: path = ShareStorageManager.get_share_log_path(share_id) - return read_model(path, KnowledgePackageLog) + return read_model(path, ShareLog) @staticmethod - def write_share_log(share_id: str, log: KnowledgePackageLog) -> pathlib.Path: + def write_share_log(share_id: str, log: ShareLog) -> pathlib.Path: path = ShareStorageManager.get_share_log_path(share_id) write_model(path, log) return path @staticmethod def read_knowledge_digest(share_id: str) -> KnowledgeDigest | None: - package = ShareStorage.read_share(share_id) - return package.digest if package else None + share = ShareStorage.read_share(share_id) + return share.digest if share else None @staticmethod def read_coordinator_conversation( @@ -163,14 +147,6 @@ def append_coordinator_message( ) -> None: """ Appends a message to the Coordinator conversation storage. - - Args: - share_id: The ID of the share - message_id: The ID of the message - content: The message content - sender_name: The name of the sender - is_assistant: Whether the message is from the assistant - timestamp: The timestamp of the message (defaults to now) """ conversation = ShareStorage.read_coordinator_conversation(share_id) if not conversation: @@ -194,75 +170,55 @@ def append_coordinator_message( @staticmethod def write_knowledge_digest(share_id: str, digest: KnowledgeDigest) -> pathlib.Path: - """Writes the knowledge digest to the main share data.""" - package = ShareStorage.read_share(share_id) - if not package: - # Create a new package if it doesn't exist - package = KnowledgePackage( - share_id=share_id, - brief=None, - digest=digest, - ) - else: - package.digest = digest - - return ShareStorage.write_share(share_id, package) + share = ShareStorage.read_share(share_id) + if not share: + raise NoShareException + share.digest = digest + return ShareStorage.write_share(share_id, share) @staticmethod def read_information_request(share_id: str, request_id: str) -> InformationRequest | None: - """Reads an information request from the main share data.""" - package = ShareStorage.read_share(share_id) - if not package or not package.requests: + share = ShareStorage.read_share(share_id) + if not share: + raise NoShareException + if not share.requests: return None - - for request in package.requests: + for request in share.requests: if request.request_id == request_id: return request - return None @staticmethod def write_information_request(share_id: str, request: InformationRequest) -> pathlib.Path: - """Writes an information request to the main share data.""" # Information requests must have an ID if not request.request_id: raise ValueError("Information request must have a request_id") - - package = ShareStorage.read_share(share_id) - if not package: - # Create a new package if it doesn't exist - package = KnowledgePackage( - share_id=share_id, - brief=None, - digest=None, - requests=[request], - ) - else: - # Update existing request or add new one - existing_requests = package.requests or [] - updated = False - for i, existing_request in enumerate(existing_requests): - if existing_request.request_id == request.request_id: - existing_requests[i] = request - updated = True - break - - if not updated: - existing_requests.append(request) - - package.requests = existing_requests - - return ShareStorage.write_share(share_id, package) + share = ShareStorage.read_share(share_id) + if not share: + raise NoShareException + + # Update existing request or add new one + existing_requests = share.requests or [] + updated = False + for i, existing_request in enumerate(existing_requests): + if existing_request.request_id == request.request_id: + existing_requests[i] = request + updated = True + break + if not updated: + existing_requests.append(request) + + share.requests = existing_requests + return ShareStorage.write_share(share_id, share) @staticmethod def get_all_information_requests(share_id: str) -> list[InformationRequest]: - """Gets all information requests from the main share data.""" - package = ShareStorage.read_share(share_id) - if not package: + share = ShareStorage.read_share(share_id) + if not share: return [] # Sort by updated_at timestamp, newest first - requests = package.requests or [] + requests = share.requests or [] requests.sort(key=lambda r: r.updated_at, reverse=True) return requests @@ -274,9 +230,9 @@ async def log_share_event( message: str, related_entity_id: str | None = None, metadata: dict[str, Any] | None = None, - ) -> bool: + ) -> None: """ - Logs an event to the knowledge transfer log. + Logs an event to the log. Args: context: Current conversation context @@ -289,35 +245,31 @@ async def log_share_event( Returns: True if the log entry was added successfully, False otherwise """ - # Get user information user_id, user_name = await get_current_user(context) - if not user_id: - return False - - # Default user name if none found - user_name = user_name or "Unknown User" + return - # Create a log entry entry = LogEntry( entry_type=LogEntryType(entry_type), message=message, user_id=user_id, - user_name=user_name, + user_name=user_name or "Unknown User", related_entity_id=related_entity_id, metadata=metadata or {}, ) - # Get existing log or create a new one - log = ShareStorage.read_share_log(share_id) - if not log: - log = KnowledgePackageLog( - entries=[], + try: + log = ShareStorage.read_share_log(share_id) + if not log: + log = ShareLog( + entries=[], + ) + log.entries.append(entry) + ShareStorage.write_share_log(share_id, log) + except Exception as e: + logger.exception( + f"Failed to log share event for share {share_id}: {e}", + exc_info=True, ) - - # Add the entry and update metadata - log.entries.append(entry) - - # Save the updated log - ShareStorage.write_share_log(share_id, log) - return True + return + return diff --git a/assistants/knowledge-transfer-assistant/assistant/tools/share_setup.py b/assistants/knowledge-transfer-assistant/assistant/tools/share_setup.py index cf97a28cc..1a3c0d435 100644 --- a/assistants/knowledge-transfer-assistant/assistant/tools/share_setup.py +++ b/assistants/knowledge-transfer-assistant/assistant/tools/share_setup.py @@ -1,7 +1,7 @@ """ Project setup tools for Knowledge Transfer Assistant. -Tools for initializing and configuring knowledge packages. +Tools for configuring knowledge shares. """ from datetime import UTC, datetime diff --git a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/__init__.py b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/__init__.py index b3a486502..04dfb8671 100644 --- a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/__init__.py +++ b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/__init__.py @@ -1,8 +1,6 @@ """ -Inspector modules for the Knowledge Transfer Assistant. - -This package contains the tabbed inspector implementations that provide -different views of the knowledge transfer state in the workbench UI. +Inspector modules for the Knowledge Transfer Assistant. Provide different views +of the knowledge transfer state in the workbench UI. """ from .brief import BriefInspector diff --git a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/common.py b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/common.py index 9a7267225..6b14d397b 100644 --- a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/common.py +++ b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/common.py @@ -2,7 +2,7 @@ Common utilities for inspector modules. """ -from assistant.data import KnowledgePackage, RequestPriority, RequestStatus +from assistant.data import RequestPriority, RequestStatus, Share from assistant.domain import TransferManager @@ -29,47 +29,47 @@ def get_priority_emoji(priority: RequestPriority) -> str: return priority_emojis.get(priority, "🔹") # Default to low priority emoji -def get_stage_label(package: KnowledgePackage, for_coordinator: bool = True) -> str: +def get_stage_label(share: Share, for_coordinator: bool = True) -> str: """ - Get a human-readable stage label based on current package state. + Get a human-readable stage label based on current share state. Args: - package: The knowledge package to get label for + share: The knowledge share to get label for for_coordinator: Whether to return coordinator-focused or team-focused labels Returns: str: Stage label with emoji """ - if package.archived: + if share.archived: return "📦 Archived" if for_coordinator: # Coordinator perspective - if not package.audience: + if not share.audience: return "🎯 Defining Audience" - elif not package.knowledge_organized: + elif not share.knowledge_organized: return "📋 Organizing Knowledge" - elif not package.brief: + elif not share.brief: return "📝 Creating Brief" - elif package.is_intended_to_accomplish_outcomes and not package.learning_objectives: + elif share.is_intended_to_accomplish_outcomes and not share.learning_objectives: return "📚 Adding Objectives" - elif not TransferManager.is_ready_for_transfer(package): + elif not TransferManager.is_ready_for_transfer(share): return "📋 Finalizing Setup" - elif package.is_intended_to_accomplish_outcomes and TransferManager._is_transfer_complete(package): + elif share.is_intended_to_accomplish_outcomes and TransferManager._is_transfer_complete(share): return "✅ Transfer Complete" - elif TransferManager.is_actively_sharing(package): + elif TransferManager.is_actively_sharing(share): return "📤 Sharing in Progress" else: return "🚀 Ready for Transfer" else: # Team perspective - if package.archived: + if share.archived: return "📦 Archived" - elif not TransferManager.is_ready_for_transfer(package): + elif not TransferManager.is_ready_for_transfer(share): return "⏳ Knowledge Being Organized" - elif not package.is_intended_to_accomplish_outcomes: + elif not share.is_intended_to_accomplish_outcomes: return "🔍 Exploring Knowledge" - elif package.is_intended_to_accomplish_outcomes: + elif share.is_intended_to_accomplish_outcomes: return "🎯 Active Learning" else: return "🎯 Active Learning" diff --git a/assistants/knowledge-transfer-assistant/tests/test_artifact_loading.py b/assistants/knowledge-transfer-assistant/tests/test_artifact_loading.py index 454308d09..a1cca2f6e 100644 --- a/assistants/knowledge-transfer-assistant/tests/test_artifact_loading.py +++ b/assistants/knowledge-transfer-assistant/tests/test_artifact_loading.py @@ -13,9 +13,9 @@ from assistant.data import ( ConversationRole, KnowledgeBrief, - KnowledgePackage, LearningObjective, LearningOutcome, + Share, ) from assistant.domain import KnowledgeBriefManager, ShareManager from assistant.storage import ShareStorage, ShareStorageManager @@ -123,7 +123,7 @@ def create_test_brief(self): ) # Create a project with the goal - project = KnowledgePackage( + project = Share( share_id="test-share-id", brief=brief, learning_objectives=[test_goal], diff --git a/assistants/knowledge-transfer-assistant/tests/test_share_manager.py b/assistants/knowledge-transfer-assistant/tests/test_share_manager.py index 3184d9f8e..4ad7eaaab 100644 --- a/assistants/knowledge-transfer-assistant/tests/test_share_manager.py +++ b/assistants/knowledge-transfer-assistant/tests/test_share_manager.py @@ -6,9 +6,9 @@ import pytest from assistant.data import ( - KnowledgePackage, LearningObjective, LearningOutcome, + Share, ) from assistant.domain import LearningObjectivesManager from semantic_workbench_assistant.assistant_app import ConversationContext @@ -36,7 +36,7 @@ async def disabled_test_delete_project_goal(self, context, monkeypatch): goal_description = "Test Description" # Create a test project with multiple goals - test_project = KnowledgePackage( + test_project = Share( share_id=project_id, brief=None, learning_objectives=[ @@ -208,7 +208,7 @@ async def disabled_test_delete_project_goal_invalid_index(self, context, monkeyp objective_index = "invalid-objective-id" # Invalid ID # Create a test project with fewer goals than the index - test_project = KnowledgePackage( + test_project = Share( share_id=project_id, brief=None, learning_objectives=[ diff --git a/assistants/knowledge-transfer-assistant/tests/test_share_storage.py b/assistants/knowledge-transfer-assistant/tests/test_share_storage.py index f4d2ec1d8..e1a55a531 100644 --- a/assistants/knowledge-transfer-assistant/tests/test_share_storage.py +++ b/assistants/knowledge-transfer-assistant/tests/test_share_storage.py @@ -17,14 +17,14 @@ InspectorTab, KnowledgeBrief, KnowledgeDigest, - KnowledgePackage, - KnowledgePackageLog, LearningObjective, LearningOutcome, LogEntry, LogEntryType, RequestPriority, RequestStatus, + Share, + ShareLog, ) from assistant.domain.share_manager import ShareManager from assistant.notifications import Notifications @@ -128,7 +128,7 @@ def create_test_project_data(self): ) # Create a KnowledgePackage with the goal and brief - project = KnowledgePackage( + project = Share( share_id=self.share_id, coordinator_conversation_id=self.conversation_id, brief=brief, @@ -196,7 +196,7 @@ async def test_read_information_request(self): async def test_write_project_log(self): """Test writing a project log.""" # Create a log entry and proper LogEntry objects - log_entry = KnowledgePackageLog( + log_entry = ShareLog( entries=[ LogEntry( id=str(uuid.uuid4()), @@ -458,7 +458,7 @@ async def test_log_project_event(self): ) # Create a log with the entry - log = KnowledgePackageLog(entries=[log_entry]) + log = ShareLog(entries=[log_entry]) # Write the log directly ShareStorage.write_share_log(self.share_id, log) From 476c3a8e8181d5b7bc58e8d8ea9fb5cd2003dff2 Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Wed, 30 Jul 2025 16:12:50 -0700 Subject: [PATCH 14/21] Removes duplicate method. --- .../assistant/domain/audience_manager.py | 2 +- .../domain/information_request_manager.py | 17 ++++++-------- .../domain/knowledge_brief_manager.py | 6 ++--- .../domain/knowledge_digest_manager.py | 16 +++----------- .../domain/learning_objectives_manager.py | 22 +++++-------------- .../assistant/notifications.py | 2 +- .../assistant/tools/progress_tracking.py | 3 +-- .../assistant/utils.py | 7 ------ 8 files changed, 22 insertions(+), 53 deletions(-) diff --git a/assistants/knowledge-transfer-assistant/assistant/domain/audience_manager.py b/assistants/knowledge-transfer-assistant/assistant/domain/audience_manager.py index a8274379b..fa5e63973 100644 --- a/assistants/knowledge-transfer-assistant/assistant/domain/audience_manager.py +++ b/assistants/knowledge-transfer-assistant/assistant/domain/audience_manager.py @@ -38,4 +38,4 @@ async def update_audience( ) await Notifications.notify(context, "Audience updated.") - await Notifications.notify_all_state_update(context, share.share_id, [InspectorTab.DEBUG]) + await Notifications.notify_all_state_update(context, [InspectorTab.DEBUG]) diff --git a/assistants/knowledge-transfer-assistant/assistant/domain/information_request_manager.py b/assistants/knowledge-transfer-assistant/assistant/domain/information_request_manager.py index 4098ccec3..098cb5b5a 100644 --- a/assistants/knowledge-transfer-assistant/assistant/domain/information_request_manager.py +++ b/assistants/knowledge-transfer-assistant/assistant/domain/information_request_manager.py @@ -18,7 +18,7 @@ from assistant.logging import logger from assistant.notifications import Notifications from assistant.storage import ShareStorage -from assistant.utils import require_current_user +from assistant.utils import get_current_user_id from .share_manager import ShareManager @@ -43,10 +43,7 @@ async def create_information_request( related_objective_ids: list[str] | None = None, ) -> InformationRequest: share_id = await ShareManager.get_share_id(context) - - current_user_id = await require_current_user(context, "create information request") - if not current_user_id: - raise ValueError("Could not identify current user") + current_user_id = await get_current_user_id(context) information_request = InformationRequest( title=title, @@ -72,7 +69,7 @@ async def create_information_request( ) await Notifications.notify_self_and_other(context, share_id, f"Information request '{title}' was created") - await Notifications.notify_all_state_update(context, share_id, [InspectorTab.SHARING]) + await Notifications.notify_all_state_update(context, [InspectorTab.SHARING]) return information_request @@ -83,7 +80,7 @@ async def resolve_information_request( resolution: str, ) -> InformationRequest: share_id = await ShareManager.get_share_id(context) - current_user_id = await require_current_user(context, "resolve information request") + current_user_id = await get_current_user_id(context) information_request = ShareStorage.read_information_request(share_id, request_id) if not information_request: @@ -139,7 +136,7 @@ async def resolve_information_request( }, ) - await Notifications.notify_all_state_update(context, share_id, [InspectorTab.SHARING]) + await Notifications.notify_all_state_update(context, [InspectorTab.SHARING]) await Notifications.notify_self_and_other( context, share_id, @@ -165,7 +162,7 @@ async def delete_information_request( Success message """ share_id = await ShareManager.get_share_id(context) - current_user_id = await require_current_user(context, "delete information request") + current_user_id = await get_current_user_id(context) # Get information request by ID cleaned_request_id = request_id.strip().replace('"', "").replace("'", "") @@ -213,6 +210,6 @@ async def delete_information_request( share_id, f"Information request '{request_title}' has been deleted.", ) - await Notifications.notify_all_state_update(context, share_id, [InspectorTab.SHARING]) + await Notifications.notify_all_state_update(context, [InspectorTab.SHARING]) return f"Information request '{request_title}' has been successfully deleted." diff --git a/assistants/knowledge-transfer-assistant/assistant/domain/knowledge_brief_manager.py b/assistants/knowledge-transfer-assistant/assistant/domain/knowledge_brief_manager.py index 0fa1d3827..ca570ff3d 100644 --- a/assistants/knowledge-transfer-assistant/assistant/domain/knowledge_brief_manager.py +++ b/assistants/knowledge-transfer-assistant/assistant/domain/knowledge_brief_manager.py @@ -9,7 +9,7 @@ from assistant.data import InspectorTab, KnowledgeBrief, LogEntryType from assistant.notifications import Notifications from assistant.storage import ShareStorage -from assistant.utils import require_current_user +from assistant.utils import get_current_user_id from .share_manager import ShareManager @@ -32,7 +32,7 @@ async def update_knowledge_brief( timeline: str | None = None, ) -> KnowledgeBrief: share_id = await ShareManager.get_share_id(context) - current_user_id = await require_current_user(context, "update brief") + current_user_id = await get_current_user_id(context) brief = KnowledgeBrief( title=title, @@ -63,6 +63,6 @@ async def update_knowledge_brief( ) await Notifications.notify_all(context, share_id, "Knowledge brief has been updated") - await Notifications.notify_all_state_update(context, share_id, [InspectorTab.BRIEF]) + await Notifications.notify_all_state_update(context, [InspectorTab.BRIEF]) return brief diff --git a/assistants/knowledge-transfer-assistant/assistant/domain/knowledge_digest_manager.py b/assistants/knowledge-transfer-assistant/assistant/domain/knowledge_digest_manager.py index d70fa7f77..3ebadb8b4 100644 --- a/assistants/knowledge-transfer-assistant/assistant/domain/knowledge_digest_manager.py +++ b/assistants/knowledge-transfer-assistant/assistant/domain/knowledge_digest_manager.py @@ -15,7 +15,7 @@ from assistant.data import InspectorTab, KnowledgeDigest, LogEntryType from assistant.notifications import Notifications from assistant.storage import ShareStorage -from assistant.utils import require_current_user +from assistant.utils import get_current_user_id from .share_manager import ShareManager @@ -39,12 +39,7 @@ async def update_knowledge_digest( is_auto_generated: bool = True, ) -> KnowledgeDigest: share_id = await ShareManager.get_share_id(context) - if not share_id: - raise ValueError("Cannot update knowledge digest: no share associated with this conversation") - - current_user_id = await require_current_user(context, "update knowledge digest") - if not current_user_id: - raise ValueError("Could not identify current user") + current_user_id = await get_current_user_id(context) digest = ShareStorage.read_knowledge_digest(share_id) is_new = False @@ -78,7 +73,6 @@ async def update_knowledge_digest( await Notifications.notify_all_state_update( context, - share_id, [InspectorTab.BRIEF], ) @@ -94,11 +88,7 @@ async def auto_update_knowledge_digest( messages = await context.get_messages() chat_history = messages.messages - share_id = await ShareManager.get_share_id(context) - if not share_id: - raise ValueError("Cannot auto-update knowledge digest: no share associated with this conversation") - - current_user_id = await require_current_user(context, "auto-update knowledge digest") + current_user_id = await get_current_user_id(context) if not current_user_id: raise ValueError("Could not identify current user") diff --git a/assistants/knowledge-transfer-assistant/assistant/domain/learning_objectives_manager.py b/assistants/knowledge-transfer-assistant/assistant/domain/learning_objectives_manager.py index 146d816a6..a075941bc 100644 --- a/assistants/knowledge-transfer-assistant/assistant/domain/learning_objectives_manager.py +++ b/assistants/knowledge-transfer-assistant/assistant/domain/learning_objectives_manager.py @@ -57,7 +57,7 @@ async def add_learning_objective( ) await Notifications.notify_all(context, share_id, f"Learning objective '{objective_name}' was added") - await Notifications.notify_all_state_update(context, share_id, [InspectorTab.LEARNING, InspectorTab.BRIEF]) + await Notifications.notify_all_state_update(context, [InspectorTab.LEARNING, InspectorTab.BRIEF]) return new_learning_objective @@ -120,9 +120,7 @@ async def update_learning_objective( await Notifications.notify_all( context, share.share_id, f"Learning objective '{objective.name}' has been updated" ) - await Notifications.notify_all_state_update( - context, share.share_id, [InspectorTab.LEARNING, InspectorTab.BRIEF] - ) + await Notifications.notify_all_state_update(context, [InspectorTab.LEARNING, InspectorTab.BRIEF]) return f"Learning objective '{objective.name}' has been successfully updated: {changes_text}." @@ -183,9 +181,7 @@ async def delete_learning_objective( await Notifications.notify_all( context, share.share_id, f"Learning objective '{objective_name}' has been deleted" ) - await Notifications.notify_all_state_update( - context, share.share_id, [InspectorTab.LEARNING, InspectorTab.BRIEF] - ) + await Notifications.notify_all_state_update(context, [InspectorTab.LEARNING, InspectorTab.BRIEF]) return f"Learning objective '{objective_name}' has been successfully deleted from the knowledge share." @@ -248,9 +244,7 @@ async def add_learning_outcome( share.share_id, f"Learning outcome '{outcome_description}' has been added", ) - await Notifications.notify_all_state_update( - context, share.share_id, [InspectorTab.LEARNING, InspectorTab.BRIEF] - ) + await Notifications.notify_all_state_update(context, [InspectorTab.LEARNING, InspectorTab.BRIEF]) return f"Learning outcome added successfully to objective '{objective.name}': {outcome_description}" @@ -311,9 +305,7 @@ async def update_learning_outcome( await Notifications.notify_all( context, share.share_id, f"Learning outcome '{new_description}' has been updated" ) - await Notifications.notify_all_state_update( - context, share.share_id, [InspectorTab.LEARNING, InspectorTab.BRIEF] - ) + await Notifications.notify_all_state_update(context, [InspectorTab.LEARNING, InspectorTab.BRIEF]) return f"Learning outcome updated successfully in objective '{objective.name}': {new_description}" @@ -385,9 +377,7 @@ async def delete_learning_outcome( share.share_id, f"Learning outcome '{deleted_description}' has been removed", ) - await Notifications.notify_all_state_update( - context, share.share_id, [InspectorTab.LEARNING, InspectorTab.BRIEF] - ) + await Notifications.notify_all_state_update(context, [InspectorTab.LEARNING, InspectorTab.BRIEF]) return f"Learning outcome deleted successfully from objective '{objective.name}': {deleted_description}" diff --git a/assistants/knowledge-transfer-assistant/assistant/notifications.py b/assistants/knowledge-transfer-assistant/assistant/notifications.py index 7d3f6b32d..910d9333b 100644 --- a/assistants/knowledge-transfer-assistant/assistant/notifications.py +++ b/assistants/knowledge-transfer-assistant/assistant/notifications.py @@ -118,7 +118,7 @@ async def notify_state_update(context: ConversationContext, tabs: list[Inspector await context.send_conversation_state_event(state_event) @staticmethod - async def notify_all_state_update(context: ConversationContext, share_id: str, tabs: list[InspectorTab]) -> None: + async def notify_all_state_update(context: ConversationContext, tabs: list[InspectorTab]) -> None: """Send state update notifications to refresh UI across all share conversations.""" # Refresh current conversation first diff --git a/assistants/knowledge-transfer-assistant/assistant/tools/progress_tracking.py b/assistants/knowledge-transfer-assistant/assistant/tools/progress_tracking.py index 3373c7c77..f6dd83214 100644 --- a/assistants/knowledge-transfer-assistant/assistant/tools/progress_tracking.py +++ b/assistants/knowledge-transfer-assistant/assistant/tools/progress_tracking.py @@ -121,7 +121,6 @@ async def mark_learning_outcome_achieved(self, objective_id: str, outcome_id: st ) await Notifications.notify_all_state_update( self.context, - share.share_id, [InspectorTab.LEARNING, InspectorTab.BRIEF], ) @@ -194,7 +193,7 @@ async def report_transfer_completion(self) -> str: share.share_id, "🎉 **Knowledge Transfer Complete**: Team has reported that all learning objectives have been achieved. The knowledge transfer is now complete.", # noqa: E501 ) - await Notifications.notify_all_state_update(self.context, share.share_id, [InspectorTab.BRIEF]) + await Notifications.notify_all_state_update(self.context, [InspectorTab.BRIEF]) await self.context.send_messages( NewConversationMessage( diff --git a/assistants/knowledge-transfer-assistant/assistant/utils.py b/assistants/knowledge-transfer-assistant/assistant/utils.py index 376b0e122..37b1bbb09 100644 --- a/assistants/knowledge-transfer-assistant/assistant/utils.py +++ b/assistants/knowledge-transfer-assistant/assistant/utils.py @@ -56,10 +56,3 @@ async def get_current_user( async def get_current_user_id(context: ConversationContext) -> str: user_id, _ = await get_current_user(context) return user_id - - -async def require_current_user(context: ConversationContext, operation_name: str) -> str: - user_id = await get_current_user_id(context) - if not user_id: - raise NoUserException - return user_id From bb38ecfb9d214675c03320fa29f3b87fe26484c2 Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Wed, 30 Jul 2025 16:13:07 -0700 Subject: [PATCH 15/21] Adds consumer communication preferences tool. --- .../assistant/assistant.py | 44 ++++++++------- .../assistant/config.py | 25 +++++++-- .../assistant/data.py | 13 ++++- .../conversation_preferences_manager.py | 54 +++++++++++++++++++ .../assistant/domain/share_manager.py | 30 +++++------ .../assistant/respond.py | 5 ++ .../assistant/storage.py | 24 +++++++-- .../coordinator_instructions.txt | 5 -- .../text_includes/team_instructions.txt | 4 -- .../assistant/tools/__init__.py | 6 +++ .../tools/conversation_preferences.py | 30 +++++++++++ .../assistant/ui_tabs/debug.py | 9 ++-- 12 files changed, 190 insertions(+), 59 deletions(-) create mode 100644 assistants/knowledge-transfer-assistant/assistant/domain/conversation_preferences_manager.py create mode 100644 assistants/knowledge-transfer-assistant/assistant/tools/conversation_preferences.py diff --git a/assistants/knowledge-transfer-assistant/assistant/assistant.py b/assistants/knowledge-transfer-assistant/assistant/assistant.py index 2b54fb2c3..768550b64 100644 --- a/assistants/knowledge-transfer-assistant/assistant/assistant.py +++ b/assistants/knowledge-transfer-assistant/assistant/assistant.py @@ -106,13 +106,21 @@ async def on_conversation_created(context: ConversationContext) -> None: """ conversation = await context.get_conversation() + + # We can't pick up the role from the share data yet, so + # we need to determine the role based on the conversation metadata. conversation_metadata = conversation.metadata or {} share_id = conversation_metadata.get("share_id") - - config = await assistant_config.get(context.assistant) - conversation_type = await ShareManager.get_conversation_role(context) - - match conversation_type: + if not share_id: + role = ConversationRole.COORDINATOR + else: + if conversation.imported_from_conversation_id: + role = ConversationRole.TEAM + else: + role = ConversationRole.SHAREABLE_TEMPLATE + + # Now handle the new conversation based on its role. + match role: case ConversationRole.COORDINATOR: try: # In the beginning, we created a share... @@ -121,6 +129,7 @@ async def on_conversation_created(context: ConversationContext) -> None: # And it was good. So we then created a sharable conversation that we use as a template. share_url = await ShareManager.create_shareable_team_conversation(context=context, share_id=share_id) + config = await assistant_config.get(context.assistant) welcome_message = config.coordinator_config.welcome_message.format( share_url=share_url or "" ) @@ -199,8 +208,8 @@ async def on_conversation_updated(context: ConversationContext) -> None: """ try: conversation = await context.get_conversation() - conversation_type = await ShareManager.get_conversation_role(context) - if conversation_type != ConversationRole.COORDINATOR: + role = await ShareManager.get_conversation_role(context) + if role != ConversationRole.COORDINATOR: return shared_conversation_id = await ShareManager.get_shared_conversation_id(context) @@ -315,7 +324,6 @@ async def on_command_created( metadata=metadata, ) finally: - # update the participant status to indicate the assistant is done thinking await context.update_participant_me(UpdateParticipant(status=None)) @@ -336,11 +344,11 @@ async def on_file_created( 1. Use as-is without copying to share storage """ try: - share = await ShareManager.get_share(context) - if not share or not file.filename: + if not file.filename: logger.warning(f"No share found or missing filename. filename={file.filename}") return + share = await ShareManager.get_share(context) role = await ShareManager.get_conversation_role(context) # Process based on role @@ -373,7 +381,7 @@ async def on_file_created( ) # 3. Update all UIs but don't send notifications to reduce noise - await Notifications.notify_all_state_update(context, share.share_id, [InspectorTab.DEBUG]) + await Notifications.notify_all_state_update(context, [InspectorTab.DEBUG]) # Team files don't need special handling as they're already in the conversation # Log file creation to knowledge transfer log for all files @@ -399,11 +407,10 @@ async def on_file_updated( file: workbench_model.File, ) -> None: try: - # Get share ID - share = await ShareManager.get_share(context) - if not share or not file.filename: + if not file.filename: return + share = await ShareManager.get_share(context) role = await ShareManager.get_conversation_role(context) if role == ConversationRole.COORDINATOR: # For Coordinator files: @@ -429,7 +436,7 @@ async def on_file_updated( ) # 3. Update all UIs but don't send notifications to reduce noise - await Notifications.notify_all_state_update(context, share.share_id, [InspectorTab.DEBUG]) + await Notifications.notify_all_state_update(context, [InspectorTab.DEBUG]) await ShareManager.log_share_event( context=context, @@ -453,11 +460,10 @@ async def on_file_deleted( file: workbench_model.File, ) -> None: try: - # Get share ID - share = await ShareManager.get_share(context) - if not share or not file.filename: + if not file.filename: return + share = await ShareManager.get_share(context) role = await ShareManager.get_conversation_role(context) if role == ConversationRole.COORDINATOR: # For Coordinator files: @@ -470,7 +476,7 @@ async def on_file_deleted( logger.error(f"Failed to delete file from share storage: {file.filename}") # 2. Update all UIs about the deletion but don't send notifications to reduce noise - await Notifications.notify_all_state_update(context, share.share_id, [InspectorTab.DEBUG]) + await Notifications.notify_all_state_update(context, [InspectorTab.DEBUG]) # Team files don't need special handling await ShareManager.log_share_event( diff --git a/assistants/knowledge-transfer-assistant/assistant/config.py b/assistants/knowledge-transfer-assistant/assistant/config.py index 6b4edb8b3..43fc44232 100644 --- a/assistants/knowledge-transfer-assistant/assistant/config.py +++ b/assistants/knowledge-transfer-assistant/assistant/config.py @@ -68,8 +68,9 @@ class PromptConfig(BaseModel): "coordinator_instructions", "team_role", "team_instructions", - "knowledge_digest_prompt", "share_information_request_detection", + "knowledge_digest_prompt", + "welcome_message_generation", ], }, ) @@ -142,7 +143,7 @@ class CoordinatorConfig(BaseModel): model_config = ConfigDict( title="Coordinator Configuration", json_schema_extra={ - "required": ["welcome_message", "prompt_for_files"], + "required": ["welcome_message", "preferred_communication_style", "max_digest_tokens"], }, ) @@ -180,12 +181,21 @@ class CoordinatorConfig(BaseModel): ), ] = 4_096 + preferred_communication_style: Annotated[ + str, + Field( + title="Preferred Communication Style", + description="The preferred communication style for the assistant. This is used to tailor responses.", + ), + UISchema(widget="textarea"), + ] = "Speak plainly. Keep your responses short and concise to create a more collaborative dynamic. Use no filler words or unnecessary content." # noqa: E501 + class TeamConfig(BaseModel): model_config = ConfigDict( title="Team Member Configuration", json_schema_extra={ - "required": ["default_welcome_message"], + "required": ["default_welcome_message", "preferred_communication_style"], }, ) @@ -198,6 +208,15 @@ class TeamConfig(BaseModel): UISchema(widget="textarea"), ] = "# Welcome to Your Team Conversation\n\nYou've joined as a team member. This is your personal conversation for exploring the knowledge share. You can communicate with the assistant, make information requests, and track your progress here." # noqa: E501 + preferred_communication_style: Annotated[ + str, + Field( + title="Preferred Communication Style", + description="The preferred communication style for the assistant. This is used to tailor responses.", + ), + UISchema(widget="textarea"), + ] = "Speak plainly. Keep your responses short and concise to create a more collaborative dynamic. Use no filler words or unnecessary content. Users tend to not want to read long answers and will skip over text. Let the user ask for longer information as needed." # noqa: E501 + # Base Assistant Configuration - shared by all templates class AssistantConfigModel(BaseModel): diff --git a/assistants/knowledge-transfer-assistant/assistant/data.py b/assistants/knowledge-transfer-assistant/assistant/data.py index d37dd707b..2ae12a36b 100644 --- a/assistants/knowledge-transfer-assistant/assistant/data.py +++ b/assistants/knowledge-transfer-assistant/assistant/data.py @@ -25,6 +25,15 @@ class ConversationShareInfo(BaseModel): role: ConversationRole +class ConversationPreferences(BaseModel): + """ + Preferences for a conversation, such as preferred communication style. + This is used to store user preferences for how they want to interact with the assistant. + """ + + preferred_communication_style: str | None = None + + class InspectorTab(str, Enum): BRIEF = "brief" LEARNING = "learning" @@ -178,9 +187,9 @@ class Share(BaseModel): # Knowledge Package components audience: str | None = None - brief: KnowledgeBrief | None - learning_objectives: list[LearningObjective] = Field(default_factory=list) takeaways: list[str] = Field(default_factory=list) + learning_objectives: list[LearningObjective] = Field(default_factory=list) + brief: KnowledgeBrief | None preferred_communication_style: str | None = None transfer_notes: str | None = None digest: KnowledgeDigest | None diff --git a/assistants/knowledge-transfer-assistant/assistant/domain/conversation_preferences_manager.py b/assistants/knowledge-transfer-assistant/assistant/domain/conversation_preferences_manager.py new file mode 100644 index 000000000..3a28424dd --- /dev/null +++ b/assistants/knowledge-transfer-assistant/assistant/domain/conversation_preferences_manager.py @@ -0,0 +1,54 @@ +from semantic_workbench_assistant.assistant_app import ConversationContext + +from assistant.config import assistant_config +from assistant.data import ConversationPreferences, ConversationRole, InspectorTab +from assistant.domain.share_manager import ShareManager +from assistant.notifications import Notifications +from assistant.storage import ConversationStorage + + +class ConversationPreferencesManager: + @staticmethod + async def update_preferred_communication_style( + context: ConversationContext, + preferred_communication_style: str, + ) -> None: + """ + Update the preferences for a conversation. + """ + preferences = ConversationStorage.read_conversation_preferences(context) + + # Set the default preferences based on the assistant config. + if not preferences: + config = await assistant_config.get(context.assistant) + role = await ShareManager.get_conversation_role(context) + if role == ConversationRole.COORDINATOR: + style = config.coordinator_config.preferred_communication_style + else: + style = config.team_config.preferred_communication_style + preferences = ConversationPreferences( + preferred_communication_style=style, + ) + + preferences.preferred_communication_style = preferred_communication_style.strip() + ConversationStorage.write_conversation_preferences(context, preferences) + + await Notifications.notify(context, "Preferred communication style updated.") + await Notifications.notify_all_state_update(context, [InspectorTab.DEBUG]) + + @staticmethod + async def get_preferred_communication_style(context: ConversationContext) -> str: + """ + Get the preferred communication style for a conversation. + """ + preferences = ConversationStorage.read_conversation_preferences(context) + if preferences and preferences.preferred_communication_style: + return preferences.preferred_communication_style + + # Return the default from the assistant config if not set. + config = await assistant_config.get(context.assistant) + role = await ShareManager.get_conversation_role(context) + if role == ConversationRole.COORDINATOR: + return config.coordinator_config.preferred_communication_style + else: + return config.team_config.preferred_communication_style diff --git a/assistants/knowledge-transfer-assistant/assistant/domain/share_manager.py b/assistants/knowledge-transfer-assistant/assistant/domain/share_manager.py index 0066c58f6..00b5dc544 100644 --- a/assistants/knowledge-transfer-assistant/assistant/domain/share_manager.py +++ b/assistants/knowledge-transfer-assistant/assistant/domain/share_manager.py @@ -127,27 +127,22 @@ async def create_shareable_team_conversation(context: ConversationContext, share share_url: URL for joining a team conversation """ - # Get the current user ID to set as owner - user_id, _ = await get_current_user(context) - if not user_id: - raise ValueError("Cannot create team conversation: no user found") - new_conversation = NewConversation( metadata={ - "is_team_conversation": True, "share_id": share_id, - "setup_complete": True, - "project_role": "team", - "assistant_mode": "team", }, ) - client = context._conversations_client - conversation = await client.create_conversation_with_owner(new_conversation=new_conversation, owner_id=user_id) - + owner_id, _ = await get_current_user(context) + logger.debug(f"Creating shareable conversation with owner {owner_id}") + conversation = await context._conversations_client.create_conversation_with_owner( + new_conversation=new_conversation, owner_id=owner_id + ) if not conversation or not conversation.id: - raise ValueError("Failed to create team conversation") + raise ValueError("Failed to create shareable conversation") + logger.debug(f"Created shareable team conversation: {conversation.id}") - new_share = NewConversationShare( + # Create a share link for the new conversation + new_share_link = NewConversationShare( conversation_id=conversation.id, label="Join Team Conversation", conversation_permission=ConversationPermission.read, @@ -158,12 +153,13 @@ async def create_shareable_team_conversation(context: ConversationContext, share "show_duplicate_action": True, }, ) - share = await context._conversations_client.create_conversation_share_with_owner( - new_conversation_share=new_share, owner_id=user_id + share_link = await context._conversations_client.create_conversation_share_with_owner( + new_conversation_share=new_share_link, owner_id=owner_id ) - share_url = f"/conversation-share/{share.id}/redeem" + share_url = f"/conversation-share/{share_link.id}/redeem" + # Capture the new conversation and conversation share link ids in the share. share = await ShareManager.get_share(context) share.shared_conversation_id = str(conversation.id) share.share_url = share_url diff --git a/assistants/knowledge-transfer-assistant/assistant/respond.py b/assistants/knowledge-transfer-assistant/assistant/respond.py index 23a78b872..75028f59f 100644 --- a/assistants/knowledge-transfer-assistant/assistant/respond.py +++ b/assistants/knowledge-transfer-assistant/assistant/respond.py @@ -26,6 +26,7 @@ ConversationContext, ) +from assistant.domain.conversation_preferences_manager import ConversationPreferencesManager from assistant.domain.learning_objectives_manager import LearningObjectivesManager from assistant.domain.share_manager import ShareManager @@ -158,6 +159,10 @@ async def respond_to_conversation( ) instructions.add_subsection(Instructions(participant_text, "Multi-participant conversation instructions")) + # Add conversation preferences instructions. + communication_style = await ConversationPreferencesManager.get_preferred_communication_style(context) + instructions.add_subsection(Instructions(communication_style, "Preferred Communication Style")) + prompt = Prompt( role=assistant_role, instructions=instructions, diff --git a/assistants/knowledge-transfer-assistant/assistant/storage.py b/assistants/knowledge-transfer-assistant/assistant/storage.py index 459e8395e..afdc6a9dd 100644 --- a/assistants/knowledge-transfer-assistant/assistant/storage.py +++ b/assistants/knowledge-transfer-assistant/assistant/storage.py @@ -14,6 +14,7 @@ # Import inside functions to avoid circular imports from .data import ( + ConversationPreferences, CoordinatorConversationMessage, CoordinatorConversationMessages, InformationRequest, @@ -31,11 +32,9 @@ class ShareStorageManager: """Manages storage paths and access for knowledge transfer data.""" SHARES_ROOT = "shares" - - # File names for knowledge transfer entities SHARE_LOG_FILE = "log.json" COORDINATOR_CONVERSATION_FILE = "coordinator_conversation.json" - SHARE_FILE = "share_data.json" # Main consolidated data file + SHARE_FILE = "share_data.json" @staticmethod def get_shares_root() -> pathlib.Path: @@ -82,6 +81,25 @@ def get_conversation_role_file_path(context: ConversationContext) -> pathlib.Pat return storage_dir / "share_role.json" +class ConversationStorage: + @staticmethod + def get_conversation_preferences_file_path(context: ConversationContext) -> pathlib.Path: + """Gets the path to the file that stores conversation preferences.""" + storage_dir = storage_directory_for_context(context) + storage_dir.mkdir(parents=True, exist_ok=True) + return storage_dir / "conversation_preferences.json" + + @staticmethod + def read_conversation_preferences(context: ConversationContext) -> ConversationPreferences | None: + path = ConversationStorage.get_conversation_preferences_file_path(context) + return read_model(path, ConversationPreferences) + + @staticmethod + def write_conversation_preferences(context: ConversationContext, preferences: ConversationPreferences) -> None: + path = ConversationStorage.get_conversation_preferences_file_path(context) + write_model(path, preferences) + + class ShareStorage: @staticmethod def read_share(share_id: str) -> Share | None: diff --git a/assistants/knowledge-transfer-assistant/assistant/text_includes/coordinator_instructions.txt b/assistants/knowledge-transfer-assistant/assistant/text_includes/coordinator_instructions.txt index 560a95b20..9c7f860ff 100644 --- a/assistants/knowledge-transfer-assistant/assistant/text_includes/coordinator_instructions.txt +++ b/assistants/knowledge-transfer-assistant/assistant/text_includes/coordinator_instructions.txt @@ -1,8 +1,3 @@ -## Tone and Style - -- Be strategic, supportive, and proactive. -- Don't be too wordy. Speak simply. - ## Behavioral Guidelines ### Unresolved Requests diff --git a/assistants/knowledge-transfer-assistant/assistant/text_includes/team_instructions.txt b/assistants/knowledge-transfer-assistant/assistant/text_includes/team_instructions.txt index f834ceed2..4ba923ca7 100644 --- a/assistants/knowledge-transfer-assistant/assistant/text_includes/team_instructions.txt +++ b/assistants/knowledge-transfer-assistant/assistant/text_includes/team_instructions.txt @@ -1,7 +1,3 @@ -## Conversational Style and Tone - -Speak plainly. Keep your responses short and concise by default to create a more collaborative dynamic. Use no filler words or unnecessary content. Users tend to not want to read long answers and will skip over text. Let the user ask for longer information as needed. - ## Stick to the coordinator's shared knowledge! - Stick to the shared knowledge shared as much as possible. diff --git a/assistants/knowledge-transfer-assistant/assistant/tools/__init__.py b/assistants/knowledge-transfer-assistant/assistant/tools/__init__.py index 35d9832ee..7394c3292 100644 --- a/assistants/knowledge-transfer-assistant/assistant/tools/__init__.py +++ b/assistants/knowledge-transfer-assistant/assistant/tools/__init__.py @@ -9,6 +9,7 @@ from semantic_workbench_assistant.assistant_app import ConversationContext from assistant.data import ConversationRole +from assistant.tools.conversation_preferences import ConversationPreferencesTools from .information_requests import InformationRequestTools from .learning_objectives import LearningObjectiveTools @@ -37,6 +38,7 @@ def __init__(self, context: ConversationContext, role: ConversationRole): self.learning_outcomes = LearningOutcomeTools(context, role) self.information_requests = InformationRequestTools(context, role) self.progress_tracking = ProgressTrackingTools(context, role) + self.conversation_preferences = ConversationPreferencesTools(context, role) if role == "coordinator": self._register_coordinator_tools() @@ -103,6 +105,10 @@ def _register_coordinator_tools(self): def _register_team_tools(self): """Register team-specific tools.""" + self.tool_functions.add_function( + self.conversation_preferences.update_preferred_communication_style, + "update_preferred_communication_style", + ) self.tool_functions.add_function( self.information_requests.create_information_request, "create_information_request", diff --git a/assistants/knowledge-transfer-assistant/assistant/tools/conversation_preferences.py b/assistants/knowledge-transfer-assistant/assistant/tools/conversation_preferences.py new file mode 100644 index 000000000..e914c9c51 --- /dev/null +++ b/assistants/knowledge-transfer-assistant/assistant/tools/conversation_preferences.py @@ -0,0 +1,30 @@ +""" +Project setup tools for Knowledge Transfer Assistant. + +Tools for configuring knowledge shares. +""" + +from assistant.domain.conversation_preferences_manager import ConversationPreferencesManager + +from .base import ToolsBase + + +class ConversationPreferencesTools(ToolsBase): + async def update_preferred_communication_style(self, style: str) -> str: + """ + Update the preferred communication style for the conversation. + + Args: + style: The preferred communication style to set. This should include all of the users instructions about how the assistant should communicate with the user. It is not just a single style, but a comprehensive set of instructions. + + Returns: + A message indicating success or failure + """ # noqa: E501 + try: + await ConversationPreferencesManager.update_preferred_communication_style( + context=self.context, + preferred_communication_style=style, + ) + return "Preferred conversation style updated successfully" + except Exception as e: + return f"Failed to update preferred conversation style: {e!s}" diff --git a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/debug.py b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/debug.py index 88e795fae..0f85208fb 100644 --- a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/debug.py +++ b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/debug.py @@ -8,6 +8,7 @@ ) from assistant.domain import KnowledgeDigestManager, ShareManager, TransferManager +from assistant.domain.conversation_preferences_manager import ConversationPreferencesManager class DebugInspector: @@ -54,12 +55,6 @@ async def _format_debug_info(self, share_id: str, context: ConversationContext) # Get the knowledge digest try: digest = await KnowledgeDigestManager.get_knowledge_digest(context) - if not digest: - lines.append("### Knowledge Digest") - lines.append("") - lines.append("No knowledge digest has been generated yet. The assistant will create and update this") - lines.append("automatically as the conversation develops.") - lines.append("") lines.append("## Knowledge Digest") lines.append("") @@ -107,6 +102,8 @@ async def _format_debug_info(self, share_id: str, context: ConversationContext) if share.coordinator_conversation_id: lines.append(f"- **Conversation ID:** `{share.coordinator_conversation_id}`") lines.append("") + style = await ConversationPreferencesManager.get_preferred_communication_style(context) + lines.append(f"- **Preferred Communication Style:** {style}") except Exception as e: lines.append("## Share Metadata") From 7a140142c95d69ef85c53b85df27c3f5b7ac409a Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Wed, 6 Aug 2025 10:44:34 -0700 Subject: [PATCH 16/21] Background thoughts. --- .../assistant/agentic/__init__.py | 13 + .../agentic/coordinator_next_action.py | 115 ++++ .../assistant/agentic/coordinator_support.py | 121 ----- .../agentic/detect_audience_and_takeaways.py | 116 ++++ ...ysis.py => detect_information_requests.py} | 0 .../assistant/agentic/team_welcome.py | 20 +- .../assistant/agentic/update_digest.py | 65 +++ .../assistant/assistant.py | 264 +++++++--- .../assistant/config.py | 76 ++- .../assistant/data.py | 8 +- .../assistant/domain/__init__.py | 2 + .../assistant/domain/audience_manager.py | 25 + .../domain/information_request_manager.py | 6 - .../domain/knowledge_brief_manager.py | 6 - .../domain/knowledge_digest_manager.py | 65 --- .../domain/learning_objectives_manager.py | 6 - .../assistant/domain/share_manager.py | 10 +- .../assistant/domain/thoughts_manager.py | 42 ++ .../assistant/domain/transfer_manager.py | 4 +- .../assistant/prompt_utils.py | 495 ++++++++++++++++++ .../assistant/respond.py | 394 +++----------- .../assistant/storage.py | 26 + .../assistant/string_utils.py | 139 +---- .../background_thought_audience.md | 18 + .../coordinator_instructions.txt | 118 +++-- .../text_includes/coordinator_role.txt | 14 +- .../knowledge_digest_instructions.txt | 4 +- ...prompt.txt => knowledge_digest_update.txt} | 5 +- .../assistant/tools/__init__.py | 15 +- .../assistant/tools/assistant_thoughts.py | 32 ++ .../assistant/tools/learning_objectives.py | 4 +- .../assistant/tools/learning_outcomes.py | 2 +- .../assistant/tools/share_setup.py | 57 +- .../assistant/ui_tabs/brief.py | 87 ++- .../assistant/ui_tabs/common.py | 11 +- .../assistant/ui_tabs/debug.py | 81 ++- .../assistant/ui_tabs/learning.py | 14 +- .../assistant/utils.py | 11 +- .../tests/test_share_tools.py | 2 +- .../openai-client/openai_client/completion.py | 28 +- .../openai-client/openai_client/tools.py | 125 +++-- 41 files changed, 1587 insertions(+), 1059 deletions(-) create mode 100644 assistants/knowledge-transfer-assistant/assistant/agentic/coordinator_next_action.py delete mode 100644 assistants/knowledge-transfer-assistant/assistant/agentic/coordinator_support.py create mode 100644 assistants/knowledge-transfer-assistant/assistant/agentic/detect_audience_and_takeaways.py rename assistants/knowledge-transfer-assistant/assistant/agentic/{analysis.py => detect_information_requests.py} (100%) create mode 100644 assistants/knowledge-transfer-assistant/assistant/agentic/update_digest.py create mode 100644 assistants/knowledge-transfer-assistant/assistant/domain/thoughts_manager.py create mode 100644 assistants/knowledge-transfer-assistant/assistant/prompt_utils.py create mode 100644 assistants/knowledge-transfer-assistant/assistant/text_includes/background_thought_audience.md rename assistants/knowledge-transfer-assistant/assistant/text_includes/{knowledge_digest_prompt.txt => knowledge_digest_update.txt} (87%) create mode 100644 assistants/knowledge-transfer-assistant/assistant/tools/assistant_thoughts.py diff --git a/assistants/knowledge-transfer-assistant/assistant/agentic/__init__.py b/assistants/knowledge-transfer-assistant/assistant/agentic/__init__.py index e69de29bb..3a45c0c77 100644 --- a/assistants/knowledge-transfer-assistant/assistant/agentic/__init__.py +++ b/assistants/knowledge-transfer-assistant/assistant/agentic/__init__.py @@ -0,0 +1,13 @@ +from types import SimpleNamespace + +from .coordinator_next_action import get_coordinator_next_action_suggestion +from .detect_audience_and_takeaways import detect_audience_and_takeaways +from .team_welcome import generate_team_welcome_message +from .update_digest import update_digest + +agentic = SimpleNamespace( + get_coordinator_next_action_suggestion=get_coordinator_next_action_suggestion, + detect_audience_and_takeaways=detect_audience_and_takeaways, + generate_team_welcome_message=generate_team_welcome_message, + update_digest=update_digest, +) diff --git a/assistants/knowledge-transfer-assistant/assistant/agentic/coordinator_next_action.py b/assistants/knowledge-transfer-assistant/assistant/agentic/coordinator_next_action.py new file mode 100644 index 000000000..d70881559 --- /dev/null +++ b/assistants/knowledge-transfer-assistant/assistant/agentic/coordinator_next_action.py @@ -0,0 +1,115 @@ +""" +Coordinator support and helper functions for Knowledge Transfer Assistant. + +Provides next action suggestions and other coordinator utilities. +""" + +from semantic_workbench_assistant.assistant_app import ConversationContext + +from assistant.data import RequestStatus +from assistant.domain import ShareManager, TransferManager +from assistant.logging import logger + + +async def get_coordinator_next_action_suggestion( + context: ConversationContext, +) -> str | None: + """ + Generate next action suggestions for the coordinator based on the knowledge transfer state. + + This output is passed to the assistant and helps guide the conversation toward completing or improving + the knowledge share in a helpful, structured way. + + Returns: + A user-facing suggestion string, or None if no suggestion is needed. + """ + try: + share_id = await ShareManager.get_share_id(context) + if not share_id: + logger.warning("No share ID found for this conversation") + return None + + share = await ShareManager.get_share(context) + if not share: + return None + + brief = share.brief + requests = share.requests + active_requests = [r for r in requests if r.status == RequestStatus.NEW] + + # 1. Unresolved requests come first + if active_requests: + request = active_requests[0] + return ( + f"There are {len(active_requests)} unanswered questions from team members. " + f'One of them is: "{request.title}" Let\'s work on answering it.' + ) + + # 2. Audience not yet defined + if not share.audience: + return ( + "Let's start by defining who your audience is. Who is this knowledge for, and what's their background?" + ) + + # 3. Knowledge not yet organized + # if not share.knowledge_organized: + # return ( + # "Next, let's organize your knowledge. Upload any relevant files or describe the knowledge " + # "you want to transfer. When you're ready, I can mark the knowledge as organized." + # ) + + # 4. Brief not yet written + if not brief: + return ( + "Your knowledge share-out needs a short introduction that will orient your team. " + "Let's write a knowledge brief next. The knowledge brief helps your team understand " + "the purpose of this knowledge transfer and will be visible to all team members in their side panel." + ) + + # 5. If intended to have outcomes but none defined yet + if share.is_intended_to_accomplish_outcomes and not share.learning_objectives: + return ( + "Would you like your team to achieve any specific outcomes? If so, let's define some learning objectives. " # noqa: E501 + "If not, you can mark this share-out as 'exploratory' instead." + ) + + # 6. Objectives exist, but missing outcomes + if share.is_intended_to_accomplish_outcomes: + incomplete_objectives = [obj for obj in share.learning_objectives if not obj.learning_outcomes] + if incomplete_objectives: + name = incomplete_objectives[0].name + return ( + f"The learning objective '{name}' doesn't have any outcomes yet. " + f"Let's define what your team should accomplish to meet it." + ) + + # 7. Ready for transfer but not yet shared + if TransferManager.is_ready_for_transfer(share) and not TransferManager.is_actively_sharing(share): + return ( + "Your knowledge is ready to share. Would you like to create a message and generate the invitation link?" + ) + + # 8. Actively sharing - monitor and support ongoing transfer + if TransferManager.is_actively_sharing(share): + if share.is_intended_to_accomplish_outcomes and not TransferManager._is_transfer_complete(share): + team_count = len(share.team_conversations) + return ( + f"Great! Your knowledge is being shared with {team_count} team member" + f"{'s' if team_count != 1 else ''}. You can continue improving the knowledge share or " + f"respond to information requests as they come in." + ) + else: + return ( + "Your knowledge transfer is in progress. You can continue improving the knowledge share or " + "respond to information requests as they come in." + ) + + # 9. Default: General support + return ( + "Your knowledge share is available. You can continue improving it or " + "respond to new information requests as they come in." + ) + + except Exception as e: + logger.exception(f"Error generating next action suggestion: {e}") + return None diff --git a/assistants/knowledge-transfer-assistant/assistant/agentic/coordinator_support.py b/assistants/knowledge-transfer-assistant/assistant/agentic/coordinator_support.py deleted file mode 100644 index c0bdfbafd..000000000 --- a/assistants/knowledge-transfer-assistant/assistant/agentic/coordinator_support.py +++ /dev/null @@ -1,121 +0,0 @@ -""" -Coordinator support and helper functions for Knowledge Transfer Assistant. - -Provides next action suggestions and other coordinator utilities. -""" - -from semantic_workbench_assistant.assistant_app import ConversationContext - -from assistant.data import RequestStatus -from assistant.domain import ShareManager, TransferManager -from assistant.logging import logger - - -class CoordinatorSupport: - """Provides support utilities for coordinators.""" - - @staticmethod - async def get_coordinator_next_action_suggestion( - context: ConversationContext, - ) -> str | None: - """ - Generate next action suggestions for the coordinator based on the knowledge transfer state. - - This output is passed to the assistant and helps guide the conversation toward completing or improving - the knowledge share in a helpful, structured way. - - Returns: - A user-facing suggestion string, or None if no suggestion is needed. - """ - try: - share_id = await ShareManager.get_share_id(context) - if not share_id: - logger.warning("No share ID found for this conversation") - return None - - share = await ShareManager.get_share(context) - if not share: - return None - - brief = share.brief - requests = share.requests - active_requests = [r for r in requests if r.status == RequestStatus.NEW] - - # 1. Unresolved requests come first - if active_requests: - request = active_requests[0] - return ( - f"There are {len(active_requests)} unanswered questions from team members. " - f'One of them is: "{request.title}" Let\'s work on answering it.' - ) - - # 2. Audience not yet defined - if not share.audience: - return ( - "Let's start by defining who your audience is. " - "Who is this knowledge for, and what's their background?" - ) - - # 3. Knowledge not yet organized - if not share.knowledge_organized: - return ( - "Next, let's organize your knowledge. Upload any relevant files or describe the knowledge " - "you want to transfer. When you're ready, I can mark the knowledge as organized." - ) - - # 4. Brief not yet written - if not brief: - return ( - "Your knowledge share-out needs a short introduction that will orient your team. " - "Let's write a knowledge brief next. The knowledge brief helps your team understand " - "the purpose of this knowledge transfer and will be visible to all team members in their side panel." # noqa: E501 - ) - - # 5. If intended to have outcomes but none defined yet - if share.is_intended_to_accomplish_outcomes and not share.learning_objectives: - return ( - "Would you like your team to achieve any specific outcomes? If so, let's define some learning objectives. " # noqa: E501 - "If not, you can mark this share-out as 'exploratory' instead." - ) - - # 6. Objectives exist, but missing outcomes - if share.is_intended_to_accomplish_outcomes: - incomplete_objectives = [obj for obj in share.learning_objectives if not obj.learning_outcomes] - if incomplete_objectives: - name = incomplete_objectives[0].name - return ( - f"The learning objective '{name}' doesn't have any outcomes yet. " - f"Let's define what your team should accomplish to meet it." - ) - - # 7. Ready for transfer but not yet shared - if TransferManager.is_ready_for_transfer(share) and not TransferManager.is_actively_sharing(share): - return ( - "Your knowledge is ready to share. " - "Would you like to create a message and generate the invitation link?" - ) - - # 8. Actively sharing - monitor and support ongoing transfer - if TransferManager.is_actively_sharing(share): - if share.is_intended_to_accomplish_outcomes and not TransferManager._is_transfer_complete(share): - team_count = len(share.team_conversations) - return ( - f"Great! Your knowledge is being shared with {team_count} team member" - f"{'s' if team_count != 1 else ''}. You can continue improving the knowledge share or " - f"respond to information requests as they come in." - ) - else: - return ( - "Your knowledge transfer is in progress. You can continue improving the knowledge share or " - "respond to information requests as they come in." - ) - - # 9. Default: General support - return ( - "Your knowledge share is available. You can continue improving it or " - "respond to new information requests as they come in." - ) - - except Exception as e: - logger.exception(f"Error generating next action suggestion: {e}") - return None diff --git a/assistants/knowledge-transfer-assistant/assistant/agentic/detect_audience_and_takeaways.py b/assistants/knowledge-transfer-assistant/assistant/agentic/detect_audience_and_takeaways.py new file mode 100644 index 000000000..3dbfae481 --- /dev/null +++ b/assistants/knowledge-transfer-assistant/assistant/agentic/detect_audience_and_takeaways.py @@ -0,0 +1,116 @@ +""" +Analysis and detection functions for the knowledge transfer assistant. + +This module contains functions for analyzing messages and knowledge transfer content +to detect specific conditions, such as information request needs. +""" + +from typing import Any + +import openai_client +from assistant_extensions.attachments import AttachmentsExtension +from pydantic import BaseModel +from semantic_workbench_assistant.assistant_app import ConversationContext + +from assistant.config import assistant_config +from assistant.data import InspectorTab +from assistant.domain.share_manager import ShareManager +from assistant.domain.thoughts_manager import ThoughtsManager +from assistant.logging import logger +from assistant.notifications import Notifications +from assistant.prompt_utils import ( + ContextSection, + ContextStrategy, + Instructions, + Prompt, + add_context_to_prompt, +) +from assistant.utils import load_text_include + + +async def detect_audience_and_takeaways( + context: ConversationContext, attachments_extension: AttachmentsExtension +) -> None: + debug: dict[str, Any] = { + "context": context, + } + + config = await assistant_config.get(context.assistant) + + # Set up prompt instructions. + audience_instructions = load_text_include("background_thought_audience.md") + instructions = Instructions(audience_instructions) + prompt = Prompt( + role=config.prompt_config.coordinator_role, + instructions=instructions, + context_strategy=ContextStrategy.MULTI, + ) + + # Add prompt context. + role = await ShareManager.get_conversation_role(context) + await add_context_to_prompt( + prompt, + context=context, + role=role, + model=config.request_config.openai_model, + token_limit=config.request_config.max_tokens, + attachments_extension=attachments_extension, + attachments_config=config.attachments_config, + attachments_in_system_message=True, + include=[ + # ContextSection.KNOWLEDGE_INFO, + # ContextSection.KNOWLEDGE_BRIEF, + ContextSection.TARGET_AUDIENCE, + ContextSection.LEARNING_OBJECTIVES, + # ContextSection.KNOWLEDGE_DIGEST, + # ContextSection.INFORMATION_REQUESTS, + # ContextSection.SUGGESTED_NEXT_ACTIONS, + ContextSection.COORDINATOR_CONVERSATION, + ContextSection.ATTACHMENTS, + ContextSection.ASSISTANT_THOUGHTS, + ], + ) + + class Output(BaseModel): + """Output class to hold the generated thoughts.""" + + thoughts: list[ + str + ] # Generated thoughts about the audience and takeaways. One thought per item. If there are no thoughts, this will be an empty list. #noqa: E501 + + # Chat completion + async with openai_client.create_client(config.service_config) as client: + try: + completion_args = { + "messages": prompt.messages(), + "model": config.request_config.openai_model, + "max_tokens": 500, + "temperature": 0.8, + "response_format": Output, + } + debug["completion_args"] = openai_client.make_completion_args_serializable(completion_args) + + # LLM call + response = await client.beta.chat.completions.parse( + **completion_args, + ) + openai_client.validate_completion(response) + debug["completion_response"] = openai_client.add_serializable_data(response.model_dump()) + + # Response + if response and response.choices and response.choices[0].message.parsed: + output: Output = response.choices[0].message.parsed + if output.thoughts: + await ThoughtsManager.add_assistant_thoughts(context, output.thoughts) + await Notifications.notify_state_update( + context, + [InspectorTab.BRIEF], + ) + else: + logger.warning("Empty response from LLM for welcome message generation") + + except Exception as e: + logger.exception(f"Failed to make OpenIA call: {e}") + debug["error"] = str(e) + + logger.debug(f"{__name__}: {debug}") diff --git a/assistants/knowledge-transfer-assistant/assistant/agentic/analysis.py b/assistants/knowledge-transfer-assistant/assistant/agentic/detect_information_requests.py similarity index 100% rename from assistants/knowledge-transfer-assistant/assistant/agentic/analysis.py rename to assistants/knowledge-transfer-assistant/assistant/agentic/detect_information_requests.py diff --git a/assistants/knowledge-transfer-assistant/assistant/agentic/team_welcome.py b/assistants/knowledge-transfer-assistant/assistant/agentic/team_welcome.py index 0b0c78185..1bab50d16 100644 --- a/assistants/knowledge-transfer-assistant/assistant/agentic/team_welcome.py +++ b/assistants/knowledge-transfer-assistant/assistant/agentic/team_welcome.py @@ -15,6 +15,7 @@ from assistant.config import assistant_config from assistant.domain.share_manager import ShareManager from assistant.logging import logger +from assistant.utils import load_text_include async def generate_team_welcome_message( @@ -64,23 +65,7 @@ async def generate_team_welcome_message( # Knowledge Digest knowledge_digest = share.digest if knowledge_digest and knowledge_digest.content: - knowledge_digest_text = dedent(f""" - ### ASSISTANT KNOWLEDGE DIGEST - KEY KNOWLEDGE SHARE INFORMATION - The knowledge digest contains critical knowledge share information that has been automatically - extracted from previous conversations. It serves as a persistent memory of important facts, - decisions, and context that you should reference when responding. - - Key characteristics of this knowledge digest: - - It contains the most essential information about the knowledge share that should be readily available - - It has been automatically curated to focus on high-value content relevant to the knowledge transfer - - It is maintained and updated as the conversation progresses - - It should be treated as a trusted source of contextual information for this knowledge transfer - - When using the knowledge digest: - - Prioritize this information when addressing questions or providing updates - - Reference it to ensure consistency in your responses across the conversation - - Use it to track important details that might otherwise be lost in the conversation history - + knowledge_digest_text = load_text_include("knowledge_digest_instructions.txt") + dedent(f""" KNOWLEDGE DIGEST CONTENT: ```markdown {knowledge_digest.content} @@ -88,6 +73,7 @@ async def generate_team_welcome_message( """) share_data["knowledge_digest"] = knowledge_digest_text + share_data["knowledge_digest"] = knowledge_digest_text try: # Chat completion diff --git a/assistants/knowledge-transfer-assistant/assistant/agentic/update_digest.py b/assistants/knowledge-transfer-assistant/assistant/agentic/update_digest.py new file mode 100644 index 000000000..7e398b4b9 --- /dev/null +++ b/assistants/knowledge-transfer-assistant/assistant/agentic/update_digest.py @@ -0,0 +1,65 @@ +import re + +import openai_client +from semantic_workbench_api_model.workbench_model import ParticipantRole +from semantic_workbench_assistant.assistant_app import ConversationContext + +from assistant.config import assistant_config +from assistant.data import KnowledgeDigest +from assistant.domain import KnowledgeDigestManager +from assistant.utils import get_current_user_id + + +async def update_digest( + context: ConversationContext, +) -> KnowledgeDigest: + """ + Automatically updates the knowledge digest by analyzing chat history. + """ + messages = await context.get_messages() + chat_history = messages.messages + + current_user_id = await get_current_user_id(context) + if not current_user_id: + raise ValueError("Could not identify current user") + + # Skip if no messages to analyze + if not chat_history: + raise ValueError("No chat history to analyze for knowledge digest update") + + # Format the chat history for the prompt + chat_history_text = "" + for msg in chat_history: + sender_type = "User" if msg.sender and msg.sender.participant_role == ParticipantRole.user else "Assistant" + chat_history_text += f"{sender_type}: {msg.content}\n\n" + + # Construct the knowledge digest prompt with the chat history + config = await assistant_config.get(context.assistant) + digest_prompt = f""" + {config.prompt_config.knowledge_digest_update} + + + {chat_history_text} + + """ + + async with openai_client.create_client(config.service_config, api_version="2024-06-01") as client: + completion = await client.chat.completions.create( + model=config.request_config.openai_model, + messages=[{"role": "user", "content": digest_prompt}], + max_tokens=config.coordinator_config.max_digest_tokens, + ) + + content = completion.choices[0].message.content or "" + digest_content = "" + match = re.search(r"(.*?)", content, re.DOTALL) + digest_content = match.group(1).strip() if match else content.strip() + + if not digest_content: + raise ValueError("No content extracted from knowledge digest LLM analysis") + + return await KnowledgeDigestManager.update_knowledge_digest( + context=context, + content=digest_content, + is_auto_generated=True, + ) diff --git a/assistants/knowledge-transfer-assistant/assistant/assistant.py b/assistants/knowledge-transfer-assistant/assistant/assistant.py index 768550b64..dba4e64ed 100644 --- a/assistants/knowledge-transfer-assistant/assistant/assistant.py +++ b/assistants/knowledge-transfer-assistant/assistant/assistant.py @@ -4,6 +4,7 @@ import asyncio import pathlib +import re from typing import Any from assistant_extensions import attachments, dashboard_card, navigator @@ -26,16 +27,23 @@ ConversationContext, ) -from assistant.domain.knowledge_digest_manager import KnowledgeDigestManager +from assistant.agentic.detect_audience_and_takeaways import ( + detect_audience_and_takeaways, +) -from .agentic.team_welcome import generate_team_welcome_message +from .agentic import agentic from .config import assistant_config from .data import ConversationRole, InspectorTab, LogEntryType from .domain import ShareManager from .files import ShareFilesManager from .logging import logger from .notifications import Notifications -from .respond import respond_to_conversation +from .respond import ( + SILENCE_TOKEN, + CoordinatorOutput, + TeamOutput, + respond_to_conversation, +) from .ui_tabs import BriefInspector, DebugInspector, LearningInspector, SharingInspector from .utils import ( DEFAULT_TEMPLATE_ID, @@ -44,7 +52,9 @@ service_id = "knowledge-transfer-assistant.made-exploration" service_name = "Knowledge Transfer Assistant" -service_description = "A mediator assistant that facilitates sharing knowledge between parties." +service_description = ( + "A mediator assistant that facilitates sharing knowledge between parties." +) async def content_evaluator_factory( @@ -76,7 +86,9 @@ async def content_evaluator_factory( template_id=DEFAULT_TEMPLATE_ID, background_color="rgb(198, 177, 222)", icon=dashboard_card.image_to_url( - pathlib.Path(__file__).parent / "assets" / "icon-knowledge-transfer.svg", + pathlib.Path(__file__).parent + / "assets" + / "icon-knowledge-transfer.svg", "image/svg+xml", ), card_content=dashboard_card.CardContent( @@ -85,9 +97,11 @@ async def content_evaluator_factory( ), ), ), - **navigator.metadata_for_assistant_navigator({ - "default": load_text_include("assistant_info.md"), - }), + **navigator.metadata_for_assistant_navigator( + { + "default": load_text_include("assistant_info.md"), + } + ), }, ) @@ -127,7 +141,9 @@ async def on_conversation_created(context: ConversationContext) -> None: share_id = await ShareManager.create_share(context) # And it was good. So we then created a sharable conversation that we use as a template. - share_url = await ShareManager.create_shareable_team_conversation(context=context, share_id=share_id) + share_url = await ShareManager.create_shareable_team_conversation( + context=context, share_id=share_id + ) config = await assistant_config.get(context.assistant) welcome_message = config.coordinator_config.welcome_message.format( @@ -158,7 +174,9 @@ async def on_conversation_created(context: ConversationContext) -> None: if not share_id: logger.error("No share ID found for shareable team conversation.") return - await ShareManager.set_conversation_role(context, share_id, ConversationRole.SHAREABLE_TEMPLATE) + await ShareManager.set_conversation_role( + context, share_id, ConversationRole.SHAREABLE_TEMPLATE + ) return case ConversationRole.TEAM: @@ -174,10 +192,16 @@ async def on_conversation_created(context: ConversationContext) -> None: ) ) - await ShareManager.set_conversation_role(context, share_id, ConversationRole.TEAM) - await ShareFilesManager.synchronize_files_to_team_conversation(context=context, share_id=share_id) + await ShareManager.set_conversation_role( + context, share_id, ConversationRole.TEAM + ) + await ShareFilesManager.synchronize_files_to_team_conversation( + context=context, share_id=share_id + ) - welcome_message, debug = await generate_team_welcome_message(context) + welcome_message, debug = await agentic.generate_team_welcome_message( + context + ) await context.send_messages( NewConversationMessage( content=welcome_message, @@ -226,14 +250,44 @@ async def on_conversation_updated(context: ConversationContext) -> None: f"Updated conversation {shared_conversation_id} title from '{target_conversation.title}' to '{conversation.title}'" # noqa: E501 ) else: - logger.debug(f"Conversation {shared_conversation_id} title already matches: '{conversation.title}'") + 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}") + 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}") +async def store_coordinator_message( + context: ConversationContext, message: ConversationMessage +) -> None: + async with context.set_status("jotting..."): + try: + sender_name = "Coordinator" + if message.sender: + participants = await context.get_participants() + for participant in participants.participants: + if participant.id == message.sender.participant_id: + sender_name = participant.name + break + + await ShareManager.append_coordinator_message( + context=context, + message_id=str(message.id), + content=message.content, + sender_name=sender_name, + is_assistant=message.sender.participant_role + == ParticipantRole.assistant, + timestamp=message.timestamp, + ) + except Exception as e: + logger.exception(f"Error storing Coordinator message for Team access: {e}") + + @assistant.events.conversation.message.chat.on_created async def on_message_created( context: ConversationContext, event: ConversationEvent, message: ConversationMessage @@ -248,49 +302,127 @@ async def on_message_created( try: share = await ShareManager.get_share(context) - if not share: - return - metadata["debug"]["share_id"] = share.share_id + is_user_message = message.sender.participant_role == ParticipantRole.user - # If this is a Coordinator conversation, store the message for Team access - async with context.set_status("jotting..."): - role = await ShareManager.get_conversation_role(context) - if role == ConversationRole.COORDINATOR and message.message_type == MessageType.chat: + # Save coordinator-role user messages for team access. + role = await ShareManager.get_conversation_role(context) + if role == ConversationRole.COORDINATOR and is_user_message: + await store_coordinator_message(context, message) + + if message.message_type == MessageType.chat and is_user_message: + async with context.set_status("pondering..."): + # Get generated message. + response = await respond_to_conversation( + context, + new_message=message, + attachments_extension=attachments_extension, + metadata=metadata, + ) + content = "" + if response: + content = str(response.get("content", "")) + + # strip out the username from the response + if content.startswith("["): + content = re.sub(r"\[.*\]:\s", "", content) + + # If there are more than one user participants in the conversation, we need + # to check if the model chose to remain silent. + if content and content.replace(" ", "") == SILENCE_TOKEN: + config = await assistant_config.get(context.assistant) + if config.enable_debug_output: + metadata["debug"]["silence_token"] = True + metadata["debug"]["silence_token_response"] = (content,) + await context.send_messages( + NewConversationMessage( + message_type=MessageType.notice, + content="[assistant chose to remain silent]", + metadata=metadata, + ) + ) + return + + # Prepare response. + response_parts: list[str] = [] + if not content: + return try: - sender_name = "Coordinator" - if message.sender: - participants = await context.get_participants() - for participant in participants.participants: - if participant.id == message.sender.participant_id: - sender_name = participant.name - break - - await ShareManager.append_coordinator_message( - context=context, - message_id=str(message.id), - content=message.content, - sender_name=sender_name, - is_assistant=message.sender.participant_role == ParticipantRole.assistant, + if role == ConversationRole.TEAM: + output_model = TeamOutput.model_validate_json(content) + if output_model.response: + response_parts.append(output_model.response) + + if output_model.excerpt: + output_model.excerpt = output_model.excerpt.strip().strip( + '"' + ) + response_parts.append( + f'> _"{output_model.excerpt}"_ (excerpt)' + ) + + if output_model.citations: + citations = ", ".join(output_model.citations) + response_parts.append(f"Sources: _{citations}_") + + if output_model.next_step_suggestion: + metadata["help"] = output_model.next_step_suggestion + + else: + output_model = CoordinatorOutput.model_validate_json(content) + if output_model.response: + response_parts.append(output_model.response) + # if output_model.next_step_suggestion: + # metadata["help"] = output_model.next_step_suggestion + + # Save valid assistant responses for team access. + await store_coordinator_message( + context, + ConversationMessage( + id=message.id, + content_type=message.content_type, + content="\n\n".join(response_parts), + sender=message.sender, timestamp=message.timestamp, + message_type=MessageType.chat, + filenames=[], + metadata={}, + has_debug_data=False, + ), + ) + + # Doing background tasks only when the coordinator has responded. + + # Detect information requests in the conversation. + task = asyncio.create_task(agentic.update_digest(context)) + task.add_done_callback( + lambda t: t.exception() if t.done() and t.exception() else None + ) + + # Background thinking - audience + task2 = asyncio.create_task( + detect_audience_and_takeaways(context, attachments_extension) + ) + task2.add_done_callback( + lambda t: t.exception() if t.done() and t.exception() else None + ) + + await context.send_messages( + NewConversationMessage( + content="\n\n".join(response_parts), + message_type=MessageType.chat, + metadata=metadata, ) - except Exception as e: - logger.exception(f"Error storing Coordinator message for Team access: {e}") - - async with context.set_status("pondering..."): - await respond_to_conversation( - context, - new_message=message, - attachments_extension=attachments_extension, - metadata=metadata, - ) + ) - # If the message is from a Coordinator, update the digest in the - # background - if role == ConversationRole.COORDINATOR and message.message_type == MessageType.chat: - task = asyncio.create_task(KnowledgeDigestManager.auto_update_knowledge_digest(context)) - # Fire and forget - we don't need to await this background task - task.add_done_callback(lambda t: t.exception() if t.done() and t.exception() else None) + except Exception as e: + metadata["debug"]["error"] = str(e) + logger.exception(f"exception occurred parsing json response: {e}") + NewConversationMessage( + content="I'm sorry, I encountered an error while processing the response.", + message_type=MessageType.notice, + metadata=metadata, + ) except Exception as e: logger.exception(f"Error handling message: {e}") @@ -312,9 +444,13 @@ async def on_command_created( if message.message_type != MessageType.command: return - await context.update_participant_me(UpdateParticipant(status="processing command...")) + await context.update_participant_me( + UpdateParticipant(status="processing command...") + ) try: - metadata = {"debug": {"content_safety": event.data.get(content_safety.metadata_key, {})}} + metadata = { + "debug": {"content_safety": event.data.get(content_safety.metadata_key, {})} + } # Respond to the conversation await respond_to_conversation( @@ -345,7 +481,9 @@ async def on_file_created( """ try: if not file.filename: - logger.warning(f"No share found or missing filename. filename={file.filename}") + logger.warning( + f"No share found or missing filename. filename={file.filename}" + ) return share = await ShareManager.get_share(context) @@ -369,7 +507,9 @@ async def on_file_created( # 2. Synchronize to all Team conversations # Get all Team conversations - team_conversations = await ShareFilesManager.get_team_conversations(context, share.share_id) + team_conversations = await ShareFilesManager.get_team_conversations( + context, share.share_id + ) if team_conversations: for team_conv_id in team_conversations: @@ -426,7 +566,9 @@ async def on_file_updated( logger.error(f"Failed to update file in share storage: {file.filename}") return - team_conversations = await ShareFilesManager.get_team_conversations(context, share.share_id) + team_conversations = await ShareFilesManager.get_team_conversations( + context, share.share_id + ) for team_conv_id in team_conversations: await ShareFilesManager.copy_file_to_conversation( context=context, @@ -473,7 +615,9 @@ async def on_file_deleted( ) if not success: - logger.error(f"Failed to delete file from share storage: {file.filename}") + logger.error( + f"Failed to delete file from share storage: {file.filename}" + ) # 2. Update all UIs about the deletion but don't send notifications to reduce noise await Notifications.notify_all_state_update(context, [InspectorTab.DEBUG]) @@ -521,7 +665,9 @@ async def on_participant_joined( if not share_id: return - await ShareFilesManager.synchronize_files_to_team_conversation(context=context, share_id=share_id) + await ShareFilesManager.synchronize_files_to_team_conversation( + context=context, share_id=share_id + ) await ShareManager.log_share_event( context=context, diff --git a/assistants/knowledge-transfer-assistant/assistant/config.py b/assistants/knowledge-transfer-assistant/assistant/config.py index 43fc44232..9c58ee274 100644 --- a/assistants/knowledge-transfer-assistant/assistant/config.py +++ b/assistants/knowledge-transfer-assistant/assistant/config.py @@ -14,7 +14,7 @@ class RequestConfig(BaseModel): model_config = ConfigDict( - title="Response Generation", + title="Response generation", json_schema_extra={ "required": ["max_tokens", "response_tokens", "openai_model"], }, @@ -23,7 +23,7 @@ class RequestConfig(BaseModel): max_tokens: Annotated[ int, Field( - title="Max Tokens", + title="Max tokens", description=( "The maximum number of tokens to use for both the prompt and response. Current max supported by OpenAI" " is 128k tokens, but varies by model (https://platform.openai.com/docs/models)" @@ -34,7 +34,7 @@ class RequestConfig(BaseModel): response_tokens: Annotated[ int, Field( - title="Response Tokens", + title="Response tokens", description=( "The number of tokens to use for the response, will reduce the number of tokens available for the" " prompt. Current max supported by OpenAI is 4096 tokens (https://platform.openai.com/docs/models)" @@ -45,7 +45,7 @@ class RequestConfig(BaseModel): coordinator_conversation_token_limit: Annotated[ int, Field( - title="Coordinator Conversation Token Limit", + title="Coordinator conversation token limit", description="The maximum number of tokens to use for the coordinator conversation history.", ), ] = 4000 @@ -53,7 +53,7 @@ class RequestConfig(BaseModel): openai_model: Annotated[ str, Field( - title="OpenAI Model", + title="OpenAI model", description="The OpenAI model to use for generating responses.", ), ] = "gpt-4.1" @@ -61,7 +61,7 @@ class RequestConfig(BaseModel): class PromptConfig(BaseModel): model_config = ConfigDict( - title="Prompt Templates", + title="Prompt templates", json_schema_extra={ "required": [ "coordinator_role", @@ -69,7 +69,7 @@ class PromptConfig(BaseModel): "team_role", "team_instructions", "share_information_request_detection", - "knowledge_digest_prompt", + "knowledge_digest_update", "welcome_message_generation", ], }, @@ -78,7 +78,7 @@ class PromptConfig(BaseModel): coordinator_role: Annotated[ str, Field( - title="Coordinator Role", + title="Coordinator role", description="The role of the coordinator assistant. This is added to the prompt when in coordinator mode.", ), UISchema(widget="textarea"), @@ -87,7 +87,7 @@ class PromptConfig(BaseModel): coordinator_instructions: Annotated[ str, Field( - title="Coordinator Instructions", + title="Coordinator instructions", description="The instructions to give the coordinator assistant. This is added to the prompt when in coordinator mode.", # noqa: E501 ), UISchema(widget="textarea"), @@ -96,7 +96,7 @@ class PromptConfig(BaseModel): team_role: Annotated[ str, Field( - title="Team Role", + title="Team role", description="The role of the team assistant. This is added to the prompt when in team member mode.", ), UISchema(widget="textarea"), @@ -105,7 +105,7 @@ class PromptConfig(BaseModel): team_instructions: Annotated[ str, Field( - title="Team Instructions", + title="Team instructions", description="The instructions to give the team assistant. This is added to the prompt when in team member mode.", # noqa: E501 ), UISchema(widget="textarea"), @@ -114,20 +114,20 @@ class PromptConfig(BaseModel): share_information_request_detection: Annotated[ str, Field( - title="Information Request Detection Prompt", + title="Information Request detection prompt", description="The prompt used to detect information requests in knowledge transfer mode.", ), UISchema(widget="textarea"), ] = load_text_include("share_information_request_detection.txt") - knowledge_digest_prompt: Annotated[ + knowledge_digest_update: Annotated[ str, Field( - title="Knowledge Digest Prompt", - description="The prompt used to generate knowledge digest content.", + title="Knowledge Digest update", + description="The prompt used to generate updated knowledge digest content.", ), UISchema(widget="textarea"), - ] = load_text_include("knowledge_digest_prompt.txt") + ] = load_text_include("knowledge_digest_update.txt") welcome_message_generation: Annotated[ str, @@ -141,7 +141,7 @@ class PromptConfig(BaseModel): class CoordinatorConfig(BaseModel): model_config = ConfigDict( - title="Coordinator Configuration", + title="Coordinator configuration", json_schema_extra={ "required": ["welcome_message", "preferred_communication_style", "max_digest_tokens"], }, @@ -176,7 +176,7 @@ class CoordinatorConfig(BaseModel): max_digest_tokens: Annotated[ int, Field( - title="Maximum Digest Tokens", + title="Maximum digest tokens", description=("The number of tokens to use for the knowledge digest. Default: 4096"), ), ] = 4_096 @@ -184,7 +184,7 @@ class CoordinatorConfig(BaseModel): preferred_communication_style: Annotated[ str, Field( - title="Preferred Communication Style", + title="Preferred communication style", description="The preferred communication style for the assistant. This is used to tailor responses.", ), UISchema(widget="textarea"), @@ -193,7 +193,7 @@ class CoordinatorConfig(BaseModel): class TeamConfig(BaseModel): model_config = ConfigDict( - title="Team Member Configuration", + title="Team-member configuration", json_schema_extra={ "required": ["default_welcome_message", "preferred_communication_style"], }, @@ -202,7 +202,7 @@ class TeamConfig(BaseModel): default_welcome_message: Annotated[ str, Field( - title="Team Welcome Message", + title="Team Welcome message", description="The message to display when a user joins a knowledge transfer as a Team member. Shown after successfully joining a knowledge transfer.", # noqa: E501 ), UISchema(widget="textarea"), @@ -211,7 +211,7 @@ class TeamConfig(BaseModel): preferred_communication_style: Annotated[ str, Field( - title="Preferred Communication Style", + title="Preferred communication style", description="The preferred communication style for the assistant. This is used to tailor responses.", ), UISchema(widget="textarea"), @@ -223,7 +223,7 @@ class AssistantConfigModel(BaseModel): enable_debug_output: Annotated[ bool, Field( - title="Include Debug Output", + title="Include debug output", description="Include debug output on conversation messages.", ), ] = False @@ -231,7 +231,7 @@ class AssistantConfigModel(BaseModel): prompt_config: Annotated[ PromptConfig, Field( - title="Prompt Configuration", + title="Prompt configuration", description="Configuration for prompt templates used throughout the assistant.", ), ] = PromptConfig() @@ -239,7 +239,7 @@ class AssistantConfigModel(BaseModel): request_config: Annotated[ RequestConfig, Field( - title="Request Configuration", + title="Request configuration", ), ] = RequestConfig() @@ -248,14 +248,14 @@ class AssistantConfigModel(BaseModel): content_safety_config: Annotated[ CombinedContentSafetyEvaluatorConfig, Field( - title="Content Safety Configuration", + title="Content Safety configuration", ), ] = CombinedContentSafetyEvaluatorConfig() attachments_config: Annotated[ AttachmentsConfigModel, Field( - title="Attachments Configuration", + title="Attachments configuration", description="Configuration for handling file attachments in messages.", ), ] = AttachmentsConfigModel() @@ -264,31 +264,15 @@ class AssistantConfigModel(BaseModel): auto_sync_files: Annotated[ bool, Field( - title="Auto-sync Files", + title="Auto-sync files", description="Automatically synchronize files between linked conversations.", ), ] = True - track_progress: Annotated[ - bool, - Field( - title="Track Progress", - description="Track knowledge transfer progress with learning objectives, outcome completion, and overall transfer state.", # noqa: E501 - ), - ] = True - - proactive_guidance: Annotated[ - bool, - Field( - title="Proactive Guidance", - description="Proactively guide knowledge organizers through knowledge structuring.", - ), - ] = True - coordinator_config: Annotated[ CoordinatorConfig, Field( - title="Coordinator Configuration", + title="Coordinator configuration", description="Configuration for knowledge transfer coordinators.", ), ] = CoordinatorConfig() @@ -296,7 +280,7 @@ class AssistantConfigModel(BaseModel): team_config: Annotated[ TeamConfig, Field( - title="Team Configuration", + title="Team configuration", description="Configuration for knowledge transfer team members.", ), ] = TeamConfig() diff --git a/assistants/knowledge-transfer-assistant/assistant/data.py b/assistants/knowledge-transfer-assistant/assistant/data.py index 2ae12a36b..f431a899f 100644 --- a/assistants/knowledge-transfer-assistant/assistant/data.py +++ b/assistants/knowledge-transfer-assistant/assistant/data.py @@ -179,7 +179,6 @@ class Share(BaseModel): shared_conversation_id: str | None = None team_conversations: dict[str, TeamConversationInfo] = Field(default_factory=dict) share_url: str | None = None - version: int = 1 created_at: datetime = Field(default_factory=datetime.utcnow) updated_at: datetime = Field(default_factory=datetime.utcnow) @@ -187,7 +186,7 @@ class Share(BaseModel): # Knowledge Package components audience: str | None = None - takeaways: list[str] = Field(default_factory=list) + audience_takeaways: list[str] = Field(default_factory=list) learning_objectives: list[LearningObjective] = Field(default_factory=list) brief: KnowledgeBrief | None preferred_communication_style: str | None = None @@ -197,10 +196,9 @@ class Share(BaseModel): # Lifecycle is_intended_to_accomplish_outcomes: bool = True next_learning_actions: list[str] = Field(default_factory=list) - transfer_lifecycle: dict[str, Any] = Field(default_factory=dict) - knowledge_organized: bool = False - archived: bool = False + # knowledge_organized: bool = False requests: list[InformationRequest] = Field(default_factory=list) + assistant_thoughts: list[str] = Field(default_factory=list) log: ShareLog | None = Field(default_factory=lambda: ShareLog()) diff --git a/assistants/knowledge-transfer-assistant/assistant/domain/__init__.py b/assistants/knowledge-transfer-assistant/assistant/domain/__init__.py index 23fa75fa1..4b3b9e584 100644 --- a/assistants/knowledge-transfer-assistant/assistant/domain/__init__.py +++ b/assistants/knowledge-transfer-assistant/assistant/domain/__init__.py @@ -10,6 +10,7 @@ from .knowledge_digest_manager import KnowledgeDigestManager from .learning_objectives_manager import LearningObjectivesManager from .share_manager import ShareManager +from .thoughts_manager import ThoughtsManager from .transfer_manager import TransferManager __all__ = [ @@ -19,5 +20,6 @@ "KnowledgeDigestManager", "LearningObjectivesManager", "ShareManager", + "ThoughtsManager", "TransferManager", ] diff --git a/assistants/knowledge-transfer-assistant/assistant/domain/audience_manager.py b/assistants/knowledge-transfer-assistant/assistant/domain/audience_manager.py index fa5e63973..30dd20f35 100644 --- a/assistants/knowledge-transfer-assistant/assistant/domain/audience_manager.py +++ b/assistants/knowledge-transfer-assistant/assistant/domain/audience_manager.py @@ -39,3 +39,28 @@ async def update_audience( await Notifications.notify(context, "Audience updated.") await Notifications.notify_all_state_update(context, [InspectorTab.DEBUG]) + + @staticmethod + async def update_audience_takeaways( + context: ConversationContext, + takeaways: list[str], + ) -> None: + """ + Update the key takeaways for the target audience. + """ + share = await ShareManager.get_share(context) + share.audience_takeaways = takeaways + share.updated_at = datetime.now(UTC) + await ShareManager.set_share(context, share) + + await ShareManager.log_share_event( + context=context, + entry_type=LogEntryType.STATUS_CHANGED.value, + message=f"Updated audience takeaways: {takeaways}", + metadata={ + "takeaways": takeaways, + }, + ) + + await Notifications.notify(context, "Audience takeaways updated.") + await Notifications.notify_all_state_update(context, [InspectorTab.BRIEF]) diff --git a/assistants/knowledge-transfer-assistant/assistant/domain/information_request_manager.py b/assistants/knowledge-transfer-assistant/assistant/domain/information_request_manager.py index 098cb5b5a..3907e56bf 100644 --- a/assistants/knowledge-transfer-assistant/assistant/domain/information_request_manager.py +++ b/assistants/knowledge-transfer-assistant/assistant/domain/information_request_manager.py @@ -1,9 +1,3 @@ -""" -Information request management for Knowledge Transfer Assistant. - -Handles information request creation, resolution, and retrieval. -""" - from datetime import UTC, datetime from semantic_workbench_assistant.assistant_app import ConversationContext diff --git a/assistants/knowledge-transfer-assistant/assistant/domain/knowledge_brief_manager.py b/assistants/knowledge-transfer-assistant/assistant/domain/knowledge_brief_manager.py index ca570ff3d..220128cbf 100644 --- a/assistants/knowledge-transfer-assistant/assistant/domain/knowledge_brief_manager.py +++ b/assistants/knowledge-transfer-assistant/assistant/domain/knowledge_brief_manager.py @@ -1,9 +1,3 @@ -""" -Knowledge brief management operations for Knowledge Transfer Assistant. - -Handles knowledge brief creation, updates, and retrieval. -""" - from semantic_workbench_assistant.assistant_app import ConversationContext from assistant.data import InspectorTab, KnowledgeBrief, LogEntryType diff --git a/assistants/knowledge-transfer-assistant/assistant/domain/knowledge_digest_manager.py b/assistants/knowledge-transfer-assistant/assistant/domain/knowledge_digest_manager.py index 3ebadb8b4..742610cd9 100644 --- a/assistants/knowledge-transfer-assistant/assistant/domain/knowledge_digest_manager.py +++ b/assistants/knowledge-transfer-assistant/assistant/domain/knowledge_digest_manager.py @@ -1,17 +1,7 @@ -""" -Knowledge digest management for Knowledge Transfer Assistant. - -Handles knowledge digest operations including auto-updating from conversations. -""" - -import re from datetime import UTC, datetime -import openai_client -from semantic_workbench_api_model.workbench_model import ParticipantRole from semantic_workbench_assistant.assistant_app import ConversationContext -from assistant.config import assistant_config from assistant.data import InspectorTab, KnowledgeDigest, LogEntryType from assistant.notifications import Notifications from assistant.storage import ShareStorage @@ -77,58 +67,3 @@ async def update_knowledge_digest( ) return digest - - @staticmethod - async def auto_update_knowledge_digest( - context: ConversationContext, - ) -> KnowledgeDigest: - """ - Automatically updates the knowledge digest by analyzing chat history. - """ - messages = await context.get_messages() - chat_history = messages.messages - - current_user_id = await get_current_user_id(context) - if not current_user_id: - raise ValueError("Could not identify current user") - - # Skip if no messages to analyze - if not chat_history: - raise ValueError("No chat history to analyze for knowledge digest update") - - # Format the chat history for the prompt - chat_history_text = "" - for msg in chat_history: - sender_type = "User" if msg.sender and msg.sender.participant_role == ParticipantRole.user else "Assistant" - chat_history_text += f"{sender_type}: {msg.content}\n\n" - - # Construct the knowledge digest prompt with the chat history - config = await assistant_config.get(context.assistant) - digest_prompt = f""" - {config.prompt_config.knowledge_digest_prompt} - - - {chat_history_text} - - """ - - async with openai_client.create_client(config.service_config, api_version="2024-06-01") as client: - completion = await client.chat.completions.create( - model=config.request_config.openai_model, - messages=[{"role": "user", "content": digest_prompt}], - max_tokens=config.coordinator_config.max_digest_tokens, - ) - - content = completion.choices[0].message.content or "" - digest_content = "" - match = re.search(r"(.*?)", content, re.DOTALL) - digest_content = match.group(1).strip() if match else content.strip() - - if not digest_content: - raise ValueError("No content extracted from knowledge digest LLM analysis") - - return await KnowledgeDigestManager.update_knowledge_digest( - context=context, - content=digest_content, - is_auto_generated=True, - ) diff --git a/assistants/knowledge-transfer-assistant/assistant/domain/learning_objectives_manager.py b/assistants/knowledge-transfer-assistant/assistant/domain/learning_objectives_manager.py index a075941bc..77e547499 100644 --- a/assistants/knowledge-transfer-assistant/assistant/domain/learning_objectives_manager.py +++ b/assistants/knowledge-transfer-assistant/assistant/domain/learning_objectives_manager.py @@ -1,9 +1,3 @@ -""" -Learning objectives and outcomes management for Knowledge Transfer Assistant. - -Handles learning objectives, outcomes creation, updates, and deletion. -""" - from semantic_workbench_assistant.assistant_app import ConversationContext from assistant.data import ( diff --git a/assistants/knowledge-transfer-assistant/assistant/domain/share_manager.py b/assistants/knowledge-transfer-assistant/assistant/domain/share_manager.py index 00b5dc544..b2355f847 100644 --- a/assistants/knowledge-transfer-assistant/assistant/domain/share_manager.py +++ b/assistants/knowledge-transfer-assistant/assistant/domain/share_manager.py @@ -1,9 +1,3 @@ -""" -Share/Project management operations for Knowledge Transfer Assistant. - -Handles creation, joining, and basic share operations. -""" - import uuid from csv import Error from datetime import UTC, datetime @@ -93,16 +87,14 @@ async def set_conversation_role(context: ConversationContext, share_id: str, rol @staticmethod async def get_conversation_role( context: ConversationContext, - ) -> ConversationRole | None: + ) -> ConversationRole: """ Gets the role of a conversation in a knowledge transfer. """ role_path = ShareStorageManager.get_conversation_role_file_path(context) role_data = read_model(role_path, ConversationShareInfo) - if role_data: return role_data.role - return ConversationRole.COORDINATOR @staticmethod diff --git a/assistants/knowledge-transfer-assistant/assistant/domain/thoughts_manager.py b/assistants/knowledge-transfer-assistant/assistant/domain/thoughts_manager.py new file mode 100644 index 000000000..83d48cc44 --- /dev/null +++ b/assistants/knowledge-transfer-assistant/assistant/domain/thoughts_manager.py @@ -0,0 +1,42 @@ +from semantic_workbench_assistant.assistant_app import ConversationContext + +from assistant.data import InspectorTab +from assistant.domain.share_manager import ShareManager +from assistant.notifications import Notifications +from assistant.storage import ShareStorage + + +class ThoughtsManager: + @staticmethod + async def get_assistant_thoughts( + context: ConversationContext, + ) -> list[str]: + share_id = await ShareManager.get_share_id(context) + if not share_id: + return [] + return ShareStorage.read_assistant_thoughts(share_id) + + @staticmethod + async def add_assistant_thoughts( + context: ConversationContext, + thoughts: list[str], + ) -> None: + share_id = await ShareManager.get_share_id(context) + if not share_id: + return + ShareStorage.add_assistant_thoughts(share_id, thoughts) + + await Notifications.notify(context, f"Added {len(thoughts)} assistant thoughts.") + await Notifications.notify_state_update(context, [InspectorTab.DEBUG]) + + @staticmethod + async def remove_assistant_thought( + context: ConversationContext, + thought: str, + ) -> None: + share_id = await ShareManager.get_share_id(context) + if not share_id: + return + ShareStorage.remove_assistant_thought(share_id, thought) + await Notifications.notify(context, "Forgot something.") + await Notifications.notify_state_update(context, [InspectorTab.DEBUG]) diff --git a/assistants/knowledge-transfer-assistant/assistant/domain/transfer_manager.py b/assistants/knowledge-transfer-assistant/assistant/domain/transfer_manager.py index c2460e94d..60adc7c0f 100644 --- a/assistants/knowledge-transfer-assistant/assistant/domain/transfer_manager.py +++ b/assistants/knowledge-transfer-assistant/assistant/domain/transfer_manager.py @@ -6,8 +6,8 @@ class TransferManager: @staticmethod def is_ready_for_transfer(share: Share) -> bool: - has_basic_requirements = share.knowledge_organized and share.brief is not None and share.audience is not None - + # has_basic_requirements = share.knowledge_organized and share.brief is not None and share.audience is not None + has_basic_requirements = share.audience and share.audience_takeaways and share.brief if not has_basic_requirements: return False diff --git a/assistants/knowledge-transfer-assistant/assistant/prompt_utils.py b/assistants/knowledge-transfer-assistant/assistant/prompt_utils.py new file mode 100644 index 000000000..6b59d526c --- /dev/null +++ b/assistants/knowledge-transfer-assistant/assistant/prompt_utils.py @@ -0,0 +1,495 @@ +import json +from dataclasses import dataclass, field +from enum import Enum +from typing import Protocol + +import openai_client +from assistant_extensions.attachments import ( + AttachmentsConfigModel, + AttachmentsExtension, +) +from openai.types.chat import ( + ChatCompletionMessageParam, +) +from pydantic import BaseModel, Field +from semantic_workbench_assistant.assistant_app import ( + ConversationContext, +) + +from assistant.agentic import get_coordinator_next_action_suggestion +from assistant.data import ( + ConversationRole, + CoordinatorConversationMessage, + RequestStatus, +) +from assistant.domain.learning_objectives_manager import LearningObjectivesManager +from assistant.domain.share_manager import ShareManager +from assistant.domain.thoughts_manager import ThoughtsManager +from assistant.ui_tabs.common import get_priority_emoji, get_status_emoji + + +def create_system_message(content: str, delimiter: str | None = None) -> ChatCompletionMessageParam: + if delimiter: + delimiter = delimiter.strip().upper().replace(" ", "_") + content = f"<{delimiter}>\n{content}\n" + + message: ChatCompletionMessageParam = { + "role": "system", + "content": content, + } + return message + + +class Instructions: + """ + A class to represent a section of a prompt. + """ + + def __init__( + self, + content: str, + title: str | None = None, + ) -> None: + self.title = title + self.content = content + self.level = 0 + self.subsections: list[Instructions] = [] + + def add_subsection(self, subsection: "Instructions") -> None: + """ + Add a subsection to the prompt section. + """ + subsection.level = self.level + 1 + self.subsections.append(subsection) + + def __str__(self) -> str: + s = "" + if self.title: + hashes = "#" * (self.level + 1) + s += f"{hashes} {self.title}\n\n" + s += self.content + if self.subsections: + s += "\n\n" + "\n\n".join(str(subsection) for subsection in self.subsections) + + return s + + +class Context(Protocol): + def message(self) -> ChatCompletionMessageParam: + raise NotImplementedError + + def content(self) -> str: + raise NotImplementedError + + def name(self) -> str: + raise NotImplementedError + + +class ChatCompletionMessageContext(Context): + def __init__(self, message: ChatCompletionMessageParam, name: str | None) -> None: + self._message = message + self._name = name or "Attachment" + + def message(self) -> ChatCompletionMessageParam: + return self._message + + def content(self) -> str: + return f"<{self._name}>\n{self._message.get('content')}\n" + + def name(self) -> str: + return self._name + + +class DataContext(Context): + def __init__(self, name: str, data: str, description: str | None = None) -> None: + self._name = name + self.description = description + self.data = data + + def message(self) -> ChatCompletionMessageParam: + return create_system_message(self.content(), self._name) + + def content(self) -> str: + s = self.data + if self.description: + s = f"{self.description}\n\n'''\n{self.data}\n'''" + return s + + def name(self) -> str: + return self._name + + +class ContextStrategy(Enum): + SINGLE = "single" # Put all context chunks in a single message. + MULTI = "multi" # Put each context chunk in its own message. + + +@dataclass +class Prompt: + role: str + instructions: Instructions + output_format: str | None = None + reasoning_steps: str | None = None + examples: str | None = None + contexts: list[Context] = field(default_factory=list) + context_strategy: ContextStrategy = ContextStrategy.SINGLE + final_instructions: str | None = None + + def messages(self) -> list[ChatCompletionMessageParam]: + parts = [ + "# Role and Objective", + self.role, + "# Instructions", + str(self.instructions), + ] + if self.reasoning_steps: + parts.append("# Reasoning Steps") + parts.append(self.reasoning_steps) + if self.output_format: + parts.append("# Output Format") + parts.append(self.output_format) + if self.examples: + parts.append("# Examples") + parts.append(self.examples) + if self.contexts and self.context_strategy == ContextStrategy.SINGLE: + parts.append("# Context") + for context in self.contexts: + parts.append(f"## {context.name()}") + parts.append(context.content()) + s = "\n\n".join(parts) + if self.final_instructions: + s += "\n\n" + self.final_instructions + + messages = [ + create_system_message(s), + ] + + if self.contexts and self.context_strategy == ContextStrategy.MULTI: + for context in self.contexts: + messages.append(context.message()) + + return messages + + +class TokenBudget: + def __init__(self, budget: int) -> None: + self.budget = budget + self.used = 0 + + def add(self, tokens: int) -> None: + self.used += tokens + + def remaining(self) -> int: + return self.budget - self.used + + def is_under_budget(self) -> bool: + return self.remaining() > 0 + + def is_over_budget(self) -> bool: + return self.remaining() < 0 + + def fits(self, tokens: int) -> bool: + return self.remaining() >= tokens + + +class ContextSection(Enum): + """ + Enum to represent different sections of the conversation context. + """ + + KNOWLEDGE_INFO = "knowledge_info" + KNOWLEDGE_BRIEF = "knowledge_brief" + TARGET_AUDIENCE = "target_audience" + LEARNING_OBJECTIVES = "learning_objectives" + KNOWLEDGE_DIGEST = "knowledge_digest" + INFORMATION_REQUESTS = "information_requests" + SUGGESTED_NEXT_ACTIONS = "suggested_next_actions" + COORDINATOR_CONVERSATION = "coordinator_conversation" + ATTACHMENTS = "attachments" + ASSISTANT_THOUGHTS = "assistant_thoughts" + + +async def add_context_to_prompt( + prompt: Prompt, + context: ConversationContext, + role: ConversationRole, + model: str, + token_limit: int, + attachments_extension: AttachmentsExtension | None = None, + attachments_config: AttachmentsConfigModel | None = None, + attachments_in_system_message: bool = False, + include: list[ContextSection] | None = None, +) -> None: + if include is None: + return + + share = await ShareManager.get_share(context) + + if ContextSection.ASSISTANT_THOUGHTS in include: + # Get the assistant's thoughts from the conversation context. + assistant_thoughts = await ThoughtsManager.get_assistant_thoughts(context) + if assistant_thoughts: + thoughts_data = "\n\n".join("- " + thought for thought in assistant_thoughts) + prompt.contexts.append( + DataContext( + "Assistant Thoughts", + thoughts_data, + "The assistant's thoughts about the conversation.", + ) + ) + + if ContextSection.KNOWLEDGE_INFO in include: + share_info_text = share.model_dump_json( + indent=2, + exclude={ + "brief", + "learning_objectives", + "audience_takeaways", + "preferred_communication_style", + "transfer_notes", + "digest", + "next_learning_actions", + "requests", + "log", + }, + ) + prompt.contexts.append(DataContext("Knowledge Share Info", share_info_text)) + + if ContextSection.KNOWLEDGE_BRIEF in include and share and share.brief: + brief_text = "" + brief_text = f"**Title:** {share.brief.title}\n**Description:** {share.brief.content}" + prompt.contexts.append( + DataContext( + "Knowledge Brief", + brief_text, + ) + ) + + if ContextSection.TARGET_AUDIENCE in include and role == ConversationRole.COORDINATOR and share: + if share.audience: + audience_context = share.audience + + if share.audience_takeaways: + audience_context += "\n\n**Intended takeaways for this audience:**\n" + audience_context += "\n".join(f"- {takeaway}" for takeaway in share.audience_takeaways) + else: + audience_context += "\n\n**Note:** No specific takeaways defined for this audience. Please define them to help guide the knowledge transfer process." # noqa: E501 + + if not share.is_intended_to_accomplish_outcomes: + audience_context += "\n\n**Note:** This knowledge package is intended for general exploration, not specific learning outcomes." # noqa: E501 + else: + audience_context = "The intended audience for this knowledge transfer has not been defined yet. Please define it to help guide the knowledge transfer process." # noqa: E501 + + prompt.contexts.append( + DataContext( + "Target Audience", + audience_context, + "Description of the intended audience for this knowledge transfer.", + ) + ) + + # Learning objectives + if ContextSection.LEARNING_OBJECTIVES in include and share and share.learning_objectives: + learning_objectives_text = "" + conversation_id = str(context.id) + + # Show progress based on role + if role == ConversationRole.COORDINATOR: + # Coordinator sees overall progress across all team members + achieved_overall, total_overall = LearningObjectivesManager.get_overall_completion(share) + learning_objectives_text += ( + f"Overall Progress: {achieved_overall}/{total_overall} outcomes achieved by team members\n\n" + ) + else: + # Team member sees their personal progress + if conversation_id in share.team_conversations: + achieved_personal, total_personal = LearningObjectivesManager.get_completion_for_conversation( + share, conversation_id + ) + progress_pct = int(achieved_personal / total_personal * 100) if total_personal > 0 else 0 + learning_objectives_text += ( + f"My Progress: {achieved_personal}/{total_personal} outcomes achieved ({progress_pct}%)\n\n" + ) + + learning_objectives = {} + for objective in share.learning_objectives: + learning_objectives[objective.id] = objective.model_dump() + learning_objectives_text = json.dumps( + learning_objectives, + indent=2, + ) + + prompt.contexts.append( + DataContext( + "Learning Objectives", + learning_objectives_text, + ) + ) + + if ContextSection.KNOWLEDGE_DIGEST in include and share and share.digest and share.digest.content: + prompt.contexts.append( + DataContext( + "Knowledge digest", + share.digest.content, + "The assistant-maintained knowledge digest.", + ) + ) + + # Information requests + if ContextSection.INFORMATION_REQUESTS in include and share: + all_requests = share.requests + if role == ConversationRole.COORDINATOR: + active_requests = [r for r in all_requests if r.status != RequestStatus.RESOLVED] + if active_requests: + coordinator_requests = ( + "> 📋 **Use the request ID (not the title) with resolve_information_request()**\n\n" + ) + for req in active_requests[:10]: # Limit to 10 for brevity + priority_emoji = get_priority_emoji(req.priority) + status_emoji = get_status_emoji(req.status) + coordinator_requests += f"{priority_emoji} **{req.title}** {status_emoji}\n" + coordinator_requests += f" **Request ID:** `{req.request_id}`\n" + coordinator_requests += f" **Description:** {req.description}\n\n" + + if len(active_requests) > 10: + coordinator_requests += f"*...and {len(active_requests) - 10} more requests.*\n" + else: + coordinator_requests = "No active information requests." + prompt.contexts.append( + DataContext( + "Information Requests", + coordinator_requests, + ) + ) + else: # team role + information_requests_info = "" + my_requests = [] + + # Filter for requests from this conversation that aren't resolved. + my_requests = [ + r for r in all_requests if r.conversation_id == str(context.id) and r.status != RequestStatus.RESOLVED + ] + + if my_requests: + information_requests_info = "" + for req in my_requests: + information_requests_info += ( + f"- **{req.title}** (ID: `{req.request_id}`, Priority: {req.priority})\n" + ) + else: + information_requests_info = "No active information requests." + + prompt.contexts.append( + DataContext( + "Information Requests", + information_requests_info, + ) + ) + + # Add next action suggestions for coordinator + if ContextSection.SUGGESTED_NEXT_ACTIONS in include and share and role == ConversationRole.COORDINATOR: + next_action_suggestion = await get_coordinator_next_action_suggestion(context) + if next_action_suggestion: + prompt.contexts.append( + DataContext( + "Suggested Next Actions", + next_action_suggestion, + "Actions the coordinator should consider taking based on the current knowledge transfer state.", + ) + ) + + # Figure out the token budget so far. + token_budget = TokenBudget(token_limit) + token_budget.add( + openai_client.num_tokens_from_messages( + model=model, + messages=prompt.messages(), + ) + ) + + # Coordinator conversation + if ContextSection.COORDINATOR_CONVERSATION in include: + coordinator_conversation = await ShareManager.get_coordinator_conversation(context) + if coordinator_conversation: + # Limit messages to the configured max token count. + total_coordinator_conversation_tokens = 0 + selected_coordinator_conversation_messages: list[CoordinatorConversationMessage] = [] + for msg in reversed(coordinator_conversation.messages): + tokens = openai_client.num_tokens_from_string(msg.model_dump_json(), model=model) + if total_coordinator_conversation_tokens + tokens > token_limit: + break + selected_coordinator_conversation_messages.append(msg) + total_coordinator_conversation_tokens += tokens + + class CoordinatorMessageList(BaseModel): + messages: list[CoordinatorConversationMessage] = Field(default_factory=list) + + selected_coordinator_conversation_messages.reverse() + coordinator_message_list = CoordinatorMessageList(messages=selected_coordinator_conversation_messages) + coordinator_message_list_data = coordinator_message_list.model_dump_json() + + if attachments_in_system_message: + prompt.contexts.append( + DataContext( + "Message History", + coordinator_message_list_data, + "The assistant conversation with the user.", + ) + ) + else: + coordinator_message_list_data = ( + f"{coordinator_message_list_data}" + ) + prompt.contexts.append(DataContext("Attachment", coordinator_message_list_data)) + + # TODO: To get exact token count, we should add delimiters. + token_budget.add( + openai_client.num_tokens_from_string( + model=model, + string=coordinator_message_list_data, + ) + ) + + # Attachments + if ContextSection.ATTACHMENTS in include and share and attachments_config and attachments_extension: + # Generate the attachment messages. + # TODO: This will exceed the token limit if there are too many attachments. + + attachment_messages: list[ChatCompletionMessageParam] = openai_client.convert_from_completion_messages( + await attachments_extension.get_completion_messages_for_attachments( + context, + config=attachments_config, + ) + ) + + if attachments_in_system_message: + attachments_data = "\n\n".join(f"{msg['content']}" for msg in attachment_messages if "content" in msg) + prompt.contexts.append( + DataContext( + "Attachments", + attachments_data, + "The attachments provided by the user.", + ) + ) + # TODO: To get exact token count, we should add delimiters. + token_budget.add( + openai_client.num_tokens_from_string( + model=model, + string=attachments_data, + ) + ) + + else: + for a in attachment_messages: + prompt.contexts.append( + ChatCompletionMessageContext( + name="Attachment", + message=a, + ) + ) + token_budget.add( + openai_client.num_tokens_from_messages( + model=model, + messages=attachment_messages, + ) + ) diff --git a/assistants/knowledge-transfer-assistant/assistant/respond.py b/assistants/knowledge-transfer-assistant/assistant/respond.py index 75028f59f..bc02f2afb 100644 --- a/assistants/knowledge-transfer-assistant/assistant/respond.py +++ b/assistants/knowledge-transfer-assistant/assistant/respond.py @@ -1,4 +1,3 @@ -import re import time from textwrap import dedent from typing import Any, ClassVar @@ -13,7 +12,8 @@ ChatCompletionUserMessageParam, ) from openai_client import num_tokens_from_messages -from openai_client.completion import message_content_from_completion +from openai_client.completion import assistant_message_from_completion +from openai_client.errors import CompletionError from openai_client.tools import complete_with_tool_calls from pydantic import ConfigDict, Field from semantic_workbench_api_model.workbench_model import ( @@ -26,18 +26,24 @@ ConversationContext, ) -from assistant.domain.conversation_preferences_manager import ConversationPreferencesManager -from assistant.domain.learning_objectives_manager import LearningObjectivesManager +from assistant.domain.conversation_preferences_manager import ( + ConversationPreferencesManager, +) from assistant.domain.share_manager import ShareManager -from .agentic.analysis import detect_information_request_needs -from .agentic.coordinator_support import CoordinatorSupport +from .agentic.detect_information_requests import detect_information_request_needs from .config import assistant_config -from .data import ConversationRole, CoordinatorConversationMessage, RequestStatus +from .data import ConversationRole from .logging import logger -from .string_utils import Context, ContextStrategy, Instructions, Prompt, TokenBudget +from .prompt_utils import ( + ContextSection, + ContextStrategy, + Instructions, + Prompt, + TokenBudget, + add_context_to_prompt, +) from .tools import ShareTools -from .ui_tabs.common import get_priority_emoji, get_status_emoji from .utils import load_text_include SILENCE_TOKEN = "{{SILENCE}}" @@ -58,15 +64,15 @@ class CoordinatorOutput(BaseModel): """ Attributes: response: The response from the assistant. - next_step_suggestion: Help for the coordinator to understand what to do next. A great way to progressively reveal the knowledge transfer process. + next_step_suggestion: Help for the user to understand what to do next. A great way to progressively reveal the knowledge transfer process. """ # noqa: E501 response: str = Field( description="The response from the assistant. The response should not duplicate information from the excerpt but may refer to it.", # noqa: E501 ) - next_step_suggestion: str = Field( - description="Help for the coordinator to understand what to do next. A great way to progressively reveal the knowledge transfer process. The audience is the coordinator, so this should be a suggestion for them to take action. Do NOT use this field to communicate what you, the assistant, are going to do next. Assume the coordinator has not yet used this assistant before and make sure to explain concepts such as the knowledge brief and learning outcomes clearly the first time you mention them.", # noqa: E501 - ) + # next_step_suggestion: str = Field( + # description="Help for the user to understand what to do next. A great way to progressively reveal the knowledge transfer process. The audience is the user, so this should be a suggestion for them to take action. Do NOT use this field to communicate what you, the assistant, are going to do next. Assume the user has not yet used this assistant before and make sure to explain concepts such as the knowledge brief and learning outcomes clearly the first time you mention them.", # noqa: E501 + # ) model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") @@ -100,21 +106,17 @@ async def respond_to_conversation( new_message: ConversationMessage, attachments_extension: AttachmentsExtension, metadata: dict[str, Any], -) -> None: +) -> ChatCompletionAssistantMessageParam | None: """ Respond to a conversation message. """ if "debug" not in metadata: metadata["debug"] = {} - # Config config = await assistant_config.get(context.assistant) model = config.request_config.openai_model - - # Requirements - role = await ShareManager.get_conversation_role(context) or ConversationRole.COORDINATOR + role = await ShareManager.get_conversation_role(context) metadata["debug"]["role"] = role - token_budget = TokenBudget(config.request_config.max_tokens) ## @@ -171,179 +173,37 @@ async def respond_to_conversation( if role == ConversationRole.TEAM: prompt.output_format = "Respond as JSON with your response in the `response` field and all citations in the `citations` field. In the `next_step_suggestion` field, suggest more areas to explore using content from the assistant whiteboard to ensure your conversation covers all of the relevant information." # noqa: E501 - ### - ### Context - ### - - # Project info - share = await ShareManager.get_share(context) - if share: - share_info_text = share.model_dump_json( - indent=2, - exclude={ - "brief", - "learning_objectives", - "takeaways", - "preferred_communication_style", - "transfer_notes", - "digest", - "next_learning_actions", - "transfer_lifecycle", - "archived", - "requests", - "log", - }, - ) - prompt.contexts.append(Context("Knowledge Info", share_info_text)) - - # Brief - if share and share.brief: - brief_text = "" - brief_text = f"**Title:** {share.brief.title}\n**Description:** {share.brief.content}" - prompt.contexts.append( - Context( - "Knowledge Brief", - brief_text, - ) - ) - - # Audience (for coordinators to understand target audience) - if role == ConversationRole.COORDINATOR and share and share.audience: - audience_context = share.audience - if not share.is_intended_to_accomplish_outcomes: - audience_context += "\n\n**Note:** This knowledge package is intended for general exploration, not specific learning outcomes." # noqa: E501 - - prompt.contexts.append( - Context( - "Target Audience", - audience_context, - "Description of the intended audience and their existing knowledge level for this knowledge transfer.", - ) - ) - - # Learning objectives - if share and share.learning_objectives: - learning_objectives_text = "" - conversation_id = str(context.id) - - # Show progress based on role - if role == ConversationRole.COORDINATOR: - # Coordinator sees overall progress across all team members - achieved_overall, total_overall = LearningObjectivesManager.get_overall_completion(share) - learning_objectives_text += ( - f"Overall Progress: {achieved_overall}/{total_overall} outcomes achieved by team members\n\n" - ) - else: - # Team member sees their personal progress - if conversation_id in share.team_conversations: - achieved_personal, total_personal = LearningObjectivesManager.get_completion_for_conversation( - share, conversation_id - ) - progress_pct = int(achieved_personal / total_personal * 100) if total_personal > 0 else 0 - learning_objectives_text += ( - f"My Progress: {achieved_personal}/{total_personal} outcomes achieved ({progress_pct}%)\n\n" - ) - - for i, objective in enumerate(share.learning_objectives): - learning_objectives_text += f"{i + 1}. **{objective.name}** - {objective.description}\n" - if objective.learning_outcomes: - for criterion in objective.learning_outcomes: - if role == ConversationRole.COORDINATOR: - # Show if achieved by any team member - achieved_by_any = any( - LearningObjectivesManager.is_outcome_achieved_by_conversation(share, criterion.id, conv_id) - for conv_id in share.team_conversations - ) - check = "✅" if achieved_by_any else "⬜" - else: - # Show if achieved by this team member - achieved_by_me = LearningObjectivesManager.is_outcome_achieved_by_conversation( - share, criterion.id, conversation_id - ) - check = "✅" if achieved_by_me else "⬜" - - learning_objectives_text += f" {check} {criterion.description}\n" - prompt.contexts.append( - Context( - "Learning Objectives", - learning_objectives_text, - ) - ) - - # Knowledge digest - if share and share.digest and share.digest.content: - prompt.contexts.append( - Context( - "Knowledge digest", - share.digest.content, - "The assistant-maintained knowledge digest.", - ) - ) - - # Information requests - if share: - all_requests = share.requests - if role == ConversationRole.COORDINATOR: - active_requests = [r for r in all_requests if r.status != RequestStatus.RESOLVED] - if active_requests: - coordinator_requests = ( - "> 📋 **Use the request ID (not the title) with resolve_information_request()**\n\n" - ) - for req in active_requests[:10]: # Limit to 10 for brevity - priority_emoji = get_priority_emoji(req.priority) - status_emoji = get_status_emoji(req.status) - coordinator_requests += f"{priority_emoji} **{req.title}** {status_emoji}\n" - coordinator_requests += f" **Request ID:** `{req.request_id}`\n" - coordinator_requests += f" **Description:** {req.description}\n\n" - - if len(active_requests) > 10: - coordinator_requests += f"*...and {len(active_requests) - 10} more requests.*\n" - else: - coordinator_requests = "No active information requests." - prompt.contexts.append( - Context( - "Information Requests", - coordinator_requests, - ) - ) - else: # team role - information_requests_info = "" - my_requests = [] - - # Filter for requests from this conversation that aren't resolved. - my_requests = [ - r for r in all_requests if r.conversation_id == str(context.id) and r.status != RequestStatus.RESOLVED - ] - - if my_requests: - information_requests_info = "" - for req in my_requests: - information_requests_info += ( - f"- **{req.title}** (ID: `{req.request_id}`, Priority: {req.priority})\n" - ) - else: - information_requests_info = "No active information requests." - - prompt.contexts.append( - Context( - "Information Requests", - information_requests_info, - ) - ) + ## + ## CONTEXT + ## - # Add next action suggestions for coordinator - if role == ConversationRole.COORDINATOR: - next_action_suggestion = await CoordinatorSupport.get_coordinator_next_action_suggestion(context) - if next_action_suggestion: - prompt.contexts.append( - Context( - "Suggested Next Actions", - next_action_suggestion, - "Actions the coordinator should consider taking based on the current knowledge transfer state.", - ) - ) + sections = [ + ContextSection.KNOWLEDGE_INFO, + ContextSection.KNOWLEDGE_BRIEF, + ContextSection.TARGET_AUDIENCE, + ContextSection.LEARNING_OBJECTIVES, + ContextSection.KNOWLEDGE_DIGEST, + ContextSection.INFORMATION_REQUESTS, + # ContextSection.SUGGESTED_NEXT_ACTIONS, + ContextSection.ATTACHMENTS, + ContextSection.ASSISTANT_THOUGHTS, + ] + if role == ConversationRole.TEAM: + sections.append(ContextSection.COORDINATOR_CONVERSATION) + + await add_context_to_prompt( + prompt, + context=context, + role=role, + model=model, + token_limit=config.request_config.max_tokens, + attachments_extension=attachments_extension, + attachments_config=config.attachments_config, + attachments_in_system_message=False, + include=sections, + ) - # Calculate token count for all system messages so far. + # Calculate token count for all prompt so far. completion_messages = prompt.messages() token_budget.add( num_tokens_from_messages( @@ -352,74 +212,6 @@ async def respond_to_conversation( ) ) - ### - ### Coordinator conversation as an attachment. - ### - - # Get the coordinator conversation and add it as an attachment. - coordinator_conversation = await ShareManager.get_coordinator_conversation(context) - if coordinator_conversation: - # Limit messages to the configured max token count. - total_coordinator_conversation_tokens = 0 - selected_coordinator_conversation_messages: list[CoordinatorConversationMessage] = [] - for msg in reversed(coordinator_conversation.messages): - tokens = openai_client.num_tokens_from_string(msg.model_dump_json(), model=model) - if ( - total_coordinator_conversation_tokens + tokens - > config.request_config.coordinator_conversation_token_limit - ): - break - selected_coordinator_conversation_messages.append(msg) - total_coordinator_conversation_tokens += tokens - - # Create a new coordinator conversation system message with the selected messages. - class CoordinatorMessageList(BaseModel): - messages: list[CoordinatorConversationMessage] = Field(default_factory=list) - - selected_coordinator_conversation_messages.reverse() - coordinator_message_list = CoordinatorMessageList(messages=selected_coordinator_conversation_messages) - coordinator_conversation_message = ChatCompletionSystemMessageParam( - role="system", - content=( - f"CoordinatorConversation.json{coordinator_message_list.model_dump_json()}" - ), - ) - completion_messages.append(coordinator_conversation_message) - - token_budget.add( - num_tokens_from_messages( - model=model, - messages=[coordinator_conversation_message], - ) - ) - - ### - ### ATTACHMENTS - ### - - # TODO: A better pattern here might be to keep the attachments as user - # in the proper flow of the conversation rather than as . - - # Generate the attachment messages. - attachment_messages: list[ChatCompletionMessageParam] = openai_client.convert_from_completion_messages( - await attachments_extension.get_completion_messages_for_attachments( - context, - config=config.attachments_config, - ) - ) - - # TODO: This will exceed the token limit if there are too many attachments. - # We do give them a warning below, though, and tell them to remove - # attachments if this happens. - - token_budget.add( - num_tokens_from_messages( - model=model, - messages=attachment_messages, - ) - ) - completion_messages.extend(attachment_messages) - ### ### USER MESSAGE ### @@ -559,6 +351,7 @@ class CoordinatorMessageList(BaseModel): ## MAKE THE LLM CALL ## + content = "" async with openai_client.create_client(config.service_config) as client: try: completion_args = { @@ -570,11 +363,12 @@ class CoordinatorMessageList(BaseModel): share_tools = ShareTools(context, role) response_start_time = time.time() - completion_response, additional_messages = await complete_with_tool_calls( + completion_response, _ = await complete_with_tool_calls( async_client=client, completion_args=completion_args, tool_functions=share_tools.tool_functions, metadata=metadata["debug"], + max_tool_call_rounds=16, ) response_end_time = time.time() footer_items = [] @@ -603,78 +397,26 @@ class CoordinatorMessageList(BaseModel): footer_items.append(get_response_duration_message(response_end_time - response_start_time)) metadata["footer_items"] = footer_items + return assistant_message_from_completion(completion_response) if completion_response else None - content = message_content_from_completion(completion_response) - if not content: - content = "I've processed your request, but couldn't generate a proper response." - - except Exception as e: - logger.exception(f"exception occurred calling openai chat completion: {e}") - content = "An error occurred while calling the OpenAI API. Is it configured correctly?" + except CompletionError as e: + logger.exception(f"Exception occurred calling OpenAI chat completion: {e}") metadata["debug"]["error"] = str(e) - - if content: - # strip out the username from the response - if isinstance(content, str) and content.startswith("["): - content = re.sub(r"\[.*\]:\s", "", content) - - # check for the silence token, in case the model chooses not to respond - # model sometimes puts extra spaces in the response, so remove them - # when checking for the silence token - if isinstance(content, str) and content.replace(" ", "") == SILENCE_TOKEN: - # normal behavior is to not respond if the model chooses to remain silent - # but we can override this behavior for debugging purposes via the assistant config - if config.enable_debug_output: - metadata["debug"]["silence_token"] = True - metadata["debug"]["silence_token_response"] = (content,) - await context.send_messages( - NewConversationMessage( - message_type=MessageType.notice, - content="[assistant chose to remain silent]", - metadata=metadata, - ) + if isinstance(e.body, dict) and "message" in e.body: + content = e.body.get("message", e.message) + elif e.message: + content = e.message + else: + content = "An error occurred while processing your request." + await context.send_messages( + NewConversationMessage( + content=content, + message_type=MessageType.notice, + metadata=metadata, ) + ) return - # Prepare response. - response_parts: list[str] = [] - try: - if role == ConversationRole.TEAM: - output_model = TeamOutput.model_validate_json(content) - if output_model.response: - response_parts.append(output_model.response) - - if output_model.excerpt: - output_model.excerpt = output_model.excerpt.strip().strip('"') - response_parts.append(f'> _"{output_model.excerpt}"_ (excerpt)') - - if output_model.citations: - citations = ", ".join(output_model.citations) - response_parts.append(f"Sources: _{citations}_") - - if output_model.next_step_suggestion: - metadata["help"] = output_model.next_step_suggestion - - else: - output_model = CoordinatorOutput.model_validate_json(content) - if output_model.response: - response_parts.append(output_model.response) - if output_model.next_step_suggestion: - metadata["help"] = output_model.next_step_suggestion - - except Exception as e: - logger.exception(f"exception occurred parsing json response: {e}") - metadata["debug"]["error"] = str(e) - response_parts.append(content) - - await context.send_messages( - NewConversationMessage( - content="\n\n".join(response_parts), - message_type=MessageType.chat, - metadata=metadata, - ) - ) - def get_formatted_token_count(tokens: int) -> str: # if less than 1k, return the number of tokens diff --git a/assistants/knowledge-transfer-assistant/assistant/storage.py b/assistants/knowledge-transfer-assistant/assistant/storage.py index afdc6a9dd..18eb330d3 100644 --- a/assistants/knowledge-transfer-assistant/assistant/storage.py +++ b/assistants/knowledge-transfer-assistant/assistant/storage.py @@ -112,6 +112,32 @@ def write_share(share_id: str, share: Share) -> pathlib.Path: write_model(path, share) return path + @staticmethod + def read_assistant_thoughts(share_id: str) -> list[str]: + share = ShareStorage.read_share(share_id) + if not share: + return [] + return share.assistant_thoughts + + @staticmethod + def add_assistant_thoughts(share_id: str, thoughts: list[str]) -> None: + share = ShareStorage.read_share(share_id) + if not share: + raise NoShareException + share.assistant_thoughts.extend(thoughts) + ShareStorage.write_share(share_id, share) + + @staticmethod + def remove_assistant_thought(share_id: str, thought: str) -> None: + share = ShareStorage.read_share(share_id) + if not share: + raise NoShareException + if thought in share.assistant_thoughts: + share.assistant_thoughts.remove(thought) + ShareStorage.write_share(share_id, share) + else: + logger.warning(f"Thought '{thought}' not found in share {share_id}.") + @staticmethod def read_knowledge_brief(share_id: str) -> KnowledgeBrief | None: share = ShareStorage.read_share(share_id) diff --git a/assistants/knowledge-transfer-assistant/assistant/string_utils.py b/assistants/knowledge-transfer-assistant/assistant/string_utils.py index 08ca97a82..56f0639e9 100644 --- a/assistants/knowledge-transfer-assistant/assistant/string_utils.py +++ b/assistants/knowledge-transfer-assistant/assistant/string_utils.py @@ -1,12 +1,9 @@ -from dataclasses import dataclass, field -from enum import Enum from typing import Any from liquid import Template -from openai.types.chat import ChatCompletionMessageParam -def render(template: str, vars: dict[str, Any]) -> str: +def render(template: str, vars: dict[str, Any] = {}) -> str: """ Format a string with the given variables using the Liquid template engine. """ @@ -16,137 +13,3 @@ def render(template: str, vars: dict[str, Any]) -> str: liquid_template = Template(template) parsed = liquid_template.render(**vars) return parsed - - -def create_system_message(content: str, delimiter: str | None = None) -> ChatCompletionMessageParam: - if delimiter: - content = f"<{delimiter}>\n{content}\n" - - message: ChatCompletionMessageParam = { - "role": "system", - "content": content, - } - return message - - -class Instructions: - """ - A class to represent a section of a prompt. - """ - - def __init__( - self, - content: str, - title: str | None = None, - ) -> None: - self.title = title - self.content = content - self.level = 0 - self.subsections: list[Instructions] = [] - - def add_subsection(self, subsection: "Instructions") -> None: - """ - Add a subsection to the prompt section. - """ - subsection.level = self.level + 1 - self.subsections.append(subsection) - - def __str__(self) -> str: - s = "" - if self.title: - hashes = "#" * (self.level + 1) - s += f"{hashes} {self.title}\n\n" - s += self.content - if self.subsections: - s += "\n\n" + "\n\n".join(str(subsection) for subsection in self.subsections) - - return s - - -class Context: - def __init__(self, name: str, data: str, description: str | None = None) -> None: - self.name = name - self.description = description - self.data = data - - def message(self) -> ChatCompletionMessageParam: - return create_system_message(self.content(), self.name) - - def content(self) -> str: - s = self.data - if self.description: - s = f"{self.description}\n\n'''\n{self.data}\n'''" - return s - - -class ContextStrategy(Enum): - SINGLE = "single" # Put all contexts in a single message. - MULTI = "multi" # Put each context in its own message. - - -@dataclass -class Prompt: - role: str - instructions: Instructions - output_format: str | None = None - reasoning_steps: str | None = None - examples: str | None = None - contexts: list[Context] = field(default_factory=list) - context_strategy: ContextStrategy = ContextStrategy.SINGLE - final_instructions: str | None = None - - def messages(self) -> list[ChatCompletionMessageParam]: - parts = [ - "# Role and Objective", - self.role, - "# Instructions", - str(self.instructions), - ] - if self.reasoning_steps: - parts.append("# Reasoning Steps") - parts.append(self.reasoning_steps) - if self.output_format: - parts.append("# Output Format") - parts.append(self.output_format) - if self.examples: - parts.append("# Examples") - parts.append(self.examples) - if self.contexts and self.context_strategy == ContextStrategy.SINGLE: - parts.append("# Context") - for context in self.contexts: - parts.append(f"## {context.name}") - parts.append(context.content()) - s = "\n\n".join(parts) - if self.final_instructions: - s += "\n\n" + self.final_instructions - - messages = [ - create_system_message(s), - ] - - if self.contexts and self.context_strategy == ContextStrategy.MULTI: - for context in self.contexts: - messages.append(context.message()) - - return messages - - -class TokenBudget: - def __init__(self, budget: int) -> None: - self.budget = budget - self.used = 0 - - def add(self, tokens: int) -> None: - self.used += tokens - - def remaining(self) -> int: - return self.budget - self.used - - def is_under_budget(self) -> bool: - return self.remaining() > 0 - - def is_over_budget(self) -> bool: - return self.remaining() < 0 - - def fits(self, tokens: int) -> bool: - return self.remaining() >= tokens diff --git a/assistants/knowledge-transfer-assistant/assistant/text_includes/background_thought_audience.md b/assistants/knowledge-transfer-assistant/assistant/text_includes/background_thought_audience.md new file mode 100644 index 000000000..e88762292 --- /dev/null +++ b/assistants/knowledge-transfer-assistant/assistant/text_includes/background_thought_audience.md @@ -0,0 +1,18 @@ +You are an AI assistant watching a conversation between another assistant and a user where the assistant is helping the user assemble a knowledge package for an audience. Your job is to ensure the audience is well defined by making suggestions to the assistant as needed. + +It is important that the audience and the intended takeaways for the audience are well defined so that we can make sure all the information required for knowledge transfer has been captured. + +Situations in which you should suggest to the assistant they attend to something (think about it): + +- If the audience has not been clearly defined, suggest the assistant guide the user to define it. +- If the audience has been defined but no specific intended takeaways for that audience have been defined, suggest the assistant guide the user in defining the intended takeaways. +- If the audience or takeaways have been defined generally, suggest the assistant guide the user to make them more specific and concrete. +- If recent messages give information about the intended audience the assistant should use the `update_audience` tool. If they haven't, remind them. +- If recent messages give information about the intended audience takeaways, the assistant should use the `update_audience_takeaways` tool. If they haven't, remind them. +- Sometimes the assistant might have duplicate thoughts about the audience or takeaways. If so, suggest the assistant remove one or more of them. + +Under all other circumstances, you should reply with an empty list. Oftentimes there will be nothing related to the audience or takeaways in new messages. This is quite common in a conversation. Return with an empty list in this case. + +IMPORTANT! If the assistant is already thinking about what you want them to think about, you don't need to tell them again. The assistant's thoughts are shown below. + +Respond with a list of suggestions for the assistant related to the audience or audience takeaways in JSON. diff --git a/assistants/knowledge-transfer-assistant/assistant/text_includes/coordinator_instructions.txt b/assistants/knowledge-transfer-assistant/assistant/text_includes/coordinator_instructions.txt index 9c7f860ff..f8a986078 100644 --- a/assistants/knowledge-transfer-assistant/assistant/text_includes/coordinator_instructions.txt +++ b/assistants/knowledge-transfer-assistant/assistant/text_includes/coordinator_instructions.txt @@ -4,26 +4,26 @@ - If there are active `NEW` information requests, alert the user and ask for input to resolve them. -### Audience Definition +### Unresolved Thoughts -- The audience is the team members who will receive the knowledge package. -- If the audience is not yet defined, prompt the user to describe who the knowledge is for. -- Use your `update_audience` tool to update the audience definition as you learn more about the intended audience. -- Update the audience proactively as the user provides more information. Don't ask for permission. +Previous unresolved thoughts you've had about the user's request are stored in the "Assistant Thoughts" section below. You should always refer to this section and attempt to resolve those thoughts as you are working with the user. -### Brief +Once you have resolved a thought, you can remove it from the "Assistant Thoughts" section using the `forget_thought` tool. -- If a knowledge brief has not been created, help the user write one that includes purpose, scope, and context of the knowledge share. -- When the user asks you to update the brief, use the `update_brief` tool to do so. -- Do NOT say you updated the brief unless you have first successfully used the tool to do so. -- Update the brief proactively as the user provides more information. Don't ask for permission. +Remove redundant or no longer relevant thoughts. They'll always come back if important. -### Files +### Knowledge Share Definition -- When files are uploaded, acknowledge the upload and summarize the file contents if possible. -- *Do not ask what to do with the file—it is already shared with team members.* +#### Audience Definition + +- "The audience" are the people who the knowledge package will be shared with. +- Help the user describe who the knowledge is for and their level of experience. +- If the audience is not yet or vaguely defined, prompt the user to describe who the knowledge is for. +- Use your `update_audience` tool to update the audience definition as you learn more about the intended audience. +- Update the audience proactively as the user provides more information. Don't ask for permission. +- Help the user define intended audience takeaways. This is important to have up-front so we can figure out how to organize the knowledge package and what to include in the brief. -### Learning Objectives +#### Learning Objectives - If the user intends to accomplish outcomes: - Help define **Learning Objectives** with names, descriptions, and priority. @@ -31,63 +31,65 @@ - Help ensure every objective has at least one outcome. - You must run tools to add update the learning objectives and outcomes. It is not enough to just reply that you added/updated. You must use your tools to do so. - If the package is for general exploration (i.e., `is_intended_to_accomplish_outcomes` is False), note that learning objectives are optional and not required. - -### Package Status and Flow - -- If the package is missing key components (audience, brief, or required objectives/outcomes), guide the user to complete those. -- If the package is ready for transfer, provide the invitation link and assist in sharing it with an appropriate message tailored to the audience. - -### Conversation Flow - -Allow the user to drive the interaction. However, your responsibility is to ensure that all essential knowledge package components are created, refined, and shared. - -This flow aligns with the knowledge transfer lifecycle and the system’s internal readiness checks: - -1. **Audience Definition** - - Help the user describe who the knowledge is for and their level of experience. - -2. **Learning Objectives (Optional)** - - - Ask if the user wants to define specific learning objectives and outcomes for their team. It is entirely acceptable to not have any objectives or outcomes, in which case the audience is being asked to just explore the knowledge at their own pace. +- Ask if the user wants to define specific learning objectives and outcomes for their audience. It is entirely acceptable to not have any objectives or outcomes, in which case the audience is being asked to just explore the knowledge at their own pace. - If yes, help create them and ensure that each objective includes at least one outcome. - If no, confirm that the package is intended for general exploration. -3. **Knowledge Content** +#### Invitation - Assist the user in uploading and/or describing the knowledge to be shared. The knowledge content can include attached files and anything the user adds to the conversation. Help the user fill in gaps in the knowledge content as needed. Everything the intended audience needs to know should be included in the knowledge content. +- Help the user write a short message and generate a customized invitation link to send to audience members. +- The message should be in the user's voice as they will copy and paste it into communication tools like SMS, Microsoft Teams, or email. +- It shouldn't include all the details about the knowledge share, just a brief statement about what it is, why they should be interested to check it out, and the invitation link. +- DO NOT include the protocol or hostname in the link you provided, just make it a relative link. Examples: + - [New project knowledge share-out](/conversation-share/a5b400d4-b8c4-4484-ae83-dad98fe49b06/redeem) + - [Learning about MADE](/conversation-share/12345678-1234-1234-1234-123456789012/redeem) + - [Our Knowledge Base](/conversation-share/abcdef12-3456-7890-abcd-ef1234567890/redeem) -4. **Knowledge Brief** +#### Knowledge Brief - - Guide the creation of an overview of the knowledge share that will be available to team members to see alongside their session. - - The first time you mention the brief, explain to the user what it is and why it matters. - - When talking about the brief, there is no need to explain its contents. The user can see it in their side panel. - - A brief should not include learning objectives or outcomes, as these are separate components. +- The "knowledge brief" is a fairly detailed summary of the knowledge share that is prepared by the user and will be displayed at all times to the audience. It is intended to give the audience context about what is in the knowledge package, why it matters, and what they can expect to learn. +- If a knowledge brief has not been created, help the user write one. +- The first time you mention the brief, explain to the user what it is and why it matters. +- When the user asks you to update the brief, use the `update_brief` tool to do so. Do NOT say you updated the brief unless you have first successfully used the tool to do so. +- Update the brief proactively as the user provides more information. Don't ask for permission. +- When talking about the brief, there is no need to explain its contents. The user can see it in their side panel. +- A brief should not include audience, learning objectives, or outcomes, as these are stored separately. + +### Knowledge Package (chat messages and attached files) + +- Assist the user in uploading and/or describing the knowledge to be shared. The "knowledge package" includes what is talked about in the chat, attached files, the brief, and the knowledge digest. +- When files are uploaded, acknowledge the upload and summarize the file contents if possible. +- Everything the intended audience needs to know should be included in the knowledge package. +- Help the user fill in gaps in the knowledge package as needed: + - Ensure the audience can takeaway what the user intends. + - If learning objectives are defined, ensure that the knowledge package covers all required outcomes. + - This step is CRUCIAL to ensure that the knowledge package is comprehensive and allows the audience to meet the takeaways and learning objectives. -5. **Gap Analysis** +### Conversation Flow - - If learning objectives are defined, help the user ensure that the knowledge content covers all required outcomes. - - This step is CRUCIAL to ensure that the knowledge share is comprehensive and allows the team to meet the learning goals. +Allow the user to drive the interaction. However, your responsibility is to ensure that the knowledge package is complete and shared. -6. **Share Message and Invitation Link** +- If the knowledge share is missing key definition (audience, audience takeaways, required objectives/outcomes), guide the user to define these things. +- If the knowledge package is missing content (chat messages, files, etc.), help the user add it. +- If the user has not defined learning objectives and outcomes or indicated they have no specific outcomes, ask if they want to do so. +- If the intended audience takeaways (and learning objectives) are not able to be achieved with the current knowledge package, help the user fill in gaps. +- If the knowledge package has no brief, help the user write one. +- If the package is ready for transfer, provide the invitation link and assist in sharing it with an appropriate message tailored to the audience. - - Help the user write a short message and generate a customized invitation link to send to team members. - - The message should be in the user's voice as they will copy and paste it into communication tools like Teams or email. - - It shouldn't include all the details about the knowledge share, just a brief statement about what it is, why they should be interested to check it out, and the invitation link. - - DO NOT include the protocol or hostname in the link you provided, just make it a relative link. Examples: - - [New project knowledge share-out](/conversation-share/a5b400d4-b8c4-4484-ae83-dad98fe49b06/redeem) - - [Learning about MADE](/conversation-share/12345678-1234-1234-1234-123456789012/redeem) - - [Our Team Knowledge Base](/conversation-share/abcdef12-3456-7890-abcd-ef1234567890/redeem) +An example conversation flow: -After the knowledge package is shared: +- Defined the intended audience takeaways. +- Define the audience. +- Define optional learning objectives and outcomes. +- Help the user add content to the knowledge package. +- Help run a gap analysis and help the user fill in gaps. +- Prepare the Knowledge brief. +- Help create an invitation. -- Continue helping the user address any information requests from team members. -- Support updates to the audience definition, knowledge brief, objectives, or outcomes at any time. +This is a general flow, but you should adapt it based on the user's needs and the current state of the knowledge package. ### Post-Transfer Support -Once shared, help the user: +After the knowledge package is shared, help the user address any information requests. -- Respond to new team member information requests. -- Monitor and update objectives, outcomes, or brief content. -- Track team progress against learning objectives. +Support updates to the audience definition, knowledge brief, objectives, outcomes, or knowledge package content at any time. diff --git a/assistants/knowledge-transfer-assistant/assistant/text_includes/coordinator_role.txt b/assistants/knowledge-transfer-assistant/assistant/text_includes/coordinator_role.txt index 07956b7e7..aec1fb03a 100644 --- a/assistants/knowledge-transfer-assistant/assistant/text_includes/coordinator_role.txt +++ b/assistants/knowledge-transfer-assistant/assistant/text_includes/coordinator_role.txt @@ -1,19 +1,15 @@ # Role and Objective -You are an autonomous AI assistant named the "Knowledge Transfer Assistant" that supports a user in creating and refining a knowledge package that will be shared with team members. +You are an autonomous AI assistant named the "Knowledge Transfer Assistant" that supports a user in creating and refining a knowledge package that will be shared with an audience. Please keep going until the user's query is completely resolved, before ending your turn and yielding back to the user. Only terminate your turn when you are sure that the problem is solved. +You are driven to guide the user by the state of the knowledge share as captured below, and, importantly, by thoughts you've had about this process. You have a sophisticated background-thinking system and your thoughts are shown in the "Assistant Thoughts" section below. You should always refer to this section and attempt to resolve those thoughts as you are guiding the user. + If you are not sure about attachment content or knowledge package structure pertaining to the user’s request, gather the relevant information: do NOT guess or make up an answer. You MUST plan extensively before each function call, and reflect extensively on the outcomes of the previous function calls. DO NOT do this entire process by making function calls only, as this can impair your ability to solve the problem and think insightfully. -Your purpose is to help the user assemble and organize the knowledge package. These package includes: - -- **Audience Definition**: A clear explanation of who the knowledge is for, including their context and expertise level. -- **Knowledge Content**: File attachments and descriptions that capture the subject matter to be shared. -- **Knowledge Brief**: An introductory overview of the knowledge package for team members. It orients them to the content and explains why it matters. Don't worry about making the brief short. Suggest a simply worded, yet comprehensive overview of the shared knowledge. -- **Learning Objectives and Outcomes (optional)**: If the user wants the team to achieve specific goals, help them define objectives and outcomes to track learning success. -- **Invitation Link**: A shareable link to distribute the knowledge package, with a clear message and customized anchor text. +Your purpose is to guide the user through the knowledge transfer process, helping them assemble and organize the knowledge share and the knowledge package. -You are responsible for guiding the user through the knowledge transfer. After the knowledge package is shared, continue supporting the user by helping respond to information requests and updating the package as needed. +After the knowledge package is shared, continue supporting the user by helping respond to information requests and updating the package as needed. diff --git a/assistants/knowledge-transfer-assistant/assistant/text_includes/knowledge_digest_instructions.txt b/assistants/knowledge-transfer-assistant/assistant/text_includes/knowledge_digest_instructions.txt index a5adc01a1..371220619 100644 --- a/assistants/knowledge-transfer-assistant/assistant/text_includes/knowledge_digest_instructions.txt +++ b/assistants/knowledge-transfer-assistant/assistant/text_includes/knowledge_digest_instructions.txt @@ -1,11 +1,11 @@ -The coordinator assistant's knowledge digest contains information about this knowledge share that has been extracted from the previous conversation thread and attachments. +The user's assistant's knowledge digest contains information about this knowledge share that has been extracted from the previous conversation thread and attachments. It serves as a persistent memory of important facts, decisions, and context. Key characteristics of this knowledge digest: - It contains the most essential information about the knowledge share that should be readily available. - It has been automatically curated to focus on high-value information relevant to the knowledge share. -- It is maintained and updated as the conversation progresses. +- It is maintained and updated as knowledge content is added. - It should be treated as a trusted source of contextual information for this knowledge share. When using the knowledge digest: diff --git a/assistants/knowledge-transfer-assistant/assistant/text_includes/knowledge_digest_prompt.txt b/assistants/knowledge-transfer-assistant/assistant/text_includes/knowledge_digest_update.txt similarity index 87% rename from assistants/knowledge-transfer-assistant/assistant/text_includes/knowledge_digest_prompt.txt rename to assistants/knowledge-transfer-assistant/assistant/text_includes/knowledge_digest_update.txt index 3e0c0f203..2dffb9093 100644 --- a/assistants/knowledge-transfer-assistant/assistant/text_includes/knowledge_digest_prompt.txt +++ b/assistants/knowledge-transfer-assistant/assistant/text_includes/knowledge_digest_update.txt @@ -1,11 +1,11 @@ -You, the assistant, maintain a "knowledge digest". Based on the last chat message, it is time to update the knowledge digest. +You, the assistant, maintain a "knowledge digest". Based on the last chat message, it is time to consider updating your knowledge digest. ## What the knowledge digest is - The knowledge digest contains an outline of the knowledge contained within a knowledge share. It is not a summary, but an organized projection of all knowledge added to the packet from the coordinator's conversation and attachments. - The digest has a high information density. The digest contains no filler words or unnecessary content. - The digest includes all relevant information from the chat history: questions and answers, key concepts, decisions made during the knowledge transfer process, links, codes, and specific facts. -- When handed off to another assistant to help the team understand the content of the knowledge share, this digest is the primary source of information. +- When handed off to another assistant to help the audience understand the content of the knowledge package, this digest is the primary source of information. - Think of the digest as your personal memory about this knowledge share - a concise reference that helps track important facts, decisions, and context. ## Knowledge digest instructions @@ -21,3 +21,4 @@ You, the assistant, maintain a "knowledge digest". Based on the last chat messag - Do not include objectives and outcomes in the knowledge digest. They are maintained separately. - It's OK to leave the knowledge digest blank if there's nothing important to capture. - Your output format must be: {content} + diff --git a/assistants/knowledge-transfer-assistant/assistant/tools/__init__.py b/assistants/knowledge-transfer-assistant/assistant/tools/__init__.py index 7394c3292..878a7f5cd 100644 --- a/assistants/knowledge-transfer-assistant/assistant/tools/__init__.py +++ b/assistants/knowledge-transfer-assistant/assistant/tools/__init__.py @@ -9,6 +9,7 @@ from semantic_workbench_assistant.assistant_app import ConversationContext from assistant.data import ConversationRole +from assistant.tools.assistant_thoughts import AssistantThoughtsTools from assistant.tools.conversation_preferences import ConversationPreferencesTools from .information_requests import InformationRequestTools @@ -33,6 +34,7 @@ def __init__(self, context: ConversationContext, role: ConversationRole): self.role = role self.tool_functions = ToolFunctions() + self.thoughts = AssistantThoughtsTools(context, role) self.share_setup = ShareSetupTools(context, role) self.learning_objectives = LearningObjectiveTools(context, role) self.learning_outcomes = LearningOutcomeTools(context, role) @@ -48,15 +50,24 @@ def __init__(self, context: ConversationContext, role: ConversationRole): def _register_coordinator_tools(self): """Register coordinator-specific tools.""" + self.tool_functions.add_function( + self.thoughts.forget_thought, + "forget_thought", + ) + # 1. Setup phase - Define audience and organize knowledge self.tool_functions.add_function( self.share_setup.update_audience, "update_audience", ) self.tool_functions.add_function( - self.share_setup.set_knowledge_organized, - "set_knowledge_organized", + self.share_setup.update_audience_takeaways, + "update_audience_takeaways", ) + # self.tool_functions.add_function( + # self.share_setup.set_knowledge_organized, + # "set_knowledge_organized", + # ) # 2. Brief creation phase self.tool_functions.add_function( diff --git a/assistants/knowledge-transfer-assistant/assistant/tools/assistant_thoughts.py b/assistants/knowledge-transfer-assistant/assistant/tools/assistant_thoughts.py new file mode 100644 index 000000000..d00956134 --- /dev/null +++ b/assistants/knowledge-transfer-assistant/assistant/tools/assistant_thoughts.py @@ -0,0 +1,32 @@ +from assistant.domain.thoughts_manager import ThoughtsManager +from assistant.logging import logger + +from .base import ToolsBase + + +class AssistantThoughtsTools(ToolsBase): + """Tools for managing assistant thoughts.""" + + async def forget_thought(self, thought: str) -> str: + """ + Delete an assistant thought. This should be called when a thought has been resolved, is a duplicate, or is no longer relevant. + Args: + thought: The thought to forget. Must be the full text of the thought as it appears in the Assistant Thoughts section. + Returns: + Message indicating success or failure + """ # noqa: E501 + try: + message = await ThoughtsManager.remove_assistant_thought( + context=self.context, + thought=thought, + ) + if not message: + message = f"Thought '{thought}' deleted successfully." + else: + message = f"Thought '{thought}' deleted, but encountered an issue: {message}" + logger.info(f"Thought deleted: {thought}") + except Exception as e: + logger.exception(f"Failed to delete thought: {e}") + message = f"Failed to delete thought: {e!s}" + return message + return message diff --git a/assistants/knowledge-transfer-assistant/assistant/tools/learning_objectives.py b/assistants/knowledge-transfer-assistant/assistant/tools/learning_objectives.py index 1cc90cf68..4362ae6cf 100644 --- a/assistants/knowledge-transfer-assistant/assistant/tools/learning_objectives.py +++ b/assistants/knowledge-transfer-assistant/assistant/tools/learning_objectives.py @@ -57,7 +57,7 @@ async def update_learning_objective( Update an existing learning objective's name or description. Args: - objective_id: The unique ID of the learning objective to update + objective_id: The UUID of the learning objective to update objective_name: New name for the objective (empty string to keep current name) description: New description (empty string to keep current description) @@ -89,7 +89,7 @@ async def delete_learning_objective(self, objective_id: str) -> str: NOTE: This action is irreversible and will remove all learning outcomes associated with the objective. Args: - objective_id: The unique ID of the learning objective to delete. + objective_id: The UUID of the learning objective to delete. Returns: A message indicating success or failure diff --git a/assistants/knowledge-transfer-assistant/assistant/tools/learning_outcomes.py b/assistants/knowledge-transfer-assistant/assistant/tools/learning_outcomes.py index 30bf065ad..0ac4083d5 100644 --- a/assistants/knowledge-transfer-assistant/assistant/tools/learning_outcomes.py +++ b/assistants/knowledge-transfer-assistant/assistant/tools/learning_outcomes.py @@ -24,7 +24,7 @@ async def add_learning_outcome(self, objective_id: str, outcome_description: str - When iteratively developing learning objectives based on feedback Args: - objective_id: The unique ID of the learning objective to add the outcome to + objective_id: The UUID of the learning objective to add the outcome to outcome_description: Clear, specific description of what needs to be understood or accomplished Returns: diff --git a/assistants/knowledge-transfer-assistant/assistant/tools/share_setup.py b/assistants/knowledge-transfer-assistant/assistant/tools/share_setup.py index 1a3c0d435..233760062 100644 --- a/assistants/knowledge-transfer-assistant/assistant/tools/share_setup.py +++ b/assistants/knowledge-transfer-assistant/assistant/tools/share_setup.py @@ -4,7 +4,7 @@ Tools for configuring knowledge shares. """ -from datetime import UTC, datetime +from datetime import datetime from assistant.domain.audience_manager import AudienceManager from assistant.domain.knowledge_brief_manager import KnowledgeBriefManager @@ -35,33 +35,52 @@ async def update_audience(self, audience_description: str) -> str: except Exception as e: return f"Failed to update audience: {e!s}" - async def set_knowledge_organized(self, is_organized: bool) -> str: + async def update_audience_takeaways(self, takeaways: list[str]) -> str: """ - Mark that all necessary knowledge has been captured and organized for transfer. - - This indicates that the coordinator has uploaded files, shared information through conversation, and confirmed that all necessary knowledge for the transfer has been captured. This is required before the knowledge package can move to the "Ready for Transfer" state. + Update the key takeaways for the target audience. Args: - is_organized: True if knowledge is organized and ready, False to - mark as currently unorganized + takeaways: List of key takeaways for the audience. Takeaways should be concise and directly related to the audience and what the user wants them to learn or understand. Returns: A message indicating success or failure """ # noqa: E501 try: - share = await ShareManager.get_share(self.context) - share.knowledge_organized = is_organized - share.updated_at = datetime.now(UTC) - await ShareManager.set_share(self.context, share) - - if is_organized: - guidance = "Knowledge is now marked as organized and ready. You can proceed to create your brief and set up learning objectives." # noqa: E501 - else: - guidance = "Knowledge is now marked as incomplete. Continue organizing your knowledge by uploading files or describing it in conversation." # noqa: E501 - return f"Knowledge organization status updated successfully. {guidance}" - + await AudienceManager.update_audience_takeaways( + context=self.context, + takeaways=takeaways, + ) + return "Audience takeaways updated successfully" except Exception as e: - return f"Failed to update knowledge organization status: {e!s}" + return f"Failed to update audience takeaways: {e!s}" + + # async def set_knowledge_organized(self, is_organized: bool) -> str: + # """ + # Mark that all necessary knowledge has been captured and organized for transfer. + + # This indicates that the coordinator has uploaded files, shared information through conversation, and confirmed that all necessary knowledge for the transfer has been captured. This is required before the knowledge package can move to the "Ready for Transfer" state. + + # Args: + # is_organized: True if knowledge is organized and ready, False to + # mark as currently unorganized + + # Returns: + # A message indicating success or failure + # """ + # try: + # share = await ShareManager.get_share(self.context) + # share.knowledge_organized = is_organized + # share.updated_at = datetime.now(UTC) + # await ShareManager.set_share(self.context, share) + + # if is_organized: + # guidance = "Knowledge is now marked as organized and ready. You can proceed to create your brief and set up learning objectives." # noqa: E501 + # else: + # guidance = "Knowledge is now marked as incomplete. Continue organizing your knowledge by uploading files or describing it in conversation." # noqa: E501 + # return f"Knowledge organization status updated successfully. {guidance}" + + # except Exception as e: + # return f"Failed to update knowledge organization status: {e!s}" async def update_brief(self, title: str, description: str) -> str: """ diff --git a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/brief.py b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/brief.py index b75a3cb12..027bd6654 100644 --- a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/brief.py +++ b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/brief.py @@ -2,14 +2,12 @@ Brief inspector for knowledge transfer status and brief information. """ -from typing import Any - from semantic_workbench_assistant.assistant_app import ( AssistantConversationInspectorStateDataModel, ConversationContext, ) -from assistant.data import ConversationRole +from assistant.data import ConversationRole, Share from assistant.domain.share_manager import ShareManager from .common import get_stage_label @@ -38,91 +36,82 @@ async def is_enabled(self, context: ConversationContext) -> bool: async def get(self, context: ConversationContext) -> AssistantConversationInspectorStateDataModel: """Get brief and status information for display.""" - conversation_role = await ShareManager.get_conversation_role(context) - - # Get share information share = await ShareManager.get_share(context) - if not share: - return AssistantConversationInspectorStateDataModel( - data={"content": "No active knowledge package. Start a conversation to create one."} - ) - - brief = share.brief - share_info = await ShareManager.get_share(context) + conversation_role = await ShareManager.get_conversation_role(context) if conversation_role == ConversationRole.COORDINATOR: - markdown = await self._format_coordinator_brief(share.share_id, brief, share_info, context) + markdown = await self._format_coordinator_brief(share) else: - markdown = await self._format_team_brief(share.share_id, brief, share_info, context) + markdown = await self._format_team_brief(share) return AssistantConversationInspectorStateDataModel(data={"content": markdown}) async def _format_coordinator_brief( - self, share_id: str, brief: Any, share_info: Any, context: ConversationContext + self, + share: Share, ) -> str: """Format brief information for coordinator.""" lines: list[str] = [] - lines.append("**Role:** Coordinator") - - # Display knowledge transfer stage - stage_label = "📋 Organizing Knowledge" - if share_info: - stage_label = get_stage_label(share_info, for_coordinator=True) + # Stage + stage_label = get_stage_label(share, for_coordinator=True) lines.append(f"**Stage:** {stage_label}") - if share_info and share_info.transfer_notes: - lines.append(f"**Status Message:** {share_info.transfer_notes}") - - lines.append("") + # Audience and takeaways + lines.append("## Audience") + lines.append(share.audience if share.audience else "_No audience defined._") - # Knowledge Brief section + if share.audience_takeaways: + lines.append("### Key Takeaways") + lines.append("") + for takeaway in share.audience_takeaways: + lines.append(f"- {takeaway}") + lines.append("") - if brief: + brief = share.brief + if brief and brief.title: title = brief.title lines.append(f"## {title}") lines.append("") - - if brief.content: - lines.append(brief.content) - lines.append("") else: lines.append("## Knowledge Brief") lines.append("") + + if brief and brief.content: + lines.append(brief.content) + lines.append("") + else: lines.append(DEFAULT_BRIEF_INSTRUCTION) lines.append("") return "\n".join(lines) - async def _format_team_brief(self, share_id: str, brief: Any, share_info: Any, context: ConversationContext) -> str: + async def _format_team_brief(self, share: Share) -> str: """Format brief information for team members.""" lines: list[str] = [] - lines.append("**Role:** Team") - - # Display knowledge transfer stage for team members - stage_label = "📚 Learning Mode" - if share_info: - stage_label = get_stage_label(share_info, for_coordinator=False) + # Stage + stage_label = get_stage_label(share, for_coordinator=False) lines.append(f"**Stage:** {stage_label}") - # Add status message if available - if share_info and share_info.transfer_notes: - lines.append(f"**Status Message:** {share_info.transfer_notes}") - - lines.append("") - - # Knowledge Brief section + brief = share.brief if brief: title = brief.title lines.append(f"## {title}") lines.append("") - if brief.content: - lines.append(brief.content) - lines.append("") + if share.audience_takeaways: + lines.append("### Key Takeaways") + lines.append("") + for takeaway in share.audience_takeaways: + lines.append(f"- {takeaway}") + lines.append("") + + if brief and brief.content: + lines.append(brief.content) + lines.append("") else: lines.append("## Knowledge Brief") lines.append("") diff --git a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/common.py b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/common.py index 6b14d397b..fe53b390b 100644 --- a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/common.py +++ b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/common.py @@ -40,15 +40,12 @@ def get_stage_label(share: Share, for_coordinator: bool = True) -> str: Returns: str: Stage label with emoji """ - if share.archived: - return "📦 Archived" - if for_coordinator: # Coordinator perspective if not share.audience: return "🎯 Defining Audience" - elif not share.knowledge_organized: - return "📋 Organizing Knowledge" + # elif not share.knowledge_organized: + # return "📋 Organizing Knowledge" elif not share.brief: return "📝 Creating Brief" elif share.is_intended_to_accomplish_outcomes and not share.learning_objectives: @@ -63,9 +60,7 @@ def get_stage_label(share: Share, for_coordinator: bool = True) -> str: return "🚀 Ready for Transfer" else: # Team perspective - if share.archived: - return "📦 Archived" - elif not TransferManager.is_ready_for_transfer(share): + if not TransferManager.is_ready_for_transfer(share): return "⏳ Knowledge Being Organized" elif not share.is_intended_to_accomplish_outcomes: return "🔍 Exploring Knowledge" diff --git a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/debug.py b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/debug.py index 0f85208fb..cea32efa5 100644 --- a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/debug.py +++ b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/debug.py @@ -7,8 +7,10 @@ ConversationContext, ) +from assistant.data import ConversationRole, Share from assistant.domain import KnowledgeDigestManager, ShareManager, TransferManager from assistant.domain.conversation_preferences_manager import ConversationPreferencesManager +from assistant.domain.thoughts_manager import ThoughtsManager class DebugInspector: @@ -32,27 +34,54 @@ async def get(self, context: ConversationContext) -> AssistantConversationInspec """Get debug information for display.""" # Get share information - share_id = await ShareManager.get_share_id(context) - if not share_id: - return AssistantConversationInspectorStateDataModel( - data={"content": "No active knowledge package. Start a conversation to create one."} - ) - - markdown = await self._format_debug_info(share_id, context) + share = await ShareManager.get_share(context) + markdown = await self._format_debug_info(context, share) return AssistantConversationInspectorStateDataModel(data={"content": markdown}) - async def _format_debug_info(self, share_id: str, context: ConversationContext) -> str: + async def _format_debug_info(self, context: ConversationContext, share: Share) -> str: """Format debug information including knowledge digest.""" lines: list[str] = [] lines.append("## Debug Information") + lines.append("_This panel shows internal information maintained by the assistant. This data is automatically") + lines.append("generated and updated by the assistant and is not directly editable by users._") lines.append("") - lines.append("This panel shows internal information maintained by the assistant. This data is automatically") - lines.append("generated and updated by the assistant and is not directly editable by users.") + + # Share metadata + share = await ShareManager.get_share(context) + lines.append("## Share Metadata") + lines.append(f"- **Share ID:** `{share.share_id}`") + lines.append(f"- **Created:** {share.created_at.strftime('%Y-%m-%d %H:%M:%S')}") + lines.append(f"- **Last Updated:** {share.updated_at.strftime('%Y-%m-%d %H:%M:%S')}") + lines.append(f"- **Team Conversations:** {len(share.team_conversations) if share.team_conversations else 0}") + lines.append(f"- **Learning Objectives:** {len(share.learning_objectives) if share.learning_objectives else 0}") + # lines.append(f"- **Knowledge Organized:** {share.knowledge_organized}") + lines.append(f"- **Ready for Transfer:** {TransferManager.is_ready_for_transfer(share)}") + lines.append(f"- **Actively Sharing:** {TransferManager.is_actively_sharing(share)}") + if share.coordinator_conversation_id: + lines.append(f"- **Conversation ID:** `{share.coordinator_conversation_id}`") lines.append("") - # Get the knowledge digest + # Conversation metadata + lines.append("## Conversation Metadata") + role_type = await ShareManager.get_conversation_role(context) + role = "Coordinator" if role_type == ConversationRole.COORDINATOR else "Team Member" + lines.append(f"- **Role:** {role}") + style = await ConversationPreferencesManager.get_preferred_communication_style(context) + lines.append(f"- **Preferred Communication Style:** {style}") + + # Assistant thoughts + lines.append("## Assistant Thoughts") + thoughts = await ThoughtsManager.get_assistant_thoughts(context) + if thoughts: + for thought in thoughts: + lines.append(f"- {thought}") + lines.append("") + else: + lines.append("_No assistant thoughts recorded yet._") + + # knowledge digest try: digest = await KnowledgeDigestManager.get_knowledge_digest(context) @@ -81,34 +110,4 @@ async def _format_debug_info(self, share_id: str, context: ConversationContext) lines.append(f"**Error retrieving knowledge digest:** {e!s}") lines.append("") - # Add share metadata for debugging - try: - share = await ShareManager.get_share(context) - if share: - lines.append("## Share Metadata") - lines.append("") - lines.append(f"- **Share ID:** `{share_id}`") - lines.append(f"- **Created:** {share.created_at.strftime('%Y-%m-%d %H:%M:%S')}") - lines.append(f"- **Last Updated:** {share.updated_at.strftime('%Y-%m-%d %H:%M:%S')}") - lines.append( - f"- **Team Conversations:** {len(share.team_conversations) if share.team_conversations else 0}" - ) - lines.append( - f"- **Learning Objectives:** {len(share.learning_objectives) if share.learning_objectives else 0}" - ) - lines.append(f"- **Knowledge Organized:** {share.knowledge_organized}") - lines.append(f"- **Ready for Transfer:** {TransferManager.is_ready_for_transfer(share)}") - lines.append(f"- **Actively Sharing:** {TransferManager.is_actively_sharing(share)}") - if share.coordinator_conversation_id: - lines.append(f"- **Conversation ID:** `{share.coordinator_conversation_id}`") - lines.append("") - style = await ConversationPreferencesManager.get_preferred_communication_style(context) - lines.append(f"- **Preferred Communication Style:** {style}") - - except Exception as e: - lines.append("## Share Metadata") - lines.append("") - lines.append(f"**Error retrieving share metadata:** {e!s}") - lines.append("") - return "\n".join(lines) diff --git a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/learning.py b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/learning.py index 8782cbbc1..a2c6ce64f 100644 --- a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/learning.py +++ b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/learning.py @@ -58,24 +58,19 @@ async def _format_coordinator_objectives(self, share: Any, context: Conversation if not share or not share.learning_objectives: lines.append("## Learning Objectives") - lines.append("") lines.append( "_No learning objectives have been set up yet. When shared, the assistant will help your recipients explore the knowledge in a more open way, helping them discover the important aspects of the knowledge without specific objectives or outcomes. If you would like to have a more formal process, ask your assistant to help you create learning objectives and outcomes._" # noqa: E501 ) - lines.append("") return "\n".join(lines) - lines.append("## Team Progress") - lines.append("") - # Overall progress summary total_outcomes = sum(len(obj.learning_outcomes) for obj in share.learning_objectives if obj.learning_outcomes) if total_outcomes > 0 and share.team_conversations: + lines.append("## Team Progress") for conv_id, team_conv in share.team_conversations.items(): achieved, total = LearningObjectivesManager.get_completion_for_conversation(share, conv_id) progress_pct = int(achieved / total * 100) if total > 0 else 0 lines.append(f"- **{team_conv.redeemer_name}**: {achieved}/{total} outcomes ({progress_pct}%)") - lines.append("") # Detailed objectives lines.append("## Learning Objectives") @@ -84,7 +79,6 @@ async def _format_coordinator_objectives(self, share: Any, context: Conversation lines.append(objective.description) if objective.learning_outcomes: - lines.append("") lines.append("#### Learning Outcomes") for criterion in objective.learning_outcomes: # Check if any team conversation has achieved this outcome @@ -107,7 +101,6 @@ async def _format_coordinator_objectives(self, share: Any, context: Conversation achievement_info = f" ({achieved_count}/{total_team_count})" lines.append(f"- {status_emoji} {criterion.description}{achievement_info}") - lines.append("") return "\n".join(lines) @@ -118,17 +111,14 @@ async def _format_team_objectives(self, share: Any, context: ConversationContext if not share or not share.learning_objectives: lines.append("## Learning Objectives") - lines.append("") lines.append( "_The coordinator hasn't set up specific learning objectives for this shared knowledge. " "Enjoy exploring at your own pace! " "The assistant will guide you towards important information as you go._" ) - lines.append("") return "\n".join(lines) lines.append("## Learning Objectives") - lines.append("") # Show my personal progress conversation_id = str(context.id) @@ -137,7 +127,6 @@ async def _format_team_objectives(self, share: Any, context: ConversationContext ) progress_pct = int(achieved_outcomes / total_outcomes * 100) if total_outcomes > 0 else 0 lines.append(f"**My Progress:** {achieved_outcomes}/{total_outcomes} outcomes achieved ({progress_pct}%)") - lines.append("") for objective in share.learning_objectives: lines.append(f"### 🎯 {objective.name}") @@ -165,6 +154,5 @@ async def _format_team_objectives(self, share: Any, context: ConversationContext break lines.append(f"- {status_emoji} {criterion.description}{completion_info}") - lines.append("") return "\n".join(lines) diff --git a/assistants/knowledge-transfer-assistant/assistant/utils.py b/assistants/knowledge-transfer-assistant/assistant/utils.py index 37b1bbb09..008b88020 100644 --- a/assistants/knowledge-transfer-assistant/assistant/utils.py +++ b/assistants/knowledge-transfer-assistant/assistant/utils.py @@ -10,11 +10,12 @@ from semantic_workbench_assistant.assistant_app import ConversationContext from assistant.errors import NoUserException +from assistant.string_utils import render DEFAULT_TEMPLATE_ID = "default" -def load_text_include(filename) -> str: +def load_text_include(filename: str, replacements: dict[str, str] = {}) -> str: """ Helper for loading an include from a text file. @@ -24,14 +25,10 @@ def load_text_include(filename) -> str: Returns: The content of the text file """ - # Get directory relative to this module directory = pathlib.Path(__file__).parent - - # Get the file path for the prompt file file_path = directory / "text_includes" / filename - - # Read the prompt from the file - return file_path.read_text() + text = file_path.read_text() + return render(text, replacements) async def get_current_user( diff --git a/assistants/knowledge-transfer-assistant/tests/test_share_tools.py b/assistants/knowledge-transfer-assistant/tests/test_share_tools.py index 6c79a0682..0443621d6 100644 --- a/assistants/knowledge-transfer-assistant/tests/test_share_tools.py +++ b/assistants/knowledge-transfer-assistant/tests/test_share_tools.py @@ -7,7 +7,7 @@ import openai_client import pytest -from assistant.agentic.analysis import detect_information_request_needs +from assistant.agentic.detect_information_requests import detect_information_request_needs from assistant.data import ConversationRole from assistant.tools import ShareTools from semantic_workbench_assistant.assistant_app import ConversationContext diff --git a/libraries/python/openai-client/openai_client/completion.py b/libraries/python/openai-client/openai_client/completion.py index c4f37dc69..9ea8a920f 100644 --- a/libraries/python/openai-client/openai_client/completion.py +++ b/libraries/python/openai-client/openai_client/completion.py @@ -25,7 +25,9 @@ JSON_OBJECT_RESPONSE_FORMAT: ResponseFormat = {"type": "json_object"} -def assistant_message_from_completion(completion: ParsedChatCompletion[None]) -> ChatCompletionAssistantMessageParam: +def assistant_message_from_completion( + completion: ParsedChatCompletion, +) -> ChatCompletionAssistantMessageParam: completion_message: ParsedChatCompletionMessage = completion.choices[0].message assistant_message = ChatCompletionAssistantMessageParam(role="assistant") if completion_message.tool_calls: @@ -36,17 +38,26 @@ def assistant_message_from_completion(completion: ParsedChatCompletion[None]) -> return assistant_message -def message_from_completion(completion: ParsedChatCompletion) -> ParsedChatCompletionMessage | None: - return completion.choices[0].message if completion and completion.choices else None +def message_from_completion( + completion: ParsedChatCompletion, +) -> ParsedChatCompletionMessage | None: + if not completion or not completion.choices: + return None + return completion.choices[0].message def message_content_from_completion(completion: ParsedChatCompletion | None) -> str: - if not completion or not completion.choices or not completion.choices[0].message: + if completion is None or not completion.choices: return "" - return completion.choices[0].message.content or "" + content = message_from_completion(completion) + if content and content.content: + return content.content + return "" -def message_content_dict_from_completion(completion: ParsedChatCompletion) -> dict[str, Any] | None: +def message_content_dict_from_completion( + completion: ParsedChatCompletion, +) -> dict[str, Any] | None: message = message_from_completion(completion) if message: if message.parsed: @@ -109,7 +120,10 @@ async def completion_structured( raise NoParsedMessageError() if response.choices[0].finish_reason != "stop": - logger.warning("Unexpected finish reason, expected stop; reason: %s", response.choices[0].finish_reason) + logger.warning( + "Unexpected finish reason, expected stop; reason: %s", + response.choices[0].finish_reason, + ) metadata = { "request": { diff --git a/libraries/python/openai-client/openai_client/tools.py b/libraries/python/openai-client/openai_client/tools.py index 6c1229427..451753be1 100644 --- a/libraries/python/openai-client/openai_client/tools.py +++ b/libraries/python/openai-client/openai_client/tools.py @@ -47,7 +47,9 @@ def to_string(value: Any) -> str: return str(value) -def function_list_to_tool_choice(functions: list[str] | None) -> Iterable[ChatCompletionToolParam] | None: +def function_list_to_tool_choice( + functions: list[str] | None, +) -> Iterable[ChatCompletionToolParam] | None: """ Convert a list of function names to a list of ChatCompletionToolParam objects. This is used in the Chat Completions API if you want to tell the @@ -56,10 +58,12 @@ def function_list_to_tool_choice(functions: list[str] | None) -> Iterable[ChatCo if not functions: return None return [ - ChatCompletionToolParam(**{ - "type": "function", - "function": {"name": name}, - }) + ChatCompletionToolParam( + **{ + "type": "function", + "function": {"name": name}, + } + ) for name in functions ] or None @@ -85,10 +89,14 @@ class ToolFunction: generate a usage string (for help messages) """ - def __init__(self, fn: Callable, name: str | None = None, description: str | None = None) -> None: + def __init__( + self, fn: Callable, name: str | None = None, description: str | None = None + ) -> None: self.fn = fn self.name = name or fn.__name__ - self.description = description or inspect.getdoc(fn) or self.name.replace("_", " ").title() + self.description = ( + description or inspect.getdoc(fn) or self.name.replace("_", " ").title() + ) def parameters(self, exclude: list[str] = []) -> list[Parameter]: """ @@ -230,7 +238,9 @@ class ToolFunctions: tool-call completion against the API. """ - def __init__(self, functions: list[ToolFunction] | None = None, with_help: bool = False) -> None: + def __init__( + self, functions: list[ToolFunction] | None = None, with_help: bool = False + ) -> None: # Set up function map. self.function_map: dict[str, ToolFunction] = {} if functions: @@ -251,7 +261,12 @@ def help(self) -> str: usage.sort() return "```text\nCommands:\n" + "\n".join(usage) + "\n```" - def add_function(self, function: Callable, name: str | None = None, description: str | None = None) -> None: + def add_function( + self, + function: Callable, + name: str | None = None, + description: str | None = None, + ) -> None: """Register a function with the tool functions.""" if not name: name = function.__name__ @@ -267,7 +282,11 @@ def get_functions(self) -> list[ToolFunction]: return [function for function in self.function_map.values()] async def execute_function( - self, name: str, args: tuple = (), kwargs: dict[str, Any] = {}, string_response: bool = False + self, + name: str, + args: tuple = (), + kwargs: dict[str, Any] = {}, + string_response: bool = False, ) -> Any: """ Run a function from the ToolFunctions list by name. If string_response @@ -280,20 +299,26 @@ async def execute_function( if string_response: return to_string(response) - async def execute_function_string(self, function_string: str, string_response: bool = False) -> Any: + async def execute_function_string( + self, function_string: str, string_response: bool = False + ) -> Any: """Parse a function string and execute the function.""" try: function, args, kwargs = self.parse_function_string(function_string) except ValueError as e: raise ValueError(f"{e} Type: `/help` for more information.") if not function: - raise ValueError("Function not found in registry. Type: `/help` for more information.") + raise ValueError( + "Function not found in registry. Type: `/help` for more information." + ) result = await function.execute(*args, **kwargs) if string_response: return to_string(result) @staticmethod - def parse_fn_string(function_string: str) -> tuple[str | None, list[Any], dict[str, Any]]: + def parse_fn_string( + function_string: str, + ) -> tuple[str | None, list[Any], dict[str, Any]]: """ Parse a string representing a function call into its name, positional arguments, and keyword arguments. @@ -313,7 +338,11 @@ def parse_fn_string(function_string: str) -> tuple[str | None, list[Any], dict[s raise ValueError("Invalid function call. Please check your syntax.") # Ensure the tree contains exactly one expression (the function call) - if not (isinstance(tree, ast.Module) and len(tree.body) == 1 and isinstance(tree.body[0], ast.Expr)): + if not ( + isinstance(tree, ast.Module) + and len(tree.body) == 1 + and isinstance(tree.body[0], ast.Expr) + ): raise ValueError("Expected a single function call.") # The function call is stored as a `Call` node within the expression @@ -336,9 +365,14 @@ def eval_node(node): elif isinstance(node, ast.Tuple): return tuple(eval_node(elem) for elem in node.elts) elif isinstance(node, ast.Dict): - return {eval_node(key): eval_node(value) for key, value in zip(node.keys, node.values)} + return { + eval_node(key): eval_node(value) + for key, value in zip(node.keys, node.values) + } elif isinstance(node, ast.Name): - return node.id # This can return variable names, but we assume they're constants + return ( + node.id + ) # This can return variable names, but we assume they're constants elif isinstance(node, ast.BinOp): # Handling arithmetic expressions return eval(compile(ast.Expression(node), filename="", mode="eval")) elif isinstance(node, ast.Call): @@ -356,7 +390,9 @@ def eval_node(node): return function_name, args, kwargs - def parse_function_string(self, function_string: str) -> tuple[ToolFunction | None, list[Any], dict[str, Any]]: + def parse_function_string( + self, function_string: str + ) -> tuple[ToolFunction | None, list[Any], dict[str, Any]]: """Parse a function call string into a function and its arguments.""" function_name, args, kwargs = ToolFunctions.parse_fn_string(function_string) @@ -377,15 +413,19 @@ def chat_completion_tools(self) -> list[ChatCompletionToolParam] | NotGiven: calls. """ tools = [ - ChatCompletionToolParam(**{ - "type": "function", - "function": func.schema(), - }) + ChatCompletionToolParam( + **{ + "type": "function", + "function": func.schema(), + } + ) for func in self.function_map.values() ] return tools or NOT_GIVEN - async def execute_tool_call(self, tool_call: ParsedFunctionToolCall) -> ChatCompletionMessageParam | None: + async def execute_tool_call( + self, tool_call: ParsedFunctionToolCall + ) -> ChatCompletionMessageParam | None: """ Execute a function as requested by a ParsedFunctionToolCall (generated by the Chat Completions API) and return the response as a @@ -396,18 +436,25 @@ async def execute_tool_call(self, tool_call: ParsedFunctionToolCall) -> ChatComp if self.has_function(function.name): logger.debug( "Function call.", - extra=add_serializable_data({"name": function.name, "arguments": function.arguments}), + extra=add_serializable_data( + {"name": function.name, "arguments": function.arguments} + ), ) value: Any = None try: kwargs: dict[str, Any] = json.loads(function.arguments) - value = await self.execute_function(function.name, (), kwargs, string_response=True) + value = await self.execute_function( + function.name, (), kwargs, string_response=True + ) except Exception as e: logger.error("Error.", extra=add_serializable_data({"error": e})) value = f"Error: {e}" finally: logger.debug( - "Function response.", extra=add_serializable_data({"tool_call_id": tool_call.id, "content": value}) + "Function response.", + extra=add_serializable_data( + {"tool_call_id": tool_call.id, "content": value} + ), ) return { "role": "tool", @@ -466,9 +513,13 @@ async def complete_with_tool_calls( logger.debug( f"Completion call ({round_description}).", - extra=add_serializable_data(make_completion_args_serializable(current_args)), + extra=add_serializable_data( + make_completion_args_serializable(current_args) + ), + ) + metadata[f"completion_request ({round_description})"] = ( + make_completion_args_serializable(current_args) ) - metadata[f"completion_request ({round_description})"] = make_completion_args_serializable(current_args) # Make the completion call try: @@ -478,15 +529,23 @@ async def complete_with_tool_calls( validate_completion(current_completion) logger.debug( f"Completion response ({round_description}).", - extra=add_serializable_data({"completion": current_completion.model_dump()}), + extra=add_serializable_data( + {"completion": current_completion.model_dump()} + ), + ) + metadata[f"completion_response ({round_description})"] = ( + current_completion.model_dump() ) - metadata[f"completion_response ({round_description})"] = current_completion.model_dump() except Exception as e: completion_error = CompletionError(e) - metadata[f"completion_error ({round_description})"] = completion_error.message + metadata[f"completion_error ({round_description})"] = ( + completion_error.message + ) logger.error( completion_error.message, - extra=add_serializable_data({"completion_error": completion_error.body, "metadata": metadata}), + extra=add_serializable_data( + {"completion_error": completion_error.body, "metadata": metadata} + ), ) raise completion_error from e @@ -504,7 +563,9 @@ async def complete_with_tool_calls( # Call all tool functions and generate return messages round_tool_messages: list[ChatCompletionMessageParam] = [] for tool_call in completion_message.tool_calls: - function_call_result_message = await tool_functions.execute_tool_call(tool_call) + function_call_result_message = await tool_functions.execute_tool_call( + tool_call + ) if function_call_result_message: round_tool_messages.append(function_call_result_message) all_new_messages.append(function_call_result_message) From b21024c0c5def824f75d63ab43b42d6302c44de2 Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Mon, 11 Aug 2025 09:52:52 -0700 Subject: [PATCH 17/21] Adds agents. Changes Thoughts to Tasks. Puts notifications closer to edge. Debug fixes. --- .../.vscode/settings.json | 1 + .../assistant/agentic/__init__.py | 12 +- .../assistant/agentic/create_invitation.py | 91 ++++++++ .../agentic/detect_audience_and_takeaways.py | 37 ++-- .../agentic/detect_coordinator_actions.py | 109 ++++++++++ ...py => detect_information_request_needs.py} | 46 +--- .../agentic/detect_knowledge_package_gaps.py | 114 ++++++++++ .../assistant/agentic/focus.py | 98 +++++++++ .../assistant/{ => agentic}/respond.py | 54 +---- .../assistant/agentic/team_welcome.py | 2 +- .../assistant/agentic/update_digest.py | 125 +++++++---- .../assistant/assistant.py | 201 +++++++++--------- .../assistant/config.py | 34 +-- .../assistant/data.py | 3 +- .../assistant/domain/__init__.py | 4 +- .../assistant/domain/audience_manager.py | 9 +- .../domain/knowledge_digest_manager.py | 4 +- .../domain/learning_objectives_manager.py | 7 +- .../assistant/domain/tasks_manager.py | 45 ++++ .../assistant/domain/thoughts_manager.py | 42 ---- .../assistant/notifications.py | 5 +- .../assistant/prompt_utils.py | 28 +-- .../assistant/storage.py | 24 ++- .../background_thought_audience.md | 18 -- .../assistant/text_includes/card_content.md | 10 +- ...s.txt => coordinator_instructions copy.md} | 48 +++-- .../text_includes/coordinator_instructions.md | 61 ++++++ .../text_includes/coordinator_role.txt | 15 -- .../text_includes/create_invitation.md | 10 + .../text_includes/detect_audience.md | 17 ++ .../detect_coordinator_actions.md | 9 + ...xt => detect_information_request_needs.md} | 0 .../detect_knowledge_package_gaps.md | 14 ++ .../assistant/text_includes/focus.md | 41 ++++ .../text_includes/team_instructions.txt | 14 ++ .../assistant/text_includes/team_role.txt | 9 - .../text_includes/update_knowledge_brief.md | 1 + ..._update.txt => update_knowledge_digest.md} | 12 +- .../assistant/tools/__init__.py | 75 ++++--- .../assistant/tools/assistant_thoughts.py | 32 --- .../assistant/tools/share_setup.py | 30 ++- .../assistant/tools/tasks.py | 28 +++ .../assistant/ui_tabs/debug.py | 14 +- .../tests/test_share_tools.py | 2 +- 44 files changed, 1039 insertions(+), 516 deletions(-) create mode 100644 assistants/knowledge-transfer-assistant/assistant/agentic/create_invitation.py create mode 100644 assistants/knowledge-transfer-assistant/assistant/agentic/detect_coordinator_actions.py rename assistants/knowledge-transfer-assistant/assistant/agentic/{detect_information_requests.py => detect_information_request_needs.py} (72%) create mode 100644 assistants/knowledge-transfer-assistant/assistant/agentic/detect_knowledge_package_gaps.py create mode 100644 assistants/knowledge-transfer-assistant/assistant/agentic/focus.py rename assistants/knowledge-transfer-assistant/assistant/{ => agentic}/respond.py (86%) create mode 100644 assistants/knowledge-transfer-assistant/assistant/domain/tasks_manager.py delete mode 100644 assistants/knowledge-transfer-assistant/assistant/domain/thoughts_manager.py delete mode 100644 assistants/knowledge-transfer-assistant/assistant/text_includes/background_thought_audience.md rename assistants/knowledge-transfer-assistant/assistant/text_includes/{coordinator_instructions.txt => coordinator_instructions copy.md} (69%) create mode 100644 assistants/knowledge-transfer-assistant/assistant/text_includes/coordinator_instructions.md delete mode 100644 assistants/knowledge-transfer-assistant/assistant/text_includes/coordinator_role.txt create mode 100644 assistants/knowledge-transfer-assistant/assistant/text_includes/create_invitation.md create mode 100644 assistants/knowledge-transfer-assistant/assistant/text_includes/detect_audience.md create mode 100644 assistants/knowledge-transfer-assistant/assistant/text_includes/detect_coordinator_actions.md rename assistants/knowledge-transfer-assistant/assistant/text_includes/{share_information_request_detection.txt => detect_information_request_needs.md} (100%) create mode 100644 assistants/knowledge-transfer-assistant/assistant/text_includes/detect_knowledge_package_gaps.md create mode 100644 assistants/knowledge-transfer-assistant/assistant/text_includes/focus.md delete mode 100644 assistants/knowledge-transfer-assistant/assistant/text_includes/team_role.txt create mode 100644 assistants/knowledge-transfer-assistant/assistant/text_includes/update_knowledge_brief.md rename assistants/knowledge-transfer-assistant/assistant/text_includes/{knowledge_digest_update.txt => update_knowledge_digest.md} (82%) delete mode 100644 assistants/knowledge-transfer-assistant/assistant/tools/assistant_thoughts.py create mode 100644 assistants/knowledge-transfer-assistant/assistant/tools/tasks.py diff --git a/assistants/knowledge-transfer-assistant/.vscode/settings.json b/assistants/knowledge-transfer-assistant/.vscode/settings.json index 384f29338..c9cf50d36 100644 --- a/assistants/knowledge-transfer-assistant/.vscode/settings.json +++ b/assistants/knowledge-transfer-assistant/.vscode/settings.json @@ -70,6 +70,7 @@ "Excalidraw", "fastapi", "GIPHY", + "insightfully", "jsonschema", "Langchain", "levelname", diff --git a/assistants/knowledge-transfer-assistant/assistant/agentic/__init__.py b/assistants/knowledge-transfer-assistant/assistant/agentic/__init__.py index 3a45c0c77..9ebe32545 100644 --- a/assistants/knowledge-transfer-assistant/assistant/agentic/__init__.py +++ b/assistants/knowledge-transfer-assistant/assistant/agentic/__init__.py @@ -1,13 +1,23 @@ from types import SimpleNamespace from .coordinator_next_action import get_coordinator_next_action_suggestion +from .create_invitation import create_invitation from .detect_audience_and_takeaways import detect_audience_and_takeaways +from .detect_coordinator_actions import detect_coordinator_actions +from .detect_information_request_needs import detect_information_request_needs +from .detect_knowledge_package_gaps import detect_knowledge_package_gaps +from .focus import focus from .team_welcome import generate_team_welcome_message from .update_digest import update_digest agentic = SimpleNamespace( - get_coordinator_next_action_suggestion=get_coordinator_next_action_suggestion, + create_invitation=create_invitation, detect_audience_and_takeaways=detect_audience_and_takeaways, + detect_coordinator_actions=detect_coordinator_actions, + detect_information_request_needs=detect_information_request_needs, + detect_knowledge_package_gaps=detect_knowledge_package_gaps, + focus=focus, generate_team_welcome_message=generate_team_welcome_message, + get_coordinator_next_action_suggestion=get_coordinator_next_action_suggestion, update_digest=update_digest, ) diff --git a/assistants/knowledge-transfer-assistant/assistant/agentic/create_invitation.py b/assistants/knowledge-transfer-assistant/assistant/agentic/create_invitation.py new file mode 100644 index 000000000..ea6634352 --- /dev/null +++ b/assistants/knowledge-transfer-assistant/assistant/agentic/create_invitation.py @@ -0,0 +1,91 @@ +from typing import Any + +import openai_client +from semantic_workbench_assistant.assistant_app import ConversationContext + +from assistant.config import assistant_config +from assistant.domain.share_manager import ShareManager +from assistant.logging import convert_to_serializable, logger +from assistant.notifications import Notifications +from assistant.prompt_utils import ( + ContextSection, + ContextStrategy, + Instructions, + Prompt, + add_context_to_prompt, +) +from assistant.utils import load_text_include + + +async def create_invitation(context: ConversationContext) -> str: + debug: dict[str, Any] = { + "context": convert_to_serializable(context.to_dict()), + } + + config = await assistant_config.get(context.assistant) + + # Set up prompt instructions. + instruction_text = load_text_include("create_invitation.md") + instructions = Instructions(instruction_text) + prompt = Prompt( + instructions=instructions, + context_strategy=ContextStrategy.MULTI, + ) + + # Add prompt context. + role = await ShareManager.get_conversation_role(context) + await add_context_to_prompt( + prompt, + context=context, + role=role, + model=config.request_config.openai_model, + token_limit=config.request_config.max_tokens, + attachments_config=config.attachments_config, + attachments_in_system_message=True, + include=[ + ContextSection.KNOWLEDGE_INFO, + ContextSection.KNOWLEDGE_BRIEF, + # ContextSection.TASKS, + ContextSection.TARGET_AUDIENCE, + # ContextSection.LEARNING_OBJECTIVES, + ContextSection.KNOWLEDGE_DIGEST, + # ContextSection.INFORMATION_REQUESTS, + # ContextSection.SUGGESTED_NEXT_ACTIONS, + ContextSection.COORDINATOR_CONVERSATION, + ContextSection.ATTACHMENTS, + ], + ) + + # Chat completion + async with openai_client.create_client(config.service_config) as client: + try: + completion_args = { + "messages": prompt.messages(), + "model": config.request_config.openai_model, + "max_tokens": 500, + "temperature": 0.8, + } + debug["completion_args"] = openai_client.serializable(completion_args) + + # LLM call + response = await client.chat.completions.create( + **completion_args, + ) + openai_client.validate_completion(response) + debug["completion_response"] = openai_client.serializable(response.model_dump()) + + # Response + if response and response.choices and response.choices[0].message.content: + output: str = response.choices[0].message.content + if output: + await Notifications.notify(context, f"Generated invitation.\n\n{output}", debug_data=debug) + return output + else: + logger.warning("Empty response from LLM while generating invitation.") + + except Exception as e: + logger.exception(f"Failed to make OpenIA call: {e}") + debug["error"] = str(e) + + logger.debug(f"{__name__}: {debug}") + return "Failed to generate invitation." diff --git a/assistants/knowledge-transfer-assistant/assistant/agentic/detect_audience_and_takeaways.py b/assistants/knowledge-transfer-assistant/assistant/agentic/detect_audience_and_takeaways.py index 3dbfae481..1335321a3 100644 --- a/assistants/knowledge-transfer-assistant/assistant/agentic/detect_audience_and_takeaways.py +++ b/assistants/knowledge-transfer-assistant/assistant/agentic/detect_audience_and_takeaways.py @@ -1,10 +1,3 @@ -""" -Analysis and detection functions for the knowledge transfer assistant. - -This module contains functions for analyzing messages and knowledge transfer content -to detect specific conditions, such as information request needs. -""" - from typing import Any import openai_client @@ -15,7 +8,7 @@ from assistant.config import assistant_config from assistant.data import InspectorTab from assistant.domain.share_manager import ShareManager -from assistant.domain.thoughts_manager import ThoughtsManager +from assistant.domain.tasks_manager import TasksManager from assistant.logging import logger from assistant.notifications import Notifications from assistant.prompt_utils import ( @@ -32,16 +25,15 @@ async def detect_audience_and_takeaways( context: ConversationContext, attachments_extension: AttachmentsExtension ) -> None: debug: dict[str, Any] = { - "context": context, + "context": context.to_dict(), } config = await assistant_config.get(context.assistant) # Set up prompt instructions. - audience_instructions = load_text_include("background_thought_audience.md") - instructions = Instructions(audience_instructions) + instruction_text = load_text_include("detect_audience.md") + instructions = Instructions(instruction_text) prompt = Prompt( - role=config.prompt_config.coordinator_role, instructions=instructions, context_strategy=ContextStrategy.MULTI, ) @@ -67,16 +59,16 @@ async def detect_audience_and_takeaways( # ContextSection.SUGGESTED_NEXT_ACTIONS, ContextSection.COORDINATOR_CONVERSATION, ContextSection.ATTACHMENTS, - ContextSection.ASSISTANT_THOUGHTS, + ContextSection.TASKS, ], ) class Output(BaseModel): - """Output class to hold the generated thoughts.""" + """Output class to hold the generated tasks.""" - thoughts: list[ + tasks: list[ str - ] # Generated thoughts about the audience and takeaways. One thought per item. If there are no thoughts, this will be an empty list. #noqa: E501 + ] # Tasks related to the audience and takeaways. One task per item. If there are no tasks to be done due to this information, this will be an empty list. #noqa: E501 # Chat completion async with openai_client.create_client(config.service_config) as client: @@ -88,20 +80,25 @@ class Output(BaseModel): "temperature": 0.8, "response_format": Output, } - debug["completion_args"] = openai_client.make_completion_args_serializable(completion_args) + debug["completion_args"] = openai_client.serializable(completion_args) # LLM call response = await client.beta.chat.completions.parse( **completion_args, ) openai_client.validate_completion(response) - debug["completion_response"] = openai_client.add_serializable_data(response.model_dump()) + debug["completion_response"] = openai_client.serializable(response.model_dump()) # Response if response and response.choices and response.choices[0].message.parsed: output: Output = response.choices[0].message.parsed - if output.thoughts: - await ThoughtsManager.add_assistant_thoughts(context, output.thoughts) + if output.tasks: + await TasksManager.add_tasks(context, output.tasks) + await Notifications.notify( + context, + f"Added {len(output.tasks)} tasks related to the audience and takeaways.", + debug_data=debug, + ) await Notifications.notify_state_update( context, [InspectorTab.BRIEF], diff --git a/assistants/knowledge-transfer-assistant/assistant/agentic/detect_coordinator_actions.py b/assistants/knowledge-transfer-assistant/assistant/agentic/detect_coordinator_actions.py new file mode 100644 index 000000000..459bd1e52 --- /dev/null +++ b/assistants/knowledge-transfer-assistant/assistant/agentic/detect_coordinator_actions.py @@ -0,0 +1,109 @@ +from typing import Any + +import openai_client +from assistant_extensions.attachments import AttachmentsExtension +from pydantic import BaseModel +from semantic_workbench_assistant.assistant_app import ConversationContext + +from assistant.config import assistant_config +from assistant.data import InspectorTab +from assistant.domain.share_manager import ShareManager +from assistant.domain.tasks_manager import TasksManager +from assistant.logging import logger +from assistant.notifications import Notifications +from assistant.prompt_utils import ( + ContextSection, + ContextStrategy, + Instructions, + Prompt, + add_context_to_prompt, +) +from assistant.utils import load_text_include + + +async def detect_coordinator_actions(context: ConversationContext, attachments_extension: AttachmentsExtension) -> None: + debug: dict[str, Any] = { + "context": context.to_dict(), + } + + config = await assistant_config.get(context.assistant) + + # Set up prompt instructions. + instruction_text = load_text_include("detect_coordinator_actions.md") + instructions = Instructions(instruction_text) + prompt = Prompt( + instructions=instructions, + context_strategy=ContextStrategy.MULTI, + ) + + # Add prompt context. + role = await ShareManager.get_conversation_role(context) + await add_context_to_prompt( + prompt, + context=context, + role=role, + model=config.request_config.openai_model, + token_limit=config.request_config.max_tokens, + attachments_extension=attachments_extension, + attachments_config=config.attachments_config, + attachments_in_system_message=True, + include=[ + ContextSection.KNOWLEDGE_INFO, + ContextSection.KNOWLEDGE_BRIEF, + ContextSection.TASKS, + ContextSection.TARGET_AUDIENCE, + ContextSection.LEARNING_OBJECTIVES, + ContextSection.KNOWLEDGE_DIGEST, + ContextSection.INFORMATION_REQUESTS, + # ContextSection.SUGGESTED_NEXT_ACTIONS, + ContextSection.COORDINATOR_CONVERSATION, + ContextSection.ATTACHMENTS, + ], + ) + + class Output(BaseModel): + """Output class to hold the additional tasks.""" + + tasks: list[ + str + ] # Additional tasks that should be completed. If there are no additional tasks needed, this will be an empty list. #noqa: E501 + + # Chat completion + async with openai_client.create_client(config.service_config) as client: + try: + completion_args = { + "messages": prompt.messages(), + "model": config.request_config.openai_model, + "max_tokens": 500, + "temperature": 0.7, + "response_format": Output, + } + debug["completion_args"] = openai_client.serializable(completion_args) + + # LLM call + response = await client.beta.chat.completions.parse( + **completion_args, + ) + openai_client.validate_completion(response) + debug["completion_response"] = openai_client.serializable(response.model_dump()) + + # Response + if response and response.choices and response.choices[0].message.parsed: + output: Output = response.choices[0].message.parsed + if output.tasks: + await TasksManager.add_tasks(context, output.tasks) + await Notifications.notify( + context, f"Added {len(output.tasks)} tasks related to the process.", debug_data=debug + ) + await Notifications.notify_state_update( + context, + [InspectorTab.DEBUG], + ) + else: + logger.warning("Empty response from LLM for welcome message generation") + + except Exception as e: + logger.exception(f"Failed to make OpenIA call: {e}") + debug["error"] = str(e) + + logger.debug(f"{__name__}: {debug}") diff --git a/assistants/knowledge-transfer-assistant/assistant/agentic/detect_information_requests.py b/assistants/knowledge-transfer-assistant/assistant/agentic/detect_information_request_needs.py similarity index 72% rename from assistants/knowledge-transfer-assistant/assistant/agentic/detect_information_requests.py rename to assistants/knowledge-transfer-assistant/assistant/agentic/detect_information_request_needs.py index 429af3fdb..4b47bf7b9 100644 --- a/assistants/knowledge-transfer-assistant/assistant/agentic/detect_information_requests.py +++ b/assistants/knowledge-transfer-assistant/assistant/agentic/detect_information_request_needs.py @@ -13,7 +13,7 @@ from semantic_workbench_assistant.assistant_app import ConversationContext from assistant.config import assistant_config -from assistant.logging import logger +from assistant.logging import convert_to_serializable, logger async def detect_information_request_needs(context: ConversationContext, message: str) -> dict[str, Any]: @@ -30,69 +30,35 @@ async def detect_information_request_needs(context: ConversationContext, message """ debug: dict[str, Any] = { "message": message, - "context": context, + "context": convert_to_serializable(context.to_dict()), } - # Get config via assistant config config = await assistant_config.get(context.assistant) - # Check if we're in a test environment (Missing parts of context) - if not hasattr(context, "assistant") or context.assistant is None: - return { - "is_information_request": False, - "reason": "Unable to perform detection in test environment - missing context", - "confidence": 0.0, - "debug": debug, - } - - # Get the config - config = await assistant_config.get(context.assistant) - - # Verify service_config is available - if not config.service_config: - logger.warning("No service_config available for LLM-based detection") - return { - "is_information_request": False, - "reason": "LLM detection unavailable - missing service configuration", - "confidence": 0.0, - "debug": debug, - } - - # Get recent conversation history (up to 10 messages) + # Get chat history chat_history = [] try: - # Get recent messages to provide context messages_response = await context.get_messages(limit=10) if messages_response and messages_response.messages: - # Format messages for the LLM for msg in messages_response.messages: - # Format the sender name sender_name = "Team Member" if msg.sender.participant_id == context.assistant.id: sender_name = "Assistant" - - # Add to chat history role = "user" if sender_name == "Team Member" else "assistant" chat_history.append({"role": role, "content": f"{sender_name}: {msg.content}"}) - - # Reverse to get chronological order chat_history.reverse() except Exception as e: logger.warning(f"Could not retrieve chat history: {e}") - # Continue without history if we can't get it try: - # Create chat completion with history context async with openai_client.create_client(config.service_config) as client: - # Prepare messages array with system prompt and chat history messages: list[ChatCompletionMessageParam] = [ { "role": "system", - "content": config.prompt_config.share_information_request_detection, + "content": config.prompt_config.detect_information_request_needs, } ] - # Add chat history if available if chat_history: for history_msg in chat_history: messages.append({"role": history_msg["role"], "content": history_msg["content"]}) @@ -110,9 +76,8 @@ async def detect_information_request_needs(context: ConversationContext, message "max_tokens": 500, "temperature": 0.2, # Low temperature for more consistent analysis } - debug["completion_args"] = openai_client.make_completion_args_serializable(completion_args) + debug["completion_args"] = openai_client.serializable(completion_args) - # Make the API call response = await client.chat.completions.create( **completion_args, ) @@ -131,6 +96,7 @@ async def detect_information_request_needs(context: ConversationContext, message "is_information_request": False, "reason": "Failed to parse LLM response", "confidence": 0.0, + "debug": debug, } else: logger.warning("Empty response from LLM for information request detection") diff --git a/assistants/knowledge-transfer-assistant/assistant/agentic/detect_knowledge_package_gaps.py b/assistants/knowledge-transfer-assistant/assistant/agentic/detect_knowledge_package_gaps.py new file mode 100644 index 000000000..6fa8acf9f --- /dev/null +++ b/assistants/knowledge-transfer-assistant/assistant/agentic/detect_knowledge_package_gaps.py @@ -0,0 +1,114 @@ +from typing import Any + +import openai_client +from assistant_extensions.attachments import AttachmentsExtension +from pydantic import BaseModel +from semantic_workbench_assistant.assistant_app import ConversationContext + +from assistant.config import assistant_config +from assistant.data import InspectorTab +from assistant.domain.share_manager import ShareManager +from assistant.domain.tasks_manager import TasksManager +from assistant.logging import convert_to_serializable, logger +from assistant.notifications import Notifications +from assistant.prompt_utils import ( + ContextSection, + ContextStrategy, + Instructions, + Prompt, + add_context_to_prompt, +) +from assistant.utils import load_text_include + + +async def detect_knowledge_package_gaps( + context: ConversationContext, attachments_extension: AttachmentsExtension +) -> None: + debug: dict[str, Any] = { + "context": convert_to_serializable(context.to_dict()), + } + + config = await assistant_config.get(context.assistant) + + # Set up prompt instructions. + instruction_text = load_text_include("detect_knowledge_package_gaps.md") + instructions = Instructions(instruction_text) + prompt = Prompt( + instructions=instructions, + context_strategy=ContextStrategy.MULTI, + ) + + # Add prompt context. + role = await ShareManager.get_conversation_role(context) + await add_context_to_prompt( + prompt, + context=context, + role=role, + model=config.request_config.openai_model, + token_limit=config.request_config.max_tokens, + attachments_extension=attachments_extension, + attachments_config=config.attachments_config, + attachments_in_system_message=True, + include=[ + # ContextSection.KNOWLEDGE_INFO, + ContextSection.KNOWLEDGE_BRIEF, + ContextSection.TASKS, + ContextSection.TARGET_AUDIENCE, + # ContextSection.LEARNING_OBJECTIVES, + ContextSection.KNOWLEDGE_DIGEST, + ContextSection.INFORMATION_REQUESTS, + # ContextSection.SUGGESTED_NEXT_ACTIONS, + ContextSection.COORDINATOR_CONVERSATION, + ContextSection.ATTACHMENTS, + ], + ) + + class Output(BaseModel): + """Identified knowledge gaps.""" + + gaps: list[str] # Gaps in the knowledge package that need to be addressed. + + # Chat completion + async with openai_client.create_client(config.service_config) as client: + try: + completion_args = { + "messages": prompt.messages(), + "model": config.request_config.openai_model, + "max_tokens": 500, + "temperature": 0.8, + "response_format": Output, + } + debug["completion_args"] = openai_client.serializable(completion_args) + + # LLM call + response = await client.beta.chat.completions.parse( + **completion_args, + ) + openai_client.validate_completion(response) + debug["completion_response"] = openai_client.serializable(response.model_dump()) + + # Response + if response and response.choices and response.choices[0].message.parsed: + output: Output = response.choices[0].message.parsed + if output.gaps: + tasks = [f"Collect the following information: {gap.strip()}" for gap in output.gaps if gap.strip()] + await TasksManager.add_tasks(context, tasks) + await Notifications.notify( + context, f"Added {len(tasks)} tasks related to the knowledge content.", debug_data=debug + ) + await Notifications.notify_state_update( + context, + [InspectorTab.DEBUG], + ) + else: + await Notifications.notify( + context, "No knowledge gaps identified. All required information is present.", debug_data=debug + ) + else: + logger.warning("Empty response from LLM for welcome message generation") + + except Exception as e: + logger.exception(f"Failed to make OpenIA call: {e}") + debug["error"] = str(e) + + logger.debug(f"{__name__}: {debug}") diff --git a/assistants/knowledge-transfer-assistant/assistant/agentic/focus.py b/assistants/knowledge-transfer-assistant/assistant/agentic/focus.py new file mode 100644 index 000000000..1ee33776c --- /dev/null +++ b/assistants/knowledge-transfer-assistant/assistant/agentic/focus.py @@ -0,0 +1,98 @@ +from typing import Any + +import openai_client +from assistant_extensions.attachments import AttachmentsExtension +from pydantic import BaseModel +from semantic_workbench_assistant.assistant_app import ConversationContext + +from assistant.config import assistant_config +from assistant.data import InspectorTab +from assistant.domain.tasks_manager import TasksManager +from assistant.logging import logger +from assistant.notifications import Notifications +from assistant.prompt_utils import ( + ContextStrategy, + DataContext, + Instructions, + Prompt, +) +from assistant.utils import load_text_include + + +async def focus(context: ConversationContext, attachments_extension: AttachmentsExtension) -> None: + debug: dict[str, Any] = { + "context": context.to_dict(), + } + + config = await assistant_config.get(context.assistant) + + # Set up prompt instructions. + instruction_text = load_text_include("focus.md") + instructions = Instructions(instruction_text) + prompt = Prompt( + instructions=instructions, + context_strategy=ContextStrategy.MULTI, + ) + + tasks = await TasksManager.get_tasks(context) + if tasks: + tasks_data = "\n\n".join("- " + thought for thought in tasks) + prompt.contexts.append( + DataContext( + "Consulting Tasks", + tasks_data, + "The consultant's current task list for the knowledge transfer consulting project.", + ) + ) + else: + prompt.contexts.append( + DataContext( + "Consulting Tasks", + "[]", + "The consultant has no current tasks for the knowledge transfer consulting project.", + ) + ) + + class Output(BaseModel): + """Output class to hold the generated tasks.""" + + reasoning: str # Reasoning behind how you are focusing the task list. + focused_tasks: list[str] # Focused task list for the knowledge transfer consultant. + + # Chat completion + async with openai_client.create_client(config.service_config) as client: + try: + completion_args = { + "messages": prompt.messages(), + "model": config.request_config.openai_model, + "max_tokens": 500, + "temperature": 0.8, + "response_format": Output, + } + debug["completion_args"] = openai_client.serializable(completion_args) + + # LLM call + response = await client.beta.chat.completions.parse( + **completion_args, + ) + openai_client.validate_completion(response) + debug["completion_response"] = openai_client.serializable(response.model_dump()) + + # Response + if response and response.choices and response.choices[0].message.parsed: + output: Output = response.choices[0].message.parsed + if output.focused_tasks: + await TasksManager.set_task_list(context, output.focused_tasks) + await Notifications.notify(context, "Focused the task list.", debug_data=debug) + await Notifications.notify_state_update( + context, + [InspectorTab.DEBUG], + ) + else: + logger.warning("Empty response from LLM for welcome message generation") + + except Exception as e: + logger.exception(f"Failed to make OpenIA call: {e}") + debug["error"] = str(e) + + logger.debug(f"{__name__}: {debug}") diff --git a/assistants/knowledge-transfer-assistant/assistant/respond.py b/assistants/knowledge-transfer-assistant/assistant/agentic/respond.py similarity index 86% rename from assistants/knowledge-transfer-assistant/assistant/respond.py rename to assistants/knowledge-transfer-assistant/assistant/agentic/respond.py index bc02f2afb..00cabfe5b 100644 --- a/assistants/knowledge-transfer-assistant/assistant/respond.py +++ b/assistants/knowledge-transfer-assistant/assistant/agentic/respond.py @@ -26,16 +26,14 @@ ConversationContext, ) +from assistant.config import assistant_config +from assistant.data import ConversationRole from assistant.domain.conversation_preferences_manager import ( ConversationPreferencesManager, ) from assistant.domain.share_manager import ShareManager - -from .agentic.detect_information_requests import detect_information_request_needs -from .config import assistant_config -from .data import ConversationRole -from .logging import logger -from .prompt_utils import ( +from assistant.logging import logger +from assistant.prompt_utils import ( ContextSection, ContextStrategy, Instructions, @@ -43,8 +41,8 @@ TokenBudget, add_context_to_prompt, ) -from .tools import ShareTools -from .utils import load_text_include +from assistant.tools import ShareTools +from assistant.utils import load_text_include SILENCE_TOKEN = "{{SILENCE}}" @@ -64,15 +62,11 @@ class CoordinatorOutput(BaseModel): """ Attributes: response: The response from the assistant. - next_step_suggestion: Help for the user to understand what to do next. A great way to progressively reveal the knowledge transfer process. - """ # noqa: E501 + """ response: str = Field( description="The response from the assistant. The response should not duplicate information from the excerpt but may refer to it.", # noqa: E501 ) - # next_step_suggestion: str = Field( - # description="Help for the user to understand what to do next. A great way to progressively reveal the knowledge transfer process. The audience is the user, so this should be a suggestion for them to take action. Do NOT use this field to communicate what you, the assistant, are going to do next. Assume the user has not yet used this assistant before and make sure to explain concepts such as the knowledge brief and learning outcomes clearly the first time you mention them.", # noqa: E501 - # ) model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") @@ -125,10 +119,8 @@ async def respond_to_conversation( # Add role-specific instructions. if role == ConversationRole.COORDINATOR: - assistant_role = config.prompt_config.coordinator_role role_specific_instructions = config.prompt_config.coordinator_instructions else: - assistant_role = config.prompt_config.team_role role_specific_instructions = config.prompt_config.team_instructions instructions = Instructions(role_specific_instructions) @@ -166,7 +158,6 @@ async def respond_to_conversation( instructions.add_subsection(Instructions(communication_style, "Preferred Communication Style")) prompt = Prompt( - role=assistant_role, instructions=instructions, context_strategy=ContextStrategy.MULTI, ) @@ -186,7 +177,7 @@ async def respond_to_conversation( ContextSection.INFORMATION_REQUESTS, # ContextSection.SUGGESTED_NEXT_ACTIONS, ContextSection.ATTACHMENTS, - ContextSection.ASSISTANT_THOUGHTS, + ContextSection.TASKS, ] if role == ConversationRole.TEAM: sections.append(ContextSection.COORDINATOR_CONVERSATION) @@ -318,35 +309,6 @@ async def respond_to_conversation( f"({token_budget.used}). Try removing some attachments." ) - # For team role, analyze message for possible information request needs. - # Send a notification if we think it might be one. - if role is ConversationRole.TEAM: - detection_result = await detect_information_request_needs(context, new_message.content) - - if detection_result.get("is_information_request", False) and detection_result.get("confidence", 0) > 0.8: - suggested_title = detection_result.get("potential_title", "") - suggested_priority = detection_result.get("suggested_priority", "medium") - potential_description = detection_result.get("potential_description", "") - reason = detection_result.get("reason", "") - - suggestion = ( - f"**Potential _Information Request_ Detected**\n\n" - f"It appears that you might need information from the knowledge coordinator. {reason}\n\n" - f"Would you like me to create an information request?\n" - f"**Title:** {suggested_title}\n" - f"**Description:** {potential_description}\n" - f"**Priority:** {suggested_priority}\n\n" - ) - - await context.send_messages( - NewConversationMessage( - content=suggestion, - message_type=MessageType.notice, - metadata={"debug": detection_result}, - ) - ) - metadata["debug"]["detection_result"] = detection_result - ## ## MAKE THE LLM CALL ## diff --git a/assistants/knowledge-transfer-assistant/assistant/agentic/team_welcome.py b/assistants/knowledge-transfer-assistant/assistant/agentic/team_welcome.py index 1bab50d16..a4f098779 100644 --- a/assistants/knowledge-transfer-assistant/assistant/agentic/team_welcome.py +++ b/assistants/knowledge-transfer-assistant/assistant/agentic/team_welcome.py @@ -91,7 +91,7 @@ async def generate_team_welcome_message( "max_tokens": 500, "temperature": 0.7, # Low temperature for more consistent analysis } - debug["completion_args"] = openai_client.make_completion_args_serializable(completion_args) + debug["completion_args"] = openai_client.serializable(completion_args) # LLM call response = await client.chat.completions.create( diff --git a/assistants/knowledge-transfer-assistant/assistant/agentic/update_digest.py b/assistants/knowledge-transfer-assistant/assistant/agentic/update_digest.py index 7e398b4b9..5a0f54c23 100644 --- a/assistants/knowledge-transfer-assistant/assistant/agentic/update_digest.py +++ b/assistants/knowledge-transfer-assistant/assistant/agentic/update_digest.py @@ -1,65 +1,96 @@ import re +from typing import Any +from venv import logger import openai_client -from semantic_workbench_api_model.workbench_model import ParticipantRole +from assistant_extensions.attachments import AttachmentsExtension from semantic_workbench_assistant.assistant_app import ConversationContext from assistant.config import assistant_config -from assistant.data import KnowledgeDigest +from assistant.data import InspectorTab from assistant.domain import KnowledgeDigestManager -from assistant.utils import get_current_user_id +from assistant.domain.share_manager import ShareManager +from assistant.notifications import Notifications +from assistant.prompt_utils import ContextSection, ContextStrategy, Instructions, Prompt, add_context_to_prompt -async def update_digest( - context: ConversationContext, -) -> KnowledgeDigest: - """ - Automatically updates the knowledge digest by analyzing chat history. - """ - messages = await context.get_messages() - chat_history = messages.messages +async def update_digest(context: ConversationContext, attachments_extension: AttachmentsExtension) -> None: + debug: dict[str, Any] = { + "context": context.to_dict(), + } - current_user_id = await get_current_user_id(context) - if not current_user_id: - raise ValueError("Could not identify current user") + config = await assistant_config.get(context.assistant) - # Skip if no messages to analyze - if not chat_history: - raise ValueError("No chat history to analyze for knowledge digest update") + # Set up prompt instructions. + instruction_text = config.prompt_config.update_knowledge_digest + instructions = Instructions(instruction_text) + prompt = Prompt( + instructions=instructions, + context_strategy=ContextStrategy.MULTI, + ) - # Format the chat history for the prompt - chat_history_text = "" - for msg in chat_history: - sender_type = "User" if msg.sender and msg.sender.participant_role == ParticipantRole.user else "Assistant" - chat_history_text += f"{sender_type}: {msg.content}\n\n" + # Add prompt context. + role = await ShareManager.get_conversation_role(context) + await add_context_to_prompt( + prompt, + context=context, + role=role, + model=config.request_config.openai_model, + token_limit=config.request_config.max_tokens, + attachments_extension=attachments_extension, + attachments_config=config.attachments_config, + attachments_in_system_message=True, + include=[ + # ContextSection.KNOWLEDGE_INFO, + ContextSection.KNOWLEDGE_BRIEF, + # ContextSection.TASKS, + ContextSection.TARGET_AUDIENCE, + ContextSection.LEARNING_OBJECTIVES, + ContextSection.KNOWLEDGE_DIGEST, + # ContextSection.INFORMATION_REQUESTS, + # ContextSection.SUGGESTED_NEXT_ACTIONS, + ContextSection.COORDINATOR_CONVERSATION, + ContextSection.ATTACHMENTS, + ], + ) - # Construct the knowledge digest prompt with the chat history - config = await assistant_config.get(context.assistant) - digest_prompt = f""" - {config.prompt_config.knowledge_digest_update} + async with openai_client.create_client(config.service_config, api_version="2024-06-01") as client: + try: + completion_args = { + "messages": prompt.messages(), + "model": config.request_config.openai_model, + "max_tokens": config.coordinator_config.max_digest_tokens, + "temperature": 0.7, + } + debug["completion_args"] = openai_client.serializable(completion_args) + response = await client.chat.completions.create(**completion_args) + openai_client.validate_completion(response) + debug["completion_response"] = openai_client.serializable(response.model_dump()) - - {chat_history_text} - - """ + # Extract the knowledge digest content from the response. + content = response.choices[0].message.content or "" + match = re.search(r"(.*?)", content, re.DOTALL) + digest_content = match.group(1).strip() if match else content + if not digest_content: + logger.error("No content extracted from knowledge digest LLM analysis", extra={"debug": debug}) + debug["digest_content"] = digest_content - async with openai_client.create_client(config.service_config, api_version="2024-06-01") as client: - completion = await client.chat.completions.create( - model=config.request_config.openai_model, - messages=[{"role": "user", "content": digest_prompt}], - max_tokens=config.coordinator_config.max_digest_tokens, - ) + # Save the knowledge digest. + await KnowledgeDigestManager.update_knowledge_digest( + context=context, + content=digest_content, + is_auto_generated=True, + ) - content = completion.choices[0].message.content or "" - digest_content = "" - match = re.search(r"(.*?)", content, re.DOTALL) - digest_content = match.group(1).strip() if match else content.strip() + # Use this for debugging in the Semantic Workbench UI. + await Notifications.notify(context, "Updated knowledge digest.", debug_data=debug) + await Notifications.notify_state_update( + context, + [InspectorTab.DEBUG], + ) - if not digest_content: - raise ValueError("No content extracted from knowledge digest LLM analysis") + except Exception as e: + debug["error"] = str(e) + logger.exception(f"Failed to make OpenIA call: {e}", extra={"debug": debug}) - return await KnowledgeDigestManager.update_knowledge_digest( - context=context, - content=digest_content, - is_auto_generated=True, - ) + logger.debug(f"{__name__}: {debug}") diff --git a/assistants/knowledge-transfer-assistant/assistant/assistant.py b/assistants/knowledge-transfer-assistant/assistant/assistant.py index dba4e64ed..d834b4f09 100644 --- a/assistants/knowledge-transfer-assistant/assistant/assistant.py +++ b/assistants/knowledge-transfer-assistant/assistant/assistant.py @@ -27,23 +27,19 @@ ConversationContext, ) -from assistant.agentic.detect_audience_and_takeaways import ( - detect_audience_and_takeaways, -) - from .agentic import agentic +from .agentic.respond import ( + SILENCE_TOKEN, + CoordinatorOutput, + TeamOutput, + respond_to_conversation, +) from .config import assistant_config from .data import ConversationRole, InspectorTab, LogEntryType from .domain import ShareManager from .files import ShareFilesManager from .logging import logger from .notifications import Notifications -from .respond import ( - SILENCE_TOKEN, - CoordinatorOutput, - TeamOutput, - respond_to_conversation, -) from .ui_tabs import BriefInspector, DebugInspector, LearningInspector, SharingInspector from .utils import ( DEFAULT_TEMPLATE_ID, @@ -52,9 +48,7 @@ service_id = "knowledge-transfer-assistant.made-exploration" service_name = "Knowledge Transfer Assistant" -service_description = ( - "A mediator assistant that facilitates sharing knowledge between parties." -) +service_description = "A mediator assistant that facilitates sharing knowledge between parties." async def content_evaluator_factory( @@ -86,9 +80,7 @@ async def content_evaluator_factory( template_id=DEFAULT_TEMPLATE_ID, background_color="rgb(198, 177, 222)", icon=dashboard_card.image_to_url( - pathlib.Path(__file__).parent - / "assets" - / "icon-knowledge-transfer.svg", + pathlib.Path(__file__).parent / "assets" / "icon-knowledge-transfer.svg", "image/svg+xml", ), card_content=dashboard_card.CardContent( @@ -97,11 +89,9 @@ async def content_evaluator_factory( ), ), ), - **navigator.metadata_for_assistant_navigator( - { - "default": load_text_include("assistant_info.md"), - } - ), + **navigator.metadata_for_assistant_navigator({ + "default": load_text_include("assistant_info.md"), + }), }, ) @@ -141,9 +131,7 @@ async def on_conversation_created(context: ConversationContext) -> None: share_id = await ShareManager.create_share(context) # And it was good. So we then created a sharable conversation that we use as a template. - share_url = await ShareManager.create_shareable_team_conversation( - context=context, share_id=share_id - ) + share_url = await ShareManager.create_shareable_team_conversation(context=context, share_id=share_id) config = await assistant_config.get(context.assistant) welcome_message = config.coordinator_config.welcome_message.format( @@ -174,9 +162,7 @@ async def on_conversation_created(context: ConversationContext) -> None: if not share_id: logger.error("No share ID found for shareable team conversation.") return - await ShareManager.set_conversation_role( - context, share_id, ConversationRole.SHAREABLE_TEMPLATE - ) + await ShareManager.set_conversation_role(context, share_id, ConversationRole.SHAREABLE_TEMPLATE) return case ConversationRole.TEAM: @@ -192,16 +178,10 @@ async def on_conversation_created(context: ConversationContext) -> None: ) ) - await ShareManager.set_conversation_role( - context, share_id, ConversationRole.TEAM - ) - await ShareFilesManager.synchronize_files_to_team_conversation( - context=context, share_id=share_id - ) + await ShareManager.set_conversation_role(context, share_id, ConversationRole.TEAM) + await ShareFilesManager.synchronize_files_to_team_conversation(context=context, share_id=share_id) - welcome_message, debug = await agentic.generate_team_welcome_message( - context - ) + welcome_message, debug = await agentic.generate_team_welcome_message(context) await context.send_messages( NewConversationMessage( content=welcome_message, @@ -250,21 +230,15 @@ async def on_conversation_updated(context: ConversationContext) -> None: f"Updated conversation {shared_conversation_id} title from '{target_conversation.title}' to '{conversation.title}'" # noqa: E501 ) else: - logger.debug( - f"Conversation {shared_conversation_id} title already matches: '{conversation.title}'" - ) + 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}" - ) + 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}") -async def store_coordinator_message( - context: ConversationContext, message: ConversationMessage -) -> None: +async def store_coordinator_message(context: ConversationContext, message: ConversationMessage) -> None: async with context.set_status("jotting..."): try: sender_name = "Coordinator" @@ -280,8 +254,7 @@ async def store_coordinator_message( message_id=str(message.id), content=message.content, sender_name=sender_name, - is_assistant=message.sender.participant_role - == ParticipantRole.assistant, + is_assistant=message.sender.participant_role == ParticipantRole.assistant, timestamp=message.timestamp, ) except Exception as e: @@ -312,7 +285,69 @@ async def on_message_created( if message.message_type == MessageType.chat and is_user_message: async with context.set_status("pondering..."): - # Get generated message. + # Process user message with background assistants. + if role == ConversationRole.COORDINATOR: + # Update knowledge digest. + digest_task = asyncio.create_task(agentic.update_digest(context, attachments_extension)) + digest_task.add_done_callback(lambda t: t.exception() if t.done() and t.exception() else None) + + # Detect audience and audience takeaways + audience_task = asyncio.create_task( + agentic.detect_audience_and_takeaways(context, attachments_extension) + ) + audience_task.add_done_callback(lambda t: t.exception() if t.done() and t.exception() else None) + + # Detect knowledge gaps + gaps_task = asyncio.create_task( + agentic.detect_knowledge_package_gaps(context, attachments_extension) + ) + gaps_task.add_done_callback(lambda t: t.exception() if t.done() and t.exception() else None) + + # Detect coordinator actions + coordinator_actions_task = asyncio.create_task( + agentic.detect_coordinator_actions(context, attachments_extension) + ) + coordinator_actions_task.add_done_callback( + lambda t: t.exception() if t.done() and t.exception() else None + ) + + # Once all the other agents have had their turn, let's focus the conversation. + # background_tasks = [audience_task, gaps_task, coordinator_actions_task] + # await asyncio.gather(*background_tasks, return_exceptions=True) + # task5 = asyncio.create_task(agentic.focus(context, attachments_extension)) + # task5.add_done_callback(lambda t: t.exception() if t.done() and t.exception() else None) + + if role == ConversationRole.TEAM: + # For team role, analyze message for possible information request needs. + # Send a notification if we think it might be one. + detection_result = await agentic.detect_information_request_needs(context, message.content) + + if ( + detection_result.get("is_information_request", False) + and detection_result.get("confidence", 0) > 0.8 + ): + suggested_title = detection_result.get("potential_title", "") + suggested_priority = detection_result.get("suggested_priority", "medium") + potential_description = detection_result.get("potential_description", "") + reason = detection_result.get("reason", "") + + # TODO: replace this with the sub-agent creating tasks. + await context.send_messages( + NewConversationMessage( + content=( + f"**Potential _Information Request_ Detected**\n\n" + f"You might need information from the knowledge coordinator. {reason}\n\n" + f"Would you like me to create an information request?\n" + f"**Title:** {suggested_title}\n" + f"**Description:** {potential_description}\n" + f"**Priority:** {suggested_priority}\n\n" + ), + message_type=MessageType.notice, + metadata={"debug": detection_result}, + ) + ) + + # Generate message. response = await respond_to_conversation( context, new_message=message, @@ -354,12 +389,8 @@ async def on_message_created( response_parts.append(output_model.response) if output_model.excerpt: - output_model.excerpt = output_model.excerpt.strip().strip( - '"' - ) - response_parts.append( - f'> _"{output_model.excerpt}"_ (excerpt)' - ) + output_model.excerpt = output_model.excerpt.strip().strip('"') + response_parts.append(f'> _"{output_model.excerpt}"_ (excerpt)') if output_model.citations: citations = ", ".join(output_model.citations) @@ -368,13 +399,21 @@ async def on_message_created( if output_model.next_step_suggestion: metadata["help"] = output_model.next_step_suggestion - else: + if role == ConversationRole.COORDINATOR: output_model = CoordinatorOutput.model_validate_json(content) if output_model.response: response_parts.append(output_model.response) # if output_model.next_step_suggestion: # metadata["help"] = output_model.next_step_suggestion + await context.send_messages( + NewConversationMessage( + content="\n\n".join(response_parts), + message_type=MessageType.chat, + metadata=metadata, + ) + ) + # Save valid assistant responses for team access. await store_coordinator_message( context, @@ -391,30 +430,6 @@ async def on_message_created( ), ) - # Doing background tasks only when the coordinator has responded. - - # Detect information requests in the conversation. - task = asyncio.create_task(agentic.update_digest(context)) - task.add_done_callback( - lambda t: t.exception() if t.done() and t.exception() else None - ) - - # Background thinking - audience - task2 = asyncio.create_task( - detect_audience_and_takeaways(context, attachments_extension) - ) - task2.add_done_callback( - lambda t: t.exception() if t.done() and t.exception() else None - ) - - await context.send_messages( - NewConversationMessage( - content="\n\n".join(response_parts), - message_type=MessageType.chat, - metadata=metadata, - ) - ) - except Exception as e: metadata["debug"]["error"] = str(e) logger.exception(f"exception occurred parsing json response: {e}") @@ -444,13 +459,9 @@ async def on_command_created( if message.message_type != MessageType.command: return - await context.update_participant_me( - UpdateParticipant(status="processing command...") - ) + await context.update_participant_me(UpdateParticipant(status="processing command...")) try: - metadata = { - "debug": {"content_safety": event.data.get(content_safety.metadata_key, {})} - } + metadata = {"debug": {"content_safety": event.data.get(content_safety.metadata_key, {})}} # Respond to the conversation await respond_to_conversation( @@ -481,9 +492,7 @@ async def on_file_created( """ try: if not file.filename: - logger.warning( - f"No share found or missing filename. filename={file.filename}" - ) + logger.warning(f"No share found or missing filename. filename={file.filename}") return share = await ShareManager.get_share(context) @@ -507,9 +516,7 @@ async def on_file_created( # 2. Synchronize to all Team conversations # Get all Team conversations - team_conversations = await ShareFilesManager.get_team_conversations( - context, share.share_id - ) + team_conversations = await ShareFilesManager.get_team_conversations(context, share.share_id) if team_conversations: for team_conv_id in team_conversations: @@ -566,9 +573,7 @@ async def on_file_updated( logger.error(f"Failed to update file in share storage: {file.filename}") return - team_conversations = await ShareFilesManager.get_team_conversations( - context, share.share_id - ) + team_conversations = await ShareFilesManager.get_team_conversations(context, share.share_id) for team_conv_id in team_conversations: await ShareFilesManager.copy_file_to_conversation( context=context, @@ -615,9 +620,7 @@ async def on_file_deleted( ) if not success: - logger.error( - f"Failed to delete file from share storage: {file.filename}" - ) + logger.error(f"Failed to delete file from share storage: {file.filename}") # 2. Update all UIs about the deletion but don't send notifications to reduce noise await Notifications.notify_all_state_update(context, [InspectorTab.DEBUG]) @@ -665,9 +668,7 @@ async def on_participant_joined( if not share_id: return - await ShareFilesManager.synchronize_files_to_team_conversation( - context=context, share_id=share_id - ) + await ShareFilesManager.synchronize_files_to_team_conversation(context=context, share_id=share_id) await ShareManager.log_share_event( context=context, diff --git a/assistants/knowledge-transfer-assistant/assistant/config.py b/assistants/knowledge-transfer-assistant/assistant/config.py index 9c58ee274..507819b1e 100644 --- a/assistants/knowledge-transfer-assistant/assistant/config.py +++ b/assistants/knowledge-transfer-assistant/assistant/config.py @@ -64,26 +64,15 @@ class PromptConfig(BaseModel): title="Prompt templates", json_schema_extra={ "required": [ - "coordinator_role", "coordinator_instructions", - "team_role", "team_instructions", "share_information_request_detection", - "knowledge_digest_update", + "update_knowledge_digest", "welcome_message_generation", ], }, ) - coordinator_role: Annotated[ - str, - Field( - title="Coordinator role", - description="The role of the coordinator assistant. This is added to the prompt when in coordinator mode.", - ), - UISchema(widget="textarea"), - ] = load_text_include("coordinator_role.txt") - coordinator_instructions: Annotated[ str, Field( @@ -91,16 +80,7 @@ class PromptConfig(BaseModel): description="The instructions to give the coordinator assistant. This is added to the prompt when in coordinator mode.", # noqa: E501 ), UISchema(widget="textarea"), - ] = load_text_include("coordinator_instructions.txt") - - team_role: Annotated[ - str, - Field( - title="Team role", - description="The role of the team assistant. This is added to the prompt when in team member mode.", - ), - UISchema(widget="textarea"), - ] = load_text_include("team_role.txt") + ] = load_text_include("coordinator_instructions.md") team_instructions: Annotated[ str, @@ -111,23 +91,23 @@ class PromptConfig(BaseModel): UISchema(widget="textarea"), ] = load_text_include("team_instructions.txt") - share_information_request_detection: Annotated[ + detect_information_request_needs: Annotated[ str, Field( title="Information Request detection prompt", description="The prompt used to detect information requests in knowledge transfer mode.", ), UISchema(widget="textarea"), - ] = load_text_include("share_information_request_detection.txt") + ] = load_text_include("detect_information_request_needs.md") - knowledge_digest_update: Annotated[ + update_knowledge_digest: Annotated[ str, Field( - title="Knowledge Digest update", + title="Knowledge Digest update prompt", description="The prompt used to generate updated knowledge digest content.", ), UISchema(widget="textarea"), - ] = load_text_include("knowledge_digest_update.txt") + ] = load_text_include("update_knowledge_digest.md") welcome_message_generation: Annotated[ str, diff --git a/assistants/knowledge-transfer-assistant/assistant/data.py b/assistants/knowledge-transfer-assistant/assistant/data.py index f431a899f..8f535c815 100644 --- a/assistants/knowledge-transfer-assistant/assistant/data.py +++ b/assistants/knowledge-transfer-assistant/assistant/data.py @@ -190,7 +190,6 @@ class Share(BaseModel): learning_objectives: list[LearningObjective] = Field(default_factory=list) brief: KnowledgeBrief | None preferred_communication_style: str | None = None - transfer_notes: str | None = None digest: KnowledgeDigest | None # Lifecycle @@ -198,7 +197,7 @@ class Share(BaseModel): next_learning_actions: list[str] = Field(default_factory=list) # knowledge_organized: bool = False requests: list[InformationRequest] = Field(default_factory=list) - assistant_thoughts: list[str] = Field(default_factory=list) + tasks: list[str] = Field(default_factory=list) log: ShareLog | None = Field(default_factory=lambda: ShareLog()) diff --git a/assistants/knowledge-transfer-assistant/assistant/domain/__init__.py b/assistants/knowledge-transfer-assistant/assistant/domain/__init__.py index 4b3b9e584..73d924834 100644 --- a/assistants/knowledge-transfer-assistant/assistant/domain/__init__.py +++ b/assistants/knowledge-transfer-assistant/assistant/domain/__init__.py @@ -10,7 +10,7 @@ from .knowledge_digest_manager import KnowledgeDigestManager from .learning_objectives_manager import LearningObjectivesManager from .share_manager import ShareManager -from .thoughts_manager import ThoughtsManager +from .tasks_manager import TasksManager from .transfer_manager import TransferManager __all__ = [ @@ -20,6 +20,6 @@ "KnowledgeDigestManager", "LearningObjectivesManager", "ShareManager", - "ThoughtsManager", + "TasksManager", "TransferManager", ] diff --git a/assistants/knowledge-transfer-assistant/assistant/domain/audience_manager.py b/assistants/knowledge-transfer-assistant/assistant/domain/audience_manager.py index 30dd20f35..560c0c9e2 100644 --- a/assistants/knowledge-transfer-assistant/assistant/domain/audience_manager.py +++ b/assistants/knowledge-transfer-assistant/assistant/domain/audience_manager.py @@ -8,8 +8,7 @@ from semantic_workbench_assistant.assistant_app import ConversationContext -from assistant.data import InspectorTab, LogEntryType -from assistant.notifications import Notifications +from assistant.data import LogEntryType from .share_manager import ShareManager @@ -37,9 +36,6 @@ async def update_audience( }, ) - await Notifications.notify(context, "Audience updated.") - await Notifications.notify_all_state_update(context, [InspectorTab.DEBUG]) - @staticmethod async def update_audience_takeaways( context: ConversationContext, @@ -61,6 +57,3 @@ async def update_audience_takeaways( "takeaways": takeaways, }, ) - - await Notifications.notify(context, "Audience takeaways updated.") - await Notifications.notify_all_state_update(context, [InspectorTab.BRIEF]) diff --git a/assistants/knowledge-transfer-assistant/assistant/domain/knowledge_digest_manager.py b/assistants/knowledge-transfer-assistant/assistant/domain/knowledge_digest_manager.py index 742610cd9..45ece3b67 100644 --- a/assistants/knowledge-transfer-assistant/assistant/domain/knowledge_digest_manager.py +++ b/assistants/knowledge-transfer-assistant/assistant/domain/knowledge_digest_manager.py @@ -27,7 +27,7 @@ async def update_knowledge_digest( context: ConversationContext, content: str, is_auto_generated: bool = True, - ) -> KnowledgeDigest: + ) -> None: share_id = await ShareManager.get_share_id(context) current_user_id = await get_current_user_id(context) @@ -66,4 +66,4 @@ async def update_knowledge_digest( [InspectorTab.BRIEF], ) - return digest + return diff --git a/assistants/knowledge-transfer-assistant/assistant/domain/learning_objectives_manager.py b/assistants/knowledge-transfer-assistant/assistant/domain/learning_objectives_manager.py index 77e547499..140f73036 100644 --- a/assistants/knowledge-transfer-assistant/assistant/domain/learning_objectives_manager.py +++ b/assistants/knowledge-transfer-assistant/assistant/domain/learning_objectives_manager.py @@ -24,8 +24,6 @@ async def add_learning_objective( outcomes: list[str] | None = None, priority: int = 1, ) -> LearningObjective | None: - share_id = await ShareManager.get_share_id(context) - criterion_objects = [] if outcomes: for criterion in outcomes: @@ -39,9 +37,7 @@ async def add_learning_objective( ) share = await ShareManager.get_share(context) - share.learning_objectives.append(new_learning_objective) - await ShareManager.set_share(context, share) await ShareManager.log_share_event( @@ -50,7 +46,7 @@ async def add_learning_objective( message=f"Added learning objective: {objective_name}", ) - await Notifications.notify_all(context, share_id, f"Learning objective '{objective_name}' was added") + await Notifications.notify_all(context, share.share_id, f"Learning objective '{objective_name}' was added") await Notifications.notify_all_state_update(context, [InspectorTab.LEARNING, InspectorTab.BRIEF]) return new_learning_objective @@ -78,7 +74,6 @@ async def update_learning_objective( if obj.id == objective_id: objective = obj break - if not objective: raise ValueError("Learning objective not found") diff --git a/assistants/knowledge-transfer-assistant/assistant/domain/tasks_manager.py b/assistants/knowledge-transfer-assistant/assistant/domain/tasks_manager.py new file mode 100644 index 000000000..3d630fe70 --- /dev/null +++ b/assistants/knowledge-transfer-assistant/assistant/domain/tasks_manager.py @@ -0,0 +1,45 @@ +from semantic_workbench_assistant.assistant_app import ConversationContext + +from assistant.domain.share_manager import ShareManager +from assistant.storage import ShareStorage + + +class TasksManager: + @staticmethod + async def get_tasks( + context: ConversationContext, + ) -> list[str]: + share_id = await ShareManager.get_share_id(context) + if not share_id: + return [] + return ShareStorage.read_tasks(share_id) + + @staticmethod + async def add_tasks( + context: ConversationContext, + tasks: list[str], + ) -> None: + share_id = await ShareManager.get_share_id(context) + if not share_id: + return + ShareStorage.add_tasks(share_id, tasks) + + @staticmethod + async def remove_task( + context: ConversationContext, + task: str, + ) -> None: + share_id = await ShareManager.get_share_id(context) + if not share_id: + return + ShareStorage.remove_task(share_id, task) + + @staticmethod + async def set_task_list( + context: ConversationContext, + tasks: list[str], + ) -> None: + share_id = await ShareManager.get_share_id(context) + if not share_id: + return + ShareStorage.set_all_tasks(share_id, tasks) diff --git a/assistants/knowledge-transfer-assistant/assistant/domain/thoughts_manager.py b/assistants/knowledge-transfer-assistant/assistant/domain/thoughts_manager.py deleted file mode 100644 index 83d48cc44..000000000 --- a/assistants/knowledge-transfer-assistant/assistant/domain/thoughts_manager.py +++ /dev/null @@ -1,42 +0,0 @@ -from semantic_workbench_assistant.assistant_app import ConversationContext - -from assistant.data import InspectorTab -from assistant.domain.share_manager import ShareManager -from assistant.notifications import Notifications -from assistant.storage import ShareStorage - - -class ThoughtsManager: - @staticmethod - async def get_assistant_thoughts( - context: ConversationContext, - ) -> list[str]: - share_id = await ShareManager.get_share_id(context) - if not share_id: - return [] - return ShareStorage.read_assistant_thoughts(share_id) - - @staticmethod - async def add_assistant_thoughts( - context: ConversationContext, - thoughts: list[str], - ) -> None: - share_id = await ShareManager.get_share_id(context) - if not share_id: - return - ShareStorage.add_assistant_thoughts(share_id, thoughts) - - await Notifications.notify(context, f"Added {len(thoughts)} assistant thoughts.") - await Notifications.notify_state_update(context, [InspectorTab.DEBUG]) - - @staticmethod - async def remove_assistant_thought( - context: ConversationContext, - thought: str, - ) -> None: - share_id = await ShareManager.get_share_id(context) - if not share_id: - return - ShareStorage.remove_assistant_thought(share_id, thought) - await Notifications.notify(context, "Forgot something.") - await Notifications.notify_state_update(context, [InspectorTab.DEBUG]) diff --git a/assistants/knowledge-transfer-assistant/assistant/notifications.py b/assistants/knowledge-transfer-assistant/assistant/notifications.py index 910d9333b..927a063ed 100644 --- a/assistants/knowledge-transfer-assistant/assistant/notifications.py +++ b/assistants/knowledge-transfer-assistant/assistant/notifications.py @@ -1,3 +1,5 @@ +from typing import Any + from semantic_workbench_api_model.workbench_model import ( AssistantStateEvent, MessageType, @@ -14,12 +16,13 @@ class Notifications: @staticmethod - async def notify(context: ConversationContext, message: str) -> None: + async def notify(context: ConversationContext, message: str, debug_data: dict[str, Any] | None = None) -> None: """Send text message notification to current conversation only.""" await context.send_messages( NewConversationMessage( content=message, message_type=MessageType.notice, + debug_data=debug_data, ) ) diff --git a/assistants/knowledge-transfer-assistant/assistant/prompt_utils.py b/assistants/knowledge-transfer-assistant/assistant/prompt_utils.py index 6b59d526c..3a9214ab9 100644 --- a/assistants/knowledge-transfer-assistant/assistant/prompt_utils.py +++ b/assistants/knowledge-transfer-assistant/assistant/prompt_utils.py @@ -24,7 +24,7 @@ ) from assistant.domain.learning_objectives_manager import LearningObjectivesManager from assistant.domain.share_manager import ShareManager -from assistant.domain.thoughts_manager import ThoughtsManager +from assistant.domain.tasks_manager import TasksManager from assistant.ui_tabs.common import get_priority_emoji, get_status_emoji @@ -126,7 +126,6 @@ class ContextStrategy(Enum): @dataclass class Prompt: - role: str instructions: Instructions output_format: str | None = None reasoning_steps: str | None = None @@ -137,9 +136,6 @@ class Prompt: def messages(self) -> list[ChatCompletionMessageParam]: parts = [ - "# Role and Objective", - self.role, - "# Instructions", str(self.instructions), ] if self.reasoning_steps: @@ -206,7 +202,7 @@ class ContextSection(Enum): SUGGESTED_NEXT_ACTIONS = "suggested_next_actions" COORDINATOR_CONVERSATION = "coordinator_conversation" ATTACHMENTS = "attachments" - ASSISTANT_THOUGHTS = "assistant_thoughts" + TASKS = "tasks" async def add_context_to_prompt( @@ -225,16 +221,14 @@ async def add_context_to_prompt( share = await ShareManager.get_share(context) - if ContextSection.ASSISTANT_THOUGHTS in include: - # Get the assistant's thoughts from the conversation context. - assistant_thoughts = await ThoughtsManager.get_assistant_thoughts(context) - if assistant_thoughts: - thoughts_data = "\n\n".join("- " + thought for thought in assistant_thoughts) + if ContextSection.TASKS in include: + tasks = await TasksManager.get_tasks(context) + if tasks: + tasks_data = json.dumps(tasks) prompt.contexts.append( DataContext( - "Assistant Thoughts", - thoughts_data, - "The assistant's thoughts about the conversation.", + "Task List", + tasks_data, ) ) @@ -246,10 +240,10 @@ async def add_context_to_prompt( "learning_objectives", "audience_takeaways", "preferred_communication_style", - "transfer_notes", "digest", "next_learning_actions", "requests", + "tasks", "log", }, ) @@ -335,7 +329,6 @@ async def add_context_to_prompt( ) ) - # Information requests if ContextSection.INFORMATION_REQUESTS in include and share: all_requests = share.requests if role == ConversationRole.COORDINATOR: @@ -386,7 +379,6 @@ async def add_context_to_prompt( ) ) - # Add next action suggestions for coordinator if ContextSection.SUGGESTED_NEXT_ACTIONS in include and share and role == ConversationRole.COORDINATOR: next_action_suggestion = await get_coordinator_next_action_suggestion(context) if next_action_suggestion: @@ -433,7 +425,7 @@ class CoordinatorMessageList(BaseModel): DataContext( "Message History", coordinator_message_list_data, - "The assistant conversation with the user.", + "The conversation so far.", ) ) else: diff --git a/assistants/knowledge-transfer-assistant/assistant/storage.py b/assistants/knowledge-transfer-assistant/assistant/storage.py index 18eb330d3..ba360d3ee 100644 --- a/assistants/knowledge-transfer-assistant/assistant/storage.py +++ b/assistants/knowledge-transfer-assistant/assistant/storage.py @@ -113,30 +113,38 @@ def write_share(share_id: str, share: Share) -> pathlib.Path: return path @staticmethod - def read_assistant_thoughts(share_id: str) -> list[str]: + def read_tasks(share_id: str) -> list[str]: share = ShareStorage.read_share(share_id) if not share: return [] - return share.assistant_thoughts + return share.tasks @staticmethod - def add_assistant_thoughts(share_id: str, thoughts: list[str]) -> None: + def add_tasks(share_id: str, tasks: list[str]) -> None: share = ShareStorage.read_share(share_id) if not share: raise NoShareException - share.assistant_thoughts.extend(thoughts) + share.tasks.extend(tasks) ShareStorage.write_share(share_id, share) @staticmethod - def remove_assistant_thought(share_id: str, thought: str) -> None: + def remove_task(share_id: str, task: str) -> None: share = ShareStorage.read_share(share_id) if not share: raise NoShareException - if thought in share.assistant_thoughts: - share.assistant_thoughts.remove(thought) + if task in share.tasks: + share.tasks.remove(task) ShareStorage.write_share(share_id, share) else: - logger.warning(f"Thought '{thought}' not found in share {share_id}.") + logger.warning(f"Thought '{task}' not found in share {share_id}.") + + @staticmethod + def set_all_tasks(share_id: str, tasks: list[str]) -> None: + share = ShareStorage.read_share(share_id) + if not share: + raise NoShareException + share.tasks = tasks + ShareStorage.write_share(share_id, share) @staticmethod def read_knowledge_brief(share_id: str) -> KnowledgeBrief | None: diff --git a/assistants/knowledge-transfer-assistant/assistant/text_includes/background_thought_audience.md b/assistants/knowledge-transfer-assistant/assistant/text_includes/background_thought_audience.md deleted file mode 100644 index e88762292..000000000 --- a/assistants/knowledge-transfer-assistant/assistant/text_includes/background_thought_audience.md +++ /dev/null @@ -1,18 +0,0 @@ -You are an AI assistant watching a conversation between another assistant and a user where the assistant is helping the user assemble a knowledge package for an audience. Your job is to ensure the audience is well defined by making suggestions to the assistant as needed. - -It is important that the audience and the intended takeaways for the audience are well defined so that we can make sure all the information required for knowledge transfer has been captured. - -Situations in which you should suggest to the assistant they attend to something (think about it): - -- If the audience has not been clearly defined, suggest the assistant guide the user to define it. -- If the audience has been defined but no specific intended takeaways for that audience have been defined, suggest the assistant guide the user in defining the intended takeaways. -- If the audience or takeaways have been defined generally, suggest the assistant guide the user to make them more specific and concrete. -- If recent messages give information about the intended audience the assistant should use the `update_audience` tool. If they haven't, remind them. -- If recent messages give information about the intended audience takeaways, the assistant should use the `update_audience_takeaways` tool. If they haven't, remind them. -- Sometimes the assistant might have duplicate thoughts about the audience or takeaways. If so, suggest the assistant remove one or more of them. - -Under all other circumstances, you should reply with an empty list. Oftentimes there will be nothing related to the audience or takeaways in new messages. This is quite common in a conversation. Return with an empty list in this case. - -IMPORTANT! If the assistant is already thinking about what you want them to think about, you don't need to tell them again. The assistant's thoughts are shown below. - -Respond with a list of suggestions for the assistant related to the audience or audience takeaways in JSON. diff --git a/assistants/knowledge-transfer-assistant/assistant/text_includes/card_content.md b/assistants/knowledge-transfer-assistant/assistant/text_includes/card_content.md index d7bfd841b..cfbc8fe59 100644 --- a/assistants/knowledge-transfer-assistant/assistant/text_includes/card_content.md +++ b/assistants/knowledge-transfer-assistant/assistant/text_includes/card_content.md @@ -1,6 +1,6 @@ -Make complex information easy to understand +Use an assistant to capture and transfer knowledge to others -- Get simple explanations for concepts -- Visualize information with diagrams -- Find answers without information overload -- Learn with personalized teaching +- Remove burden of gathering knowledge +- Share with one or more recipients +- Remove yourself from the learning loop +- Make sure learning objectives are achieved diff --git a/assistants/knowledge-transfer-assistant/assistant/text_includes/coordinator_instructions.txt b/assistants/knowledge-transfer-assistant/assistant/text_includes/coordinator_instructions copy.md similarity index 69% rename from assistants/knowledge-transfer-assistant/assistant/text_includes/coordinator_instructions.txt rename to assistants/knowledge-transfer-assistant/assistant/text_includes/coordinator_instructions copy.md index f8a986078..607b13b4b 100644 --- a/assistants/knowledge-transfer-assistant/assistant/text_includes/coordinator_instructions.txt +++ b/assistants/knowledge-transfer-assistant/assistant/text_includes/coordinator_instructions copy.md @@ -1,29 +1,44 @@ -## Behavioral Guidelines +# Role and Objective -### Unresolved Requests +You are an autonomous AI assistant named the "Knowledge Transfer Assistant" that supports a user in creating and refining a knowledge package that will be shared with an audience. You are an expert in knowledge transfer and management, and your primary goal is to help the user assemble, organize, and refine the knowledge package. -- If there are active `NEW` information requests, alert the user and ask for input to resolve them. +Please keep going until the user's query is completely resolved, before ending your turn and yielding back to the user. Only terminate your turn when you are sure that the problem is solved. + +You are driven to guide the user by the state of the knowledge share as captured below, and, importantly, by tasks on your task list. You should always refer your task list and attempt to resolve tasks as you are guiding the user. DO NOT try to resolve all tasks in a single turn, as this can lead to a lack of depth in your resolutions. Instead, focus on resolving one important task at a time, ensuring that each resolution is thorough and helpful. + +If you are not sure about attachment content or knowledge package structure pertaining to the user’s request, gather the relevant information: do NOT guess or make up an answer. -### Unresolved Thoughts +You MUST plan extensively before each function call, and reflect extensively on the outcomes of the previous function calls. DO NOT do this entire process by making function calls only, as this can impair your ability to solve the problem and think insightfully. -Previous unresolved thoughts you've had about the user's request are stored in the "Assistant Thoughts" section below. You should always refer to this section and attempt to resolve those thoughts as you are working with the user. +Your purpose is to guide the user through the knowledge transfer process, helping them assemble and organize the knowledge share and the knowledge package. -Once you have resolved a thought, you can remove it from the "Assistant Thoughts" section using the `forget_thought` tool. +After the knowledge package is shared, continue supporting the user by helping respond to information requests and updating the package as needed. -Remove redundant or no longer relevant thoughts. They'll always come back if important. +# Instructions -### Knowledge Share Definition +## Information Requests + +- If there are active `NEW` information requests, alert the user and ask for input to resolve them. -#### Audience Definition +## Tasks -- "The audience" are the people who the knowledge package will be shared with. +- Collaborate with the user to complete tasks from your task list. +- Don't disrupt the current flow of the conversation, but if a natural transition occurs in the conversation, work on the next most important task. +- If you can resolve any task by simply executing one of your tools, do so in the current turn. +- Once a task is completed, remove it from the task list by using the `mark_task_completed` tool. + +## Knowledge Share Definition + +### Audience Definition + +- The "audience" is the people who the knowledge package will be shared with. - Help the user describe who the knowledge is for and their level of experience. - If the audience is not yet or vaguely defined, prompt the user to describe who the knowledge is for. - Use your `update_audience` tool to update the audience definition as you learn more about the intended audience. - Update the audience proactively as the user provides more information. Don't ask for permission. - Help the user define intended audience takeaways. This is important to have up-front so we can figure out how to organize the knowledge package and what to include in the brief. -#### Learning Objectives +### Learning Objectives - If the user intends to accomplish outcomes: - Help define **Learning Objectives** with names, descriptions, and priority. @@ -35,7 +50,7 @@ Remove redundant or no longer relevant thoughts. They'll always come back if imp - If yes, help create them and ensure that each objective includes at least one outcome. - If no, confirm that the package is intended for general exploration. -#### Invitation +### Invitation - Help the user write a short message and generate a customized invitation link to send to audience members. - The message should be in the user's voice as they will copy and paste it into communication tools like SMS, Microsoft Teams, or email. @@ -45,7 +60,7 @@ Remove redundant or no longer relevant thoughts. They'll always come back if imp - [Learning about MADE](/conversation-share/12345678-1234-1234-1234-123456789012/redeem) - [Our Knowledge Base](/conversation-share/abcdef12-3456-7890-abcd-ef1234567890/redeem) -#### Knowledge Brief +### Knowledge Brief - The "knowledge brief" is a fairly detailed summary of the knowledge share that is prepared by the user and will be displayed at all times to the audience. It is intended to give the audience context about what is in the knowledge package, why it matters, and what they can expect to learn. - If a knowledge brief has not been created, help the user write one. @@ -55,7 +70,7 @@ Remove redundant or no longer relevant thoughts. They'll always come back if imp - When talking about the brief, there is no need to explain its contents. The user can see it in their side panel. - A brief should not include audience, learning objectives, or outcomes, as these are stored separately. -### Knowledge Package (chat messages and attached files) +## Knowledge Package (chat messages and attached files) - Assist the user in uploading and/or describing the knowledge to be shared. The "knowledge package" includes what is talked about in the chat, attached files, the brief, and the knowledge digest. - When files are uploaded, acknowledge the upload and summarize the file contents if possible. @@ -65,7 +80,7 @@ Remove redundant or no longer relevant thoughts. They'll always come back if imp - If learning objectives are defined, ensure that the knowledge package covers all required outcomes. - This step is CRUCIAL to ensure that the knowledge package is comprehensive and allows the audience to meet the takeaways and learning objectives. -### Conversation Flow +## Conversation Flow Allow the user to drive the interaction. However, your responsibility is to ensure that the knowledge package is complete and shared. @@ -88,8 +103,9 @@ An example conversation flow: This is a general flow, but you should adapt it based on the user's needs and the current state of the knowledge package. -### Post-Transfer Support +## Post-Transfer Support After the knowledge package is shared, help the user address any information requests. Support updates to the audience definition, knowledge brief, objectives, outcomes, or knowledge package content at any time. + diff --git a/assistants/knowledge-transfer-assistant/assistant/text_includes/coordinator_instructions.md b/assistants/knowledge-transfer-assistant/assistant/text_includes/coordinator_instructions.md new file mode 100644 index 000000000..200d94340 --- /dev/null +++ b/assistants/knowledge-transfer-assistant/assistant/text_includes/coordinator_instructions.md @@ -0,0 +1,61 @@ +# Role and Objective + +You are an autonomous AI assistant named the "Knowledge Transfer Assistant" that supports a user in creating and refining a knowledge package that will be shared with an audience. You are an expert in knowledge transfer and management, and your primary goal is to help the user assemble, organize, and refine the knowledge package. + +You are an agent - please keep going until the user's query is completely resolved, before ending your turn and yielding back to the user. Only terminate your turn when you are sure that the problem is solved. + +You are driven to guide the user by the state of the knowledge share as captured below, and, importantly, by tasks on your task list. You should always refer your task list and attempt to resolve tasks as you are guiding the user. + +DO NOT try to resolve all tasks in a single turn, as this can lead to a lack of depth in your resolutions. Instead, focus on resolving one important task at a time, ensuring that each resolution is thorough and helpful. + +You MUST plan extensively before each function call, and reflect extensively on the outcomes of the previous function calls. DO NOT do this entire process by making function calls only, as this can impair your ability to solve the problem and think insightfully. + +# Context available to you + +- Task list: Tasks to be completed to help the user. +- Information requests: After a knowledge package is shared, the audience is able to request more information from the user. These audience requests are stored as "Information Requests". +- Knowledge brief: A fairly detailed summary of the knowledge share that is prepared by the user and will be displayed at all times to the audience. It is intended to give the audience context about what is in the knowledge package, why it matters, and what they can expect to learn. +- Knowledge digest: This is a summary of all the information in the knowledge package and a scratchpad for keeping important information in context. +- Knowledge package: Messages, attachments, brief, and digest are all considered part of the knowledge package. They are all shared with the audience. + +# Instructions + +- If there are active `NEW` information requests, alert the user and collaborate with the user resolve them. +- Collaborate with the user to complete tasks from the task list. +- If you can resolve any task with a single tool call, do it now. +- IMPORTANT! Remove tasks once they are completed. Use the `mark_task_completed` tool every time you complete a task. If any old tasks are still in the task list that have already been completed, REMOVE THEM TOO. +- Don't respond with information about tasks that you have completed. Respond only with ways to move the knowledge transfer process forward. + +## Knowledge Package + +- Assist the user in capturing the knowledge to be shared through messages and attachments. +- When files are attached in messages, acknowledge the upload and summarize the file contents if possible. +- Everything the intended audience needs to know should be included in the knowledge package. +- Help the user fill in gaps in the knowledge package as needed: + - Ensure the audience can takeaway what the user intends. + - If learning objectives are defined, ensure that the knowledge package covers all required outcomes. + +### Invitation + +- When appropriate, help the user create an invitation message to share the knowledge package with the audience. +- The user won't see the output of the `create_invitation` tool. You must show it to them in entirety. + +### Knowledge Brief + +- At the appropriate time, help the user write a knowledge brief. +- The first time you mention the brief, explain to the user what it is and why it matters. +- Update the brief proactively as the user provides more information. Don't ask for permission. +- When discussing the brief, there is no need to explain its contents. The user can see it in their side panel. +- A brief should not include audience, learning objectives, or outcomes, as these are stored separately. + +# Conversation Flow + +Allow the user to drive the interaction. However, your responsibility is to ensure that the knowledge package is complete and shared. Your task-list should help you know what to do next. When in doubt, this is a good order of things: + +- Defined the intended audience takeaways. +- Define the audience. +- Define optional learning objectives and outcomes. +- Help the user add content to the knowledge package. +- Help run a gap analysis and help the user fill in gaps. +- Prepare the Knowledge brief. +- Help create an invitation. diff --git a/assistants/knowledge-transfer-assistant/assistant/text_includes/coordinator_role.txt b/assistants/knowledge-transfer-assistant/assistant/text_includes/coordinator_role.txt deleted file mode 100644 index aec1fb03a..000000000 --- a/assistants/knowledge-transfer-assistant/assistant/text_includes/coordinator_role.txt +++ /dev/null @@ -1,15 +0,0 @@ -# Role and Objective - -You are an autonomous AI assistant named the "Knowledge Transfer Assistant" that supports a user in creating and refining a knowledge package that will be shared with an audience. - -Please keep going until the user's query is completely resolved, before ending your turn and yielding back to the user. Only terminate your turn when you are sure that the problem is solved. - -You are driven to guide the user by the state of the knowledge share as captured below, and, importantly, by thoughts you've had about this process. You have a sophisticated background-thinking system and your thoughts are shown in the "Assistant Thoughts" section below. You should always refer to this section and attempt to resolve those thoughts as you are guiding the user. - -If you are not sure about attachment content or knowledge package structure pertaining to the user’s request, gather the relevant information: do NOT guess or make up an answer. - -You MUST plan extensively before each function call, and reflect extensively on the outcomes of the previous function calls. DO NOT do this entire process by making function calls only, as this can impair your ability to solve the problem and think insightfully. - -Your purpose is to guide the user through the knowledge transfer process, helping them assemble and organize the knowledge share and the knowledge package. - -After the knowledge package is shared, continue supporting the user by helping respond to information requests and updating the package as needed. diff --git a/assistants/knowledge-transfer-assistant/assistant/text_includes/create_invitation.md b/assistants/knowledge-transfer-assistant/assistant/text_includes/create_invitation.md new file mode 100644 index 000000000..c8728f10d --- /dev/null +++ b/assistants/knowledge-transfer-assistant/assistant/text_includes/create_invitation.md @@ -0,0 +1,10 @@ +Create an invitation for this knowledge share. + +- Incorporate any specific instructions provided by the user in the conversation. +- The invitation should include a customized invitation link. +- The invitation should be in the user's voice as they will copy and paste it into communication tools like SMS, Microsoft Teams, or email. +- The invitation shouldn't include all the details about the knowledge share, just a brief statement about what it is, why they should be interested to check it out, and the invitation link. +- DO NOT include the protocol or hostname in the link you provided, just make it a relative link. Examples: + - [New project knowledge share-out](/conversation-share/a5b400d4-b8c4-4484-ae83-dad98fe49b06/redeem) + - [Learning about MADE](/conversation-share/12345678-1234-1234-1234-123456789012/redeem) + - [Our Knowledge Base](/conversation-share/abcdef12-3456-7890-abcd-ef1234567890/redeem) diff --git a/assistants/knowledge-transfer-assistant/assistant/text_includes/detect_audience.md b/assistants/knowledge-transfer-assistant/assistant/text_includes/detect_audience.md new file mode 100644 index 000000000..c8a322ff5 --- /dev/null +++ b/assistants/knowledge-transfer-assistant/assistant/text_includes/detect_audience.md @@ -0,0 +1,17 @@ +You are an AI assistant watching a conversation between a consultant and their client. The consultant is helping the client assemble a knowledge package for an audience. Your job is to ensure the audience is well defined adding tasks to the consultant's task list as needed. + +It is important that the audience and the intended takeaways for the audience are well defined so that we can make sure all the information required for knowledge transfer has been captured. + +The ONLY situations in which you should add a task to the consultant's task list: + +- If the audience has not been clearly defined, task the consultant to guide the user to define it. +- If the audience has been defined but no specific intended takeaways for that audience have been defined, task the consultant to guide the user in defining the intended takeaways. +- If the audience or takeaways have been defined generally, task the consultant to guide the user to make them more specific and concrete. +- If recent messages give additional information about the intended audience, task the consultant to update the audience with additional information (provide the specific information that needs to be added in the task). +- If recent messages give additional information about the intended audience takeaways, task the consultant to update their audience takeaways (provide the specific takeaway information to be updated in the task) + +UNDER ALL OTHER CIRCUMSTANCES, you should reply with an empty list. Oftentimes there will be nothing related to the audience or takeaways in new messages. This is quite common in a conversation. Return with an empty list in this case. + +IMPORTANT! If the consultant is already tasked with what you want them to do, you don't need to task them again. The consultant's tasks are shown below. + +Respond with a list of new tasks for the consultant related to the audience or audience takeaways in JSON. diff --git a/assistants/knowledge-transfer-assistant/assistant/text_includes/detect_coordinator_actions.md b/assistants/knowledge-transfer-assistant/assistant/text_includes/detect_coordinator_actions.md new file mode 100644 index 000000000..89bfcab00 --- /dev/null +++ b/assistants/knowledge-transfer-assistant/assistant/text_includes/detect_coordinator_actions.md @@ -0,0 +1,9 @@ +You are an an AI assistant who is expert in the knowledge transfer process. You are helping a user assemble a knowledge package for an audience. + +# Instructions + +- With your understanding of the knowledge transfer process and the current state of where we are at, examine the task list and add additional tasks if they are needed. If no additional tasks are needed, don't add any. +- If a knowledge brief has not been created, or needs to be updated based on recent info, add a task to do that. +- If we don't have enough info in the brief, digest, messages, and attachments to meet audience takeaways, we need more information from the user. +- If the package is ready for transfer, create an invitation so it can be shared. +- If the package has been shared already, look out for new information requests. diff --git a/assistants/knowledge-transfer-assistant/assistant/text_includes/share_information_request_detection.txt b/assistants/knowledge-transfer-assistant/assistant/text_includes/detect_information_request_needs.md similarity index 100% rename from assistants/knowledge-transfer-assistant/assistant/text_includes/share_information_request_detection.txt rename to assistants/knowledge-transfer-assistant/assistant/text_includes/detect_information_request_needs.md diff --git a/assistants/knowledge-transfer-assistant/assistant/text_includes/detect_knowledge_package_gaps.md b/assistants/knowledge-transfer-assistant/assistant/text_includes/detect_knowledge_package_gaps.md new file mode 100644 index 000000000..a6f97e2a6 --- /dev/null +++ b/assistants/knowledge-transfer-assistant/assistant/text_includes/detect_knowledge_package_gaps.md @@ -0,0 +1,14 @@ +You are an AI assistant who is expert at finding required information that is missing. Information is currently being collected to transfer to a particular audience. Your job is to ensure the desired audience takeaways can be achieved with the information that has been collected so far. If not, you identify what additional information is required. + +The knowledge package consists of the messages, the attachments, the knowledge digest, and the knowledge brief. + +The desired audience takeaways are listed below. + +A task list of items we know we need to do is also included. + +# Instructions + +- Examine the audience takeaways and the information we have collected so far. +- If there are specific, concrete pieces of information that our audience will require to takeaway what we want them to, add a task to collect that information. Be specific. +- If the knowledge gap you identify is already identified on the task list, do not return it. +- If the collected information is sufficient for our audience to take away what we want them to, return an empty list. diff --git a/assistants/knowledge-transfer-assistant/assistant/text_includes/focus.md b/assistants/knowledge-transfer-assistant/assistant/text_includes/focus.md new file mode 100644 index 000000000..be9ccaa42 --- /dev/null +++ b/assistants/knowledge-transfer-assistant/assistant/text_includes/focus.md @@ -0,0 +1,41 @@ +You are an AI assistant managing the focus of a task list related to a knowledge gathering process. You are not to add anything new to the task list, just help with organizing it. + +The user creates consulting tasks about a lot of closely related things. Help the user by consolidating their tasks as appropriate. +- Sometimes the user has tasks that are contradictory. Help the user resolve these contradictions by proposing new tasks that resolve the conflicts. +- Sometimes the user may have tasks unrelated to their knowledge transfer consulting project. In such cases, remove those tasks entirely. +- Remove tasks that have already been completed. + +### Conversation Flow + +Help the user by reinforcing the approved knowledge transfer flow. The approved flow is: + +``` +Ensure that the knowledge package is complete and shared. + +- If the knowledge share is missing key definition (audience, audience takeaways, required objectives/outcomes), help the client define these things. +- If the knowledge package is missing necessary content (chat messages, files, etc.), help the client add it. +- If the client has not defined learning objectives and outcomes or has NOT indicated they have no specific outcomes, ask if they want help defining them. +- If the intended audience takeaways (and learning objectives) are not able to be achieved with the current knowledge package, help the client collect more content to fill in gaps. +- If the knowledge package has no brief, help the client write one. +- If the package is ready for transfer, provide the invitation link and help the client craft an appropriate sharing message tailored to the audience. + +An example consulting flow for a knowledge transfer project: + +- Defined the intended audience takeaways. +- Define the audience. +- Define optional learning objectives and outcomes. +- Help the user add content to the knowledge package. +- Help run a gap analysis and help the user fill in gaps. +- Prepare the Knowledge brief. +- Help create an invitation. + +This is a general flow. The consultant should adapt it based on the client's needs and the current state of the knowledge package. +``` + +### Post-Transfer Support + +After the user/consultant is done helping their client with the knowledge sharing project, the user/consultant will continue to address any information requests from their clients but is also able to support updates to the audience definition, knowledge brief, objectives, outcomes, or knowledge package content at any time. Focus the user's task list for these purposes. + +### Output + +Given the set of tasks, return a new set of focused tasks. If the user has no tasks, return an empty list. diff --git a/assistants/knowledge-transfer-assistant/assistant/text_includes/team_instructions.txt b/assistants/knowledge-transfer-assistant/assistant/text_includes/team_instructions.txt index 4ba923ca7..436b50114 100644 --- a/assistants/knowledge-transfer-assistant/assistant/text_includes/team_instructions.txt +++ b/assistants/knowledge-transfer-assistant/assistant/text_includes/team_instructions.txt @@ -1,3 +1,17 @@ +# Role and objective + +You are an assistant that helps a user (a "team member") explore shared knowledge provided by a "coordinator". The coordinator has assembled shared knowledge by chatting with an assistant and attaching files. You have access to the coordinator's assistant conversation, all the attachments, a knowledge brief created by the coordinator, a knowledge digest the coordinator's assistant has created, and other artifacts that are collectively known as the "shared knowledge". + +Your responsibilities include: + +- Helping team members explore and understand the shared knowledge +- Clarifying complex topics from the shared knowledge +- Helping team members understand and execute the learning objectives defined by the coordinator and meet their defined learning outcomes. +- Tracking and marking completion of outcomes for each learning objective +- Creating information requests when users ask questions that weren't covered in the knowledge transfer or are needed to fill a gap or unblock the team members in achieving their outcomes + +# Instructions + ## Stick to the coordinator's shared knowledge! - Stick to the shared knowledge shared as much as possible. diff --git a/assistants/knowledge-transfer-assistant/assistant/text_includes/team_role.txt b/assistants/knowledge-transfer-assistant/assistant/text_includes/team_role.txt deleted file mode 100644 index 183faf4b4..000000000 --- a/assistants/knowledge-transfer-assistant/assistant/text_includes/team_role.txt +++ /dev/null @@ -1,9 +0,0 @@ -You are an assistant that helps a user (a "team member") explore shared knowledge provided by a "coordinator". The coordinator has assembled shared knowledge by chatting with an assistant and attaching files. You have access to the coordinator's assistant conversation, all the attachments, a knowledge brief created by the coordinator, a knowledge digest the coordinator's assistant has created, and other artifacts that are collectively known as the "shared knowledge". - -Your responsibilities include: - -- Helping team members explore and understand the shared knowledge -- Clarifying complex topics from the shared knowledge -- Helping team members understand and execute the learning objectives defined by the coordinator and meet their defined learning outcomes. -- Tracking and marking completion of outcomes for each learning objective -- Creating information requests when users ask questions that weren't covered in the knowledge transfer or are needed to fill a gap or unblock the team members in achieving their outcomes diff --git a/assistants/knowledge-transfer-assistant/assistant/text_includes/update_knowledge_brief.md b/assistants/knowledge-transfer-assistant/assistant/text_includes/update_knowledge_brief.md new file mode 100644 index 000000000..c73e24089 --- /dev/null +++ b/assistants/knowledge-transfer-assistant/assistant/text_includes/update_knowledge_brief.md @@ -0,0 +1 @@ +You are an AI assistant managing a document called a "knowledge brief". You are watching a chat conversation between a knowledge transfer consultant and their client. Your job is to create a diff --git a/assistants/knowledge-transfer-assistant/assistant/text_includes/knowledge_digest_update.txt b/assistants/knowledge-transfer-assistant/assistant/text_includes/update_knowledge_digest.md similarity index 82% rename from assistants/knowledge-transfer-assistant/assistant/text_includes/knowledge_digest_update.txt rename to assistants/knowledge-transfer-assistant/assistant/text_includes/update_knowledge_digest.md index 2dffb9093..9eafda586 100644 --- a/assistants/knowledge-transfer-assistant/assistant/text_includes/knowledge_digest_update.txt +++ b/assistants/knowledge-transfer-assistant/assistant/text_includes/update_knowledge_digest.md @@ -5,12 +5,17 @@ You, the assistant, maintain a "knowledge digest". Based on the last chat messag - The knowledge digest contains an outline of the knowledge contained within a knowledge share. It is not a summary, but an organized projection of all knowledge added to the packet from the coordinator's conversation and attachments. - The digest has a high information density. The digest contains no filler words or unnecessary content. - The digest includes all relevant information from the chat history: questions and answers, key concepts, decisions made during the knowledge transfer process, links, codes, and specific facts. -- When handed off to another assistant to help the audience understand the content of the knowledge package, this digest is the primary source of information. -- Think of the digest as your personal memory about this knowledge share - a concise reference that helps track important facts, decisions, and context. +- This digest is the primary resource of information for the audience. + +## What the knowledge digest is NOT + +- The knowledge digest is NOT a place for planning. +- The knowledge digest is NOT a place to keep track of the audience. +- The knowledge digest is NOT a place to keep track of learning objectives. ## Knowledge digest instructions -- Provide updated content based upon information extracted from the chat history. +- Provide updated content based upon information extracted from the last message in the chat history. - Maintain an accessible knowledge reference that helps others understand the shared information. - Organize knowledge share facts and concepts. - Maintain an outline of the content at all times. If the latest message suggests a new or expanded outline, update the existing outline to reflect the new content. @@ -21,4 +26,3 @@ You, the assistant, maintain a "knowledge digest". Based on the last chat messag - Do not include objectives and outcomes in the knowledge digest. They are maintained separately. - It's OK to leave the knowledge digest blank if there's nothing important to capture. - Your output format must be: {content} - diff --git a/assistants/knowledge-transfer-assistant/assistant/tools/__init__.py b/assistants/knowledge-transfer-assistant/assistant/tools/__init__.py index 878a7f5cd..32ac218e0 100644 --- a/assistants/knowledge-transfer-assistant/assistant/tools/__init__.py +++ b/assistants/knowledge-transfer-assistant/assistant/tools/__init__.py @@ -9,8 +9,8 @@ from semantic_workbench_assistant.assistant_app import ConversationContext from assistant.data import ConversationRole -from assistant.tools.assistant_thoughts import AssistantThoughtsTools from assistant.tools.conversation_preferences import ConversationPreferencesTools +from assistant.tools.tasks import TaskTools from .information_requests import InformationRequestTools from .learning_objectives import LearningObjectiveTools @@ -34,7 +34,7 @@ def __init__(self, context: ConversationContext, role: ConversationRole): self.role = role self.tool_functions = ToolFunctions() - self.thoughts = AssistantThoughtsTools(context, role) + self.tasks = TaskTools(context, role) self.share_setup = ShareSetupTools(context, role) self.learning_objectives = LearningObjectiveTools(context, role) self.learning_outcomes = LearningOutcomeTools(context, role) @@ -51,62 +51,67 @@ def _register_coordinator_tools(self): """Register coordinator-specific tools.""" self.tool_functions.add_function( - self.thoughts.forget_thought, - "forget_thought", + self.tasks.delete_task, + "mark_task_completed", ) - # 1. Setup phase - Define audience and organize knowledge self.tool_functions.add_function( self.share_setup.update_audience, "update_audience", ) + self.tool_functions.add_function( self.share_setup.update_audience_takeaways, "update_audience_takeaways", ) + + self.tool_functions.add_function( + self.share_setup.create_invitation, + "create_invitation", + ) + # self.tool_functions.add_function( # self.share_setup.set_knowledge_organized, # "set_knowledge_organized", # ) - # 2. Brief creation phase self.tool_functions.add_function( self.share_setup.update_brief, "update_brief", ) # 3. Learning objectives phase - self.tool_functions.add_function( - self.share_setup.set_learning_intention, - "set_learning_intention", - ) - self.tool_functions.add_function( - self.learning_objectives.add_learning_objective, - "add_learning_objective", - ) - self.tool_functions.add_function( - self.learning_objectives.update_learning_objective, - "update_learning_objective", - ) - self.tool_functions.add_function( - self.learning_objectives.delete_learning_objective, - "delete_learning_objective", - ) + # self.tool_functions.add_function( + # self.share_setup.set_learning_intention, + # "set_learning_intention", + # ) + # self.tool_functions.add_function( + # self.learning_objectives.add_learning_objective, + # "add_learning_objective", + # ) + # self.tool_functions.add_function( + # self.learning_objectives.update_learning_objective, + # "update_learning_objective", + # ) + # self.tool_functions.add_function( + # self.learning_objectives.delete_learning_objective, + # "delete_learning_objective", + # ) # Individual outcome management tools - self.tool_functions.add_function( - self.learning_outcomes.add_learning_outcome, - "add_learning_outcome", - ) - self.tool_functions.add_function( - self.learning_outcomes.update_learning_outcome, - "update_learning_outcome", - ) - self.tool_functions.add_function( - self.learning_outcomes.delete_learning_outcome, - "delete_learning_outcome", - "Delete a learning outcome by outcome ID", - ) + # self.tool_functions.add_function( + # self.learning_outcomes.add_learning_outcome, + # "add_learning_outcome", + # ) + # self.tool_functions.add_function( + # self.learning_outcomes.update_learning_outcome, + # "update_learning_outcome", + # ) + # self.tool_functions.add_function( + # self.learning_outcomes.delete_learning_outcome, + # "delete_learning_outcome", + # "Delete a learning outcome by outcome ID", + # ) # 4. Ongoing support phase self.tool_functions.add_function( diff --git a/assistants/knowledge-transfer-assistant/assistant/tools/assistant_thoughts.py b/assistants/knowledge-transfer-assistant/assistant/tools/assistant_thoughts.py deleted file mode 100644 index d00956134..000000000 --- a/assistants/knowledge-transfer-assistant/assistant/tools/assistant_thoughts.py +++ /dev/null @@ -1,32 +0,0 @@ -from assistant.domain.thoughts_manager import ThoughtsManager -from assistant.logging import logger - -from .base import ToolsBase - - -class AssistantThoughtsTools(ToolsBase): - """Tools for managing assistant thoughts.""" - - async def forget_thought(self, thought: str) -> str: - """ - Delete an assistant thought. This should be called when a thought has been resolved, is a duplicate, or is no longer relevant. - Args: - thought: The thought to forget. Must be the full text of the thought as it appears in the Assistant Thoughts section. - Returns: - Message indicating success or failure - """ # noqa: E501 - try: - message = await ThoughtsManager.remove_assistant_thought( - context=self.context, - thought=thought, - ) - if not message: - message = f"Thought '{thought}' deleted successfully." - else: - message = f"Thought '{thought}' deleted, but encountered an issue: {message}" - logger.info(f"Thought deleted: {thought}") - except Exception as e: - logger.exception(f"Failed to delete thought: {e}") - message = f"Failed to delete thought: {e!s}" - return message - return message diff --git a/assistants/knowledge-transfer-assistant/assistant/tools/share_setup.py b/assistants/knowledge-transfer-assistant/assistant/tools/share_setup.py index 233760062..34800dc14 100644 --- a/assistants/knowledge-transfer-assistant/assistant/tools/share_setup.py +++ b/assistants/knowledge-transfer-assistant/assistant/tools/share_setup.py @@ -4,11 +4,14 @@ Tools for configuring knowledge shares. """ -from datetime import datetime +from datetime import UTC, datetime +from assistant import agentic +from assistant.data import InspectorTab from assistant.domain.audience_manager import AudienceManager from assistant.domain.knowledge_brief_manager import KnowledgeBriefManager from assistant.domain.share_manager import ShareManager +from assistant.notifications import Notifications from .base import ToolsBase @@ -31,6 +34,8 @@ async def update_audience(self, audience_description: str) -> str: context=self.context, audience_description=audience_description, ) + await Notifications.notify(self.context, "Audience updated.") + await Notifications.notify_all_state_update(self.context, [InspectorTab.DEBUG]) return "Audience updated successfully" except Exception as e: return f"Failed to update audience: {e!s}" @@ -50,6 +55,8 @@ async def update_audience_takeaways(self, takeaways: list[str]) -> str: context=self.context, takeaways=takeaways, ) + await Notifications.notify(self.context, "Audience takeaways updated.") + await Notifications.notify_all_state_update(self.context, [InspectorTab.BRIEF]) return "Audience takeaways updated successfully" except Exception as e: return f"Failed to update audience takeaways: {e!s}" @@ -58,7 +65,7 @@ async def update_audience_takeaways(self, takeaways: list[str]) -> str: # """ # Mark that all necessary knowledge has been captured and organized for transfer. - # This indicates that the coordinator has uploaded files, shared information through conversation, and confirmed that all necessary knowledge for the transfer has been captured. This is required before the knowledge package can move to the "Ready for Transfer" state. + # This indicates that the coordinator has uploaded files, shared information through conversation, and confirmed that all necessary knowledge for the transfer has been captured. This is required before the knowledge package can move to the "Ready for Transfer" state. # noqa: E501 # Args: # is_organized: True if knowledge is organized and ready, False to @@ -118,8 +125,9 @@ async def set_learning_intention(self, is_for_specific_outcomes: bool) -> str: try: share = await ShareManager.get_share(self.context) share.is_intended_to_accomplish_outcomes = is_for_specific_outcomes - share.updated_at = datetime.utcnow() + share.updated_at = datetime.now(UTC) await ShareManager.set_share(self.context, share) + await Notifications.notify(self.context, "Knowledge share learning intention set.") # Provide appropriate guidance based on the choice if is_for_specific_outcomes: @@ -131,3 +139,19 @@ async def set_learning_intention(self, is_for_specific_outcomes: bool) -> str: except Exception as e: return f"Failed to update learning intention: {e!s}" + + async def create_invitation(self) -> str: + """ + Create an invitation for the knowledge transfer. + + Args: + invitation_text: The text of the invitation to be sent to participants. + + Returns: + A message indicating success or failure + """ + try: + invitation = await agentic.create_invitation(self.context) + return invitation + except Exception as e: + return f"Failed to create invitation: {e!s}" diff --git a/assistants/knowledge-transfer-assistant/assistant/tools/tasks.py b/assistants/knowledge-transfer-assistant/assistant/tools/tasks.py new file mode 100644 index 000000000..65321a19a --- /dev/null +++ b/assistants/knowledge-transfer-assistant/assistant/tools/tasks.py @@ -0,0 +1,28 @@ +from assistant.data import InspectorTab +from assistant.domain.tasks_manager import TasksManager +from assistant.logging import logger +from assistant.notifications import Notifications + +from .base import ToolsBase + + +class TaskTools(ToolsBase): + async def delete_task(self, task: str) -> str: + """ + Mark a task completed. This should be called EVERY TIME a task has been completed. + Args: + task (str): The task to mark completed. Must be the full text of the task as it appears in the task list. + Returns: + Message indicating success or failure + """ + try: + await TasksManager.remove_task(self.context, task) + await Notifications.notify(self.context, "Task marked completed.", debug_data={"task": task}) + await Notifications.notify_all_state_update(self.context, [InspectorTab.DEBUG]) + message = f"Task marked completed: {task}" + logger.info(message) + return "Marked completed." + except Exception as e: + message = f"Failed to mark task completed: {e!s}" + logger.exception(message) + return message diff --git a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/debug.py b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/debug.py index cea32efa5..3af02c660 100644 --- a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/debug.py +++ b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/debug.py @@ -10,7 +10,7 @@ from assistant.data import ConversationRole, Share from assistant.domain import KnowledgeDigestManager, ShareManager, TransferManager from assistant.domain.conversation_preferences_manager import ConversationPreferencesManager -from assistant.domain.thoughts_manager import ThoughtsManager +from assistant.domain.tasks_manager import TasksManager class DebugInspector: @@ -71,15 +71,15 @@ async def _format_debug_info(self, context: ConversationContext, share: Share) - style = await ConversationPreferencesManager.get_preferred_communication_style(context) lines.append(f"- **Preferred Communication Style:** {style}") - # Assistant thoughts - lines.append("## Assistant Thoughts") - thoughts = await ThoughtsManager.get_assistant_thoughts(context) - if thoughts: - for thought in thoughts: + # Assistant tasks + lines.append("## Assistant task list") + tasks = await TasksManager.get_tasks(context) + if tasks: + for thought in tasks: lines.append(f"- {thought}") lines.append("") else: - lines.append("_No assistant thoughts recorded yet._") + lines.append("_No tasks recorded yet._") # knowledge digest try: diff --git a/assistants/knowledge-transfer-assistant/tests/test_share_tools.py b/assistants/knowledge-transfer-assistant/tests/test_share_tools.py index 0443621d6..ed8e71c6f 100644 --- a/assistants/knowledge-transfer-assistant/tests/test_share_tools.py +++ b/assistants/knowledge-transfer-assistant/tests/test_share_tools.py @@ -7,7 +7,7 @@ import openai_client import pytest -from assistant.agentic.detect_information_requests import detect_information_request_needs +from assistant.agentic.detect_information_request_needs import detect_information_request_needs from assistant.data import ConversationRole from assistant.tools import ShareTools from semantic_workbench_assistant.assistant_app import ConversationContext From 4d855a5ba98a615cdf8381ed22f1e2ac2676d5fd Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Mon, 11 Aug 2025 09:56:31 -0700 Subject: [PATCH 18/21] Adds to_dict method to context for debug output serialization. --- .../assistant_app/context.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) 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 78a6952cf..3f18a58f7 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 @@ -3,9 +3,10 @@ import logging import pathlib import uuid +from collections.abc import AsyncGenerator, AsyncIterator from contextlib import asynccontextmanager from dataclasses import dataclass, field -from typing import Any, AsyncGenerator, AsyncIterator +from typing import Any import httpx import semantic_workbench_api_model @@ -55,6 +56,18 @@ def for_conversation( httpx_client=self._httpx_client, ) + def to_dict(self) -> dict[str, Any]: + return { + "id": self.id, + "title": self.title, + "assistant": { + "id": self.assistant.id, + "name": self.assistant.name, + "_assistant_service_id": self.assistant._assistant_service_id, + "_template_id": self.assistant._template_id, + }, + } + @property def _conversation_client( self, From eb1ceac902126a1f2ca961012f8f2f32d1701c1d Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Mon, 11 Aug 2025 09:57:00 -0700 Subject: [PATCH 19/21] Serialization updates. --- .../openai-client/openai_client/__init__.py | 8 ++- .../openai-client/openai_client/logging.py | 28 +++++--- .../openai-client/openai_client/tools.py | 72 +++++++++---------- 3 files changed, 61 insertions(+), 47 deletions(-) diff --git a/libraries/python/openai-client/openai_client/__init__.py b/libraries/python/openai-client/openai_client/__init__.py index 56a64cb12..10448442f 100644 --- a/libraries/python/openai-client/openai_client/__init__.py +++ b/libraries/python/openai-client/openai_client/__init__.py @@ -3,7 +3,11 @@ from .client import ( create_client, ) -from .completion import completion_structured, message_content_from_completion, message_from_completion +from .completion import ( + completion_structured, + message_content_from_completion, + message_from_completion, +) from .config import ( AzureOpenAIApiKeyAuthConfig, AzureOpenAIAzureIdentityAuthConfig, @@ -22,6 +26,7 @@ add_serializable_data, extra_data, make_completion_args_serializable, + serializable, ) from .messages import ( convert_from_completion_messages, @@ -74,6 +79,7 @@ "num_tokens_from_tools_and_messages", "OpenAIServiceConfig", "OpenAIRequestConfig", + "serializable", "ServiceConfig", "truncate_messages_for_logging", "validate_completion", diff --git a/libraries/python/openai-client/openai_client/logging.py b/libraries/python/openai-client/openai_client/logging.py index ca271570f..28e61c036 100644 --- a/libraries/python/openai-client/openai_client/logging.py +++ b/libraries/python/openai-client/openai_client/logging.py @@ -10,7 +10,9 @@ from pydantic import BaseModel -def make_completion_args_serializable(completion_args: dict[str, Any]) -> dict[str, Any]: +def make_completion_args_serializable( + completion_args: dict[str, Any], +) -> dict[str, Any]: """ We put the completion args into logs and messages, so it's important that they are serializable. This function returns a copy of the completion args @@ -38,6 +40,9 @@ def convert_to_serializable(data: Any) -> Any: """ if isinstance(data, BaseModel): return data.model_dump() + elif inspect.isclass(data) and issubclass(data, BaseModel): + # Handle Pydantic model classes (not instances) + return data.__name__ elif isinstance(data, dict): return {key: convert_to_serializable(value) for key, value in data.items()} elif isinstance(data, list): @@ -58,26 +63,29 @@ def default(self, o): return super().default(o) -def add_serializable_data(data: Any) -> dict[str, Any]: +def serializable(data: Any) -> dict[str, Any]: """ - Helper function to use when adding extra data to log messages. Data will - attempt to be put into a serializable format. + Convert data to a serializable format for logging or other purposes. """ - extra = {} - - # Convert to serializable. data = convert_to_serializable(data) - - # Ensure data is a JSON-serializable object. try: data = json.loads(json.dumps(data, cls=CustomEncoder)) except Exception as e: data = str(e) + return data + +def add_serializable_data(data: Any) -> dict[str, Any]: + """ + Helper function to use when adding extra data to log messages. Data will + attempt to be put into a serializable format. + """ + extra = {} + data = serializable(data) if data: extra["data"] = data - return extra +# Helpful alias extra_data = add_serializable_data diff --git a/libraries/python/openai-client/openai_client/tools.py b/libraries/python/openai-client/openai_client/tools.py index 451753be1..e4a6b623d 100644 --- a/libraries/python/openai-client/openai_client/tools.py +++ b/libraries/python/openai-client/openai_client/tools.py @@ -1,8 +1,9 @@ import ast import inspect import json +from collections.abc import Callable, Iterable from dataclasses import dataclass -from typing import Any, Callable, Iterable +from typing import Any from openai import ( NOT_GIVEN, @@ -15,13 +16,18 @@ ParsedChatCompletion, ParsedFunctionToolCall, ) +from openai.types.shared_params.function_definition import FunctionDefinition from pydantic import BaseModel, create_model from pydantic.fields import FieldInfo from . import logger from .completion import assistant_message_from_completion from .errors import CompletionError, validate_completion -from .logging import add_serializable_data, make_completion_args_serializable +from .logging import ( + add_serializable_data, + make_completion_args_serializable, + serializable, +) def to_string(value: Any) -> str: @@ -33,7 +39,7 @@ def to_string(value: Any) -> str: return "Function executed successfully." elif isinstance(value, str): return value - elif isinstance(value, (int, float)): + elif isinstance(value, int | float): return str(value) elif isinstance(value, dict): return json.dumps(value) @@ -58,12 +64,7 @@ def function_list_to_tool_choice( if not functions: return None return [ - ChatCompletionToolParam( - **{ - "type": "function", - "function": {"name": name}, - } - ) + ChatCompletionToolParam(type="function", function={"name": name}) for name in functions ] or None @@ -98,10 +99,12 @@ def __init__( description or inspect.getdoc(fn) or self.name.replace("_", " ").title() ) - def parameters(self, exclude: list[str] = []) -> list[Parameter]: + def parameters(self, exclude: list[str] | None = None) -> list[Parameter]: """ This function's parameters and their default values. """ + if exclude is None: + exclude = [] parameters = dict(inspect.signature(self.fn).parameters) for param_name in exclude: del parameters[param_name] @@ -163,7 +166,7 @@ def schema(self, strict: bool = True) -> dict[str, Any]: # Remove title attribute from all properties (not allowed by the Chat # Completions API). properties = parameters_schema["properties"] - for property_key in properties.keys(): + for property_key in properties: if "title" in properties[property_key]: del properties[property_key]["title"] @@ -279,19 +282,21 @@ def get_function(self, name: str) -> ToolFunction | None: return self.function_map.get(name) def get_functions(self) -> list[ToolFunction]: - return [function for function in self.function_map.values()] + return list(self.function_map.values()) async def execute_function( self, name: str, args: tuple = (), - kwargs: dict[str, Any] = {}, + kwargs: dict[str, Any] | None = None, string_response: bool = False, ) -> Any: """ Run a function from the ToolFunctions list by name. If string_response is True, the function return value will be converted to a string. """ + if kwargs is None: + kwargs = {} function = self.get_function(name) if not function: raise ValueError(f"Function {name} not found in registry.") @@ -306,7 +311,7 @@ async def execute_function_string( try: function, args, kwargs = self.parse_function_string(function_string) except ValueError as e: - raise ValueError(f"{e} Type: `/help` for more information.") + raise ValueError(f"{e} Type: `/help` for more information.") from e if not function: raise ValueError( "Function not found in registry. Type: `/help` for more information." @@ -334,8 +339,10 @@ def parse_fn_string( # Parse the string into an AST (Abstract Syntax Tree) try: tree = ast.parse(function_string) - except SyntaxError: - raise ValueError("Invalid function call. Please check your syntax.") + except SyntaxError as err: + raise ValueError( + "Invalid function call. Please check your syntax." + ) from err # Ensure the tree contains exactly one expression (the function call) if not ( @@ -367,7 +374,7 @@ def eval_node(node): elif isinstance(node, ast.Dict): return { eval_node(key): eval_node(value) - for key, value in zip(node.keys, node.values) + for key, value in zip(node.keys, node.values, strict=False) } elif isinstance(node, ast.Name): return ( @@ -414,10 +421,7 @@ def chat_completion_tools(self) -> list[ChatCompletionToolParam] | NotGiven: """ tools = [ ChatCompletionToolParam( - **{ - "type": "function", - "function": func.schema(), - } + type="function", function=FunctionDefinition(**func.schema()) ) for func in self.function_map.values() ] @@ -456,11 +460,11 @@ async def execute_tool_call( {"tool_call_id": tool_call.id, "content": value} ), ) - return { - "role": "tool", - "content": value, - "tool_call_id": tool_call.id, - } + return { + "role": "tool", + "content": value, + "tool_call_id": tool_call.id, + } else: logger.error(f"Function not found: {function.name}") return None @@ -470,7 +474,7 @@ async def complete_with_tool_calls( async_client: AsyncOpenAI, completion_args: dict[str, Any], tool_functions: ToolFunctions, - metadata: dict[str, Any] = {}, + metadata: dict[str, Any] | None = None, max_tool_call_rounds: int = 5, # Adding a parameter to limit the maximum number of rounds ) -> tuple[ParsedChatCompletion | None, list[ChatCompletionMessageParam]]: """ @@ -488,6 +492,8 @@ async def complete_with_tool_calls( - metadata: Metadata to be added to the completion response. - max_tool_call_rounds: Maximum number of tool call rounds to prevent infinite loops (default: 5) """ + if metadata is None: + metadata = {} messages: list[ChatCompletionMessageParam] = completion_args.get("messages", []) all_new_messages: list[ChatCompletionMessageParam] = [] current_completion = None @@ -502,23 +508,17 @@ async def complete_with_tool_calls( # or we hit the maximum number of rounds while rounds < max_tool_call_rounds: rounds += 1 - - # Prepare arguments for this round - current_args = {**completion_args, "messages": [*messages, *all_new_messages]} - - # Log the completion request round_description = f"round {rounds}" - if rounds == 1: - round_description = "pre-tool" + current_args = {**completion_args, "messages": [*messages, *all_new_messages]} logger.debug( f"Completion call ({round_description}).", extra=add_serializable_data( make_completion_args_serializable(current_args) ), ) - metadata[f"completion_request ({round_description})"] = ( - make_completion_args_serializable(current_args) + metadata[f"completion_request ({round_description})"] = serializable( + current_args ) # Make the completion call From 1c75e1d1b83b513a338b99a473f0cdafc912cc95 Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Mon, 11 Aug 2025 09:57:10 -0700 Subject: [PATCH 20/21] Spelling. --- libraries/python/openai-client/.vscode/settings.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/libraries/python/openai-client/.vscode/settings.json b/libraries/python/openai-client/.vscode/settings.json index 197b82a0f..9adaf355b 100644 --- a/libraries/python/openai-client/.vscode/settings.json +++ b/libraries/python/openai-client/.vscode/settings.json @@ -36,7 +36,7 @@ "**/.data": true, "**/__pycache__": true }, - // For use with optional extension: "streetsidesoftware.code-spell-checker" + // For use with optional extension: "streetsidesoftware.code-spell-checker" "cSpell.ignorePaths": [ ".venv", "node_modules", @@ -48,6 +48,7 @@ "addopts", "asctime", "contentsafety", + "elts", "levelname", "openai", "Pydantic", @@ -56,4 +57,4 @@ "testpaths", "tiktoken" ] -} +} \ No newline at end of file From 8d04b76b8c63cf5631c56388274714d59219b883 Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Wed, 13 Aug 2025 13:35:36 -0700 Subject: [PATCH 21/21] Everything kinda working. --- .../assistant/agentic/act.py | 207 ++++++++++++++++++ .../assistant/agentic/create_invitation.py | 2 +- .../agentic/detect_audience_and_takeaways.py | 8 +- .../agentic/detect_coordinator_actions.py | 2 +- .../agentic/detect_knowledge_package_gaps.py | 7 +- .../assistant/agentic/focus.py | 2 +- .../assistant/agentic/respond.py | 4 +- .../assistant/agentic/update_digest.py | 7 +- .../assistant/assistant.py | 41 ++-- .../assistant/data.py | 40 +++- .../domain/knowledge_brief_manager.py | 6 +- .../assistant/domain/tasks_manager.py | 21 +- .../assistant/notifications.py | 8 +- .../assistant/prompt_utils.py | 4 +- .../assistant/storage.py | 43 +++- .../text_includes/actor_instructions.md | 57 +++++ .../text_includes/coordinator_instructions.md | 84 +++++-- .../text_includes/detect_audience.md | 20 +- .../detect_coordinator_actions.md | 2 +- .../detect_knowledge_package_gaps.md | 10 +- .../knowledge_digest_instructions.txt | 11 +- .../text_includes/update_knowledge_digest.md | 11 +- .../welcome_message_generation.txt | 8 +- .../assistant/tools/__init__.py | 20 +- .../assistant/tools/learning_outcomes.py | 136 +++++++++++- .../assistant/tools/progress_tracking.py | 123 +---------- .../assistant/tools/share_setup.py | 13 +- .../assistant/tools/tasks.py | 75 ++++++- .../assistant/ui_tabs/common.py | 23 +- .../assistant/ui_tabs/debug.py | 7 +- .../docs/design/control_options.md | 52 +++++ 31 files changed, 799 insertions(+), 255 deletions(-) create mode 100644 assistants/knowledge-transfer-assistant/assistant/agentic/act.py create mode 100644 assistants/knowledge-transfer-assistant/assistant/text_includes/actor_instructions.md create mode 100644 assistants/knowledge-transfer-assistant/docs/design/control_options.md diff --git a/assistants/knowledge-transfer-assistant/assistant/agentic/act.py b/assistants/knowledge-transfer-assistant/assistant/agentic/act.py new file mode 100644 index 000000000..0f98447e1 --- /dev/null +++ b/assistants/knowledge-transfer-assistant/assistant/agentic/act.py @@ -0,0 +1,207 @@ +import time +from textwrap import dedent +from typing import Any, ClassVar + +import openai_client +from assistant_extensions.attachments import AttachmentsExtension +from openai import BaseModel +from openai.types.chat import ( + ChatCompletionAssistantMessageParam, +) +from openai_client import num_tokens_from_messages +from openai_client.completion import assistant_message_from_completion +from openai_client.errors import CompletionError +from openai_client.tools import complete_with_tool_calls +from pydantic import ConfigDict, Field +from semantic_workbench_api_model.workbench_model import ( + MessageType, + NewConversationMessage, +) +from semantic_workbench_assistant.assistant_app import ( + ConversationContext, +) + +from assistant.config import assistant_config +from assistant.domain.share_manager import ShareManager +from assistant.logging import logger +from assistant.prompt_utils import ( + ContextSection, + ContextStrategy, + Instructions, + Prompt, + TokenBudget, + add_context_to_prompt, +) +from assistant.tools import ShareTools +from assistant.utils import load_text_include + + +class ActorOutput(BaseModel): + """ + Attributes: + response: The response from the assistant. + """ + + accomplishments: str = Field( + description="A summary of all the actions performed and their results.", + ) + user_information_requests: list[str] = Field( + description="A list of all the information requests that were made during the conversation.", + ) + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + +async def act( + context: ConversationContext, + attachments_extension: AttachmentsExtension, + metadata: dict[str, Any], +) -> ChatCompletionAssistantMessageParam | None: + """ + Work, work, work, work, work... + """ + + if "debug" not in metadata: + metadata["debug"] = {} + + config = await assistant_config.get(context.assistant) + model = config.request_config.openai_model + role = await ShareManager.get_conversation_role(context) + metadata["debug"]["role"] = role + token_budget = TokenBudget(config.request_config.max_tokens) + + instructions = load_text_include("detect_knowledge_package_gaps.md") + instructions = Instructions(instructions) + prompt = Prompt( + instructions=instructions, + context_strategy=ContextStrategy.MULTI, + ) + sections = [ + ContextSection.KNOWLEDGE_INFO, + ContextSection.KNOWLEDGE_BRIEF, + ContextSection.TARGET_AUDIENCE, + # ContextSection.LEARNING_OBJECTIVES, + ContextSection.KNOWLEDGE_DIGEST, + ContextSection.INFORMATION_REQUESTS, + # ContextSection.SUGGESTED_NEXT_ACTIONS, + ContextSection.ATTACHMENTS, + ContextSection.TASKS, + ContextSection.COORDINATOR_CONVERSATION, + ] + await add_context_to_prompt( + prompt, + context=context, + role=role, + model=model, + token_limit=config.request_config.max_tokens, + attachments_extension=attachments_extension, + attachments_config=config.attachments_config, + attachments_in_system_message=False, + include=sections, + ) + + # Calculate token count for all prompt so far. + completion_messages = prompt.messages() + token_budget.add( + num_tokens_from_messages( + model=model, + messages=completion_messages, + ) + ) + + content = "" + async with openai_client.create_client(config.service_config) as client: + try: + completion_args = { + "messages": completion_messages, + "model": model, + "max_tokens": config.request_config.response_tokens, + "temperature": 0.7, + "response_format": ActorOutput, + } + + share_tools = ShareTools(context, role) + response_start_time = time.time() + completion_response, _ = await complete_with_tool_calls( + async_client=client, + completion_args=completion_args, + tool_functions=share_tools.tool_functions, + metadata=metadata["debug"], + max_tool_call_rounds=32, + ) + response_end_time = time.time() + footer_items = [] + + # Add the token usage message to the footer items + if completion_response: + response_tokens = completion_response.usage.completion_tokens if completion_response.usage else 0 + request_tokens = token_budget.used + footer_items.append( + get_token_usage_message( + max_tokens=config.request_config.max_tokens, + total_tokens=request_tokens + response_tokens, + request_tokens=request_tokens, + completion_tokens=response_tokens, + ) + ) + + footer_items.append(get_response_duration_message(response_end_time - response_start_time)) + metadata["footer_items"] = footer_items + return assistant_message_from_completion(completion_response) if completion_response else None + + except CompletionError as e: + logger.exception(f"Exception occurred calling OpenAI chat completion: {e}") + metadata["debug"]["error"] = str(e) + if isinstance(e.body, dict) and "message" in e.body: + content = e.body.get("message", e.message) + elif e.message: + content = e.message + else: + content = "An error occurred while processing your request." + await context.send_messages( + NewConversationMessage( + content=content, + message_type=MessageType.notice, + metadata=metadata, + ) + ) + return + + +def get_formatted_token_count(tokens: int) -> str: + # if less than 1k, return the number of tokens + # if greater than or equal to 1k, return the number of tokens in k + # use 1 decimal place for k + # drop the decimal place if the number of tokens in k is a whole number + if tokens < 1000: + return str(tokens) + else: + tokens_in_k = tokens / 1000 + if tokens_in_k.is_integer(): + return f"{int(tokens_in_k)}k" + else: + return f"{tokens_in_k:.1f}k" + + +def get_token_usage_message( + max_tokens: int, + total_tokens: int, + request_tokens: int, + completion_tokens: int, +) -> str: + """ + Generate a display friendly message for the token usage, to be added to the footer items. + """ + + return dedent(f""" + Tokens used: {get_formatted_token_count(total_tokens)} + ({get_formatted_token_count(request_tokens)} in / {get_formatted_token_count(completion_tokens)} out) + of {get_formatted_token_count(max_tokens)} ({int(total_tokens / max_tokens * 100)}%) + """).strip() + + +def get_response_duration_message(response_duration: float) -> str: + """ + Generate a display friendly message for the response duration, to be added to the footer items. + """ + + return f"Response time: {response_duration:.2f} seconds" diff --git a/assistants/knowledge-transfer-assistant/assistant/agentic/create_invitation.py b/assistants/knowledge-transfer-assistant/assistant/agentic/create_invitation.py index ea6634352..9f8390b79 100644 --- a/assistants/knowledge-transfer-assistant/assistant/agentic/create_invitation.py +++ b/assistants/knowledge-transfer-assistant/assistant/agentic/create_invitation.py @@ -87,5 +87,5 @@ async def create_invitation(context: ConversationContext) -> str: logger.exception(f"Failed to make OpenIA call: {e}") debug["error"] = str(e) - logger.debug(f"{__name__}: {debug}") + # logger.debug(f"{__name__}: {debug}") return "Failed to generate invitation." diff --git a/assistants/knowledge-transfer-assistant/assistant/agentic/detect_audience_and_takeaways.py b/assistants/knowledge-transfer-assistant/assistant/agentic/detect_audience_and_takeaways.py index 1335321a3..4a0e56989 100644 --- a/assistants/knowledge-transfer-assistant/assistant/agentic/detect_audience_and_takeaways.py +++ b/assistants/knowledge-transfer-assistant/assistant/agentic/detect_audience_and_takeaways.py @@ -6,7 +6,7 @@ from semantic_workbench_assistant.assistant_app import ConversationContext from assistant.config import assistant_config -from assistant.data import InspectorTab +from assistant.data import InspectorTab, NewTaskInfo from assistant.domain.share_manager import ShareManager from assistant.domain.tasks_manager import TasksManager from assistant.logging import logger @@ -67,8 +67,8 @@ class Output(BaseModel): """Output class to hold the generated tasks.""" tasks: list[ - str - ] # Tasks related to the audience and takeaways. One task per item. If there are no tasks to be done due to this information, this will be an empty list. #noqa: E501 + NewTaskInfo + ] # Tasks related to the audience and takeaways. One task per item. If there are no tasks to be added, this will be an empty list. #noqa: E501 # Chat completion async with openai_client.create_client(config.service_config) as client: @@ -110,4 +110,4 @@ class Output(BaseModel): logger.exception(f"Failed to make OpenIA call: {e}") debug["error"] = str(e) - logger.debug(f"{__name__}: {debug}") + # logger.debug(f"{__name__}: {debug}") diff --git a/assistants/knowledge-transfer-assistant/assistant/agentic/detect_coordinator_actions.py b/assistants/knowledge-transfer-assistant/assistant/agentic/detect_coordinator_actions.py index 459bd1e52..ab83a3a08 100644 --- a/assistants/knowledge-transfer-assistant/assistant/agentic/detect_coordinator_actions.py +++ b/assistants/knowledge-transfer-assistant/assistant/agentic/detect_coordinator_actions.py @@ -106,4 +106,4 @@ class Output(BaseModel): logger.exception(f"Failed to make OpenIA call: {e}") debug["error"] = str(e) - logger.debug(f"{__name__}: {debug}") + # logger.debug(f"{__name__}: {debug}") diff --git a/assistants/knowledge-transfer-assistant/assistant/agentic/detect_knowledge_package_gaps.py b/assistants/knowledge-transfer-assistant/assistant/agentic/detect_knowledge_package_gaps.py index 6fa8acf9f..51e8c6d4a 100644 --- a/assistants/knowledge-transfer-assistant/assistant/agentic/detect_knowledge_package_gaps.py +++ b/assistants/knowledge-transfer-assistant/assistant/agentic/detect_knowledge_package_gaps.py @@ -6,7 +6,7 @@ from semantic_workbench_assistant.assistant_app import ConversationContext from assistant.config import assistant_config -from assistant.data import InspectorTab +from assistant.data import InspectorTab, NewTaskInfo from assistant.domain.share_manager import ShareManager from assistant.domain.tasks_manager import TasksManager from assistant.logging import convert_to_serializable, logger @@ -91,7 +91,8 @@ class Output(BaseModel): if response and response.choices and response.choices[0].message.parsed: output: Output = response.choices[0].message.parsed if output.gaps: - tasks = [f"Collect the following information: {gap.strip()}" for gap in output.gaps if gap.strip()] + task_contents = [f"Ask the user about: {gap.strip()}" for gap in output.gaps if gap.strip()] + tasks = [NewTaskInfo(content=content) for content in task_contents] await TasksManager.add_tasks(context, tasks) await Notifications.notify( context, f"Added {len(tasks)} tasks related to the knowledge content.", debug_data=debug @@ -111,4 +112,4 @@ class Output(BaseModel): logger.exception(f"Failed to make OpenIA call: {e}") debug["error"] = str(e) - logger.debug(f"{__name__}: {debug}") + # logger.debug(f"{__name__}: {debug}") diff --git a/assistants/knowledge-transfer-assistant/assistant/agentic/focus.py b/assistants/knowledge-transfer-assistant/assistant/agentic/focus.py index 1ee33776c..626163233 100644 --- a/assistants/knowledge-transfer-assistant/assistant/agentic/focus.py +++ b/assistants/knowledge-transfer-assistant/assistant/agentic/focus.py @@ -95,4 +95,4 @@ class Output(BaseModel): logger.exception(f"Failed to make OpenIA call: {e}") debug["error"] = str(e) - logger.debug(f"{__name__}: {debug}") + # logger.debug(f"{__name__}: {debug}") diff --git a/assistants/knowledge-transfer-assistant/assistant/agentic/respond.py b/assistants/knowledge-transfer-assistant/assistant/agentic/respond.py index 00cabfe5b..837fd071b 100644 --- a/assistants/knowledge-transfer-assistant/assistant/agentic/respond.py +++ b/assistants/knowledge-transfer-assistant/assistant/agentic/respond.py @@ -172,7 +172,7 @@ async def respond_to_conversation( ContextSection.KNOWLEDGE_INFO, ContextSection.KNOWLEDGE_BRIEF, ContextSection.TARGET_AUDIENCE, - ContextSection.LEARNING_OBJECTIVES, + # ContextSection.LEARNING_OBJECTIVES, ContextSection.KNOWLEDGE_DIGEST, ContextSection.INFORMATION_REQUESTS, # ContextSection.SUGGESTED_NEXT_ACTIONS, @@ -330,7 +330,7 @@ async def respond_to_conversation( completion_args=completion_args, tool_functions=share_tools.tool_functions, metadata=metadata["debug"], - max_tool_call_rounds=16, + max_tool_call_rounds=32, ) response_end_time = time.time() footer_items = [] diff --git a/assistants/knowledge-transfer-assistant/assistant/agentic/update_digest.py b/assistants/knowledge-transfer-assistant/assistant/agentic/update_digest.py index 5a0f54c23..180fa440a 100644 --- a/assistants/knowledge-transfer-assistant/assistant/agentic/update_digest.py +++ b/assistants/knowledge-transfer-assistant/assistant/agentic/update_digest.py @@ -69,6 +69,11 @@ async def update_digest(context: ConversationContext, attachments_extension: Att # Extract the knowledge digest content from the response. content = response.choices[0].message.content or "" + + if content == "": + logger.info("Knowledge digest is already up to date, no changes made.", extra={"debug": debug}) + return + match = re.search(r"(.*?)", content, re.DOTALL) digest_content = match.group(1).strip() if match else content if not digest_content: @@ -93,4 +98,4 @@ async def update_digest(context: ConversationContext, attachments_extension: Att debug["error"] = str(e) logger.exception(f"Failed to make OpenIA call: {e}", extra={"debug": debug}) - logger.debug(f"{__name__}: {debug}") + # logger.debug(f"{__name__}: {debug}") diff --git a/assistants/knowledge-transfer-assistant/assistant/assistant.py b/assistants/knowledge-transfer-assistant/assistant/assistant.py index d834b4f09..a88ec0189 100644 --- a/assistants/knowledge-transfer-assistant/assistant/assistant.py +++ b/assistants/knowledge-transfer-assistant/assistant/assistant.py @@ -285,35 +285,42 @@ async def on_message_created( if message.message_type == MessageType.chat and is_user_message: async with context.set_status("pondering..."): - # Process user message with background assistants. if role == ConversationRole.COORDINATOR: # Update knowledge digest. digest_task = asyncio.create_task(agentic.update_digest(context, attachments_extension)) digest_task.add_done_callback(lambda t: t.exception() if t.done() and t.exception() else None) - # Detect audience and audience takeaways + parallel_tasks = [] + + # Solicit audience and audience takeaway tasks. audience_task = asyncio.create_task( agentic.detect_audience_and_takeaways(context, attachments_extension) ) audience_task.add_done_callback(lambda t: t.exception() if t.done() and t.exception() else None) - - # Detect knowledge gaps - gaps_task = asyncio.create_task( - agentic.detect_knowledge_package_gaps(context, attachments_extension) - ) - gaps_task.add_done_callback(lambda t: t.exception() if t.done() and t.exception() else None) + parallel_tasks.append(audience_task) + + # Solicit knowledge package gap tasks. + if share.digest and share.tasks: + # Let's deal with other pending tasks first, though. + pending_tasks = [task for task in share.tasks if task.status == "pending"] + if not pending_tasks: + gaps_task = asyncio.create_task( + agentic.detect_knowledge_package_gaps(context, attachments_extension) + ) + gaps_task.add_done_callback(lambda t: t.exception() if t.done() and t.exception() else None) + parallel_tasks.append(gaps_task) # Detect coordinator actions - coordinator_actions_task = asyncio.create_task( - agentic.detect_coordinator_actions(context, attachments_extension) - ) - coordinator_actions_task.add_done_callback( - lambda t: t.exception() if t.done() and t.exception() else None - ) + # coordinator_actions_task = asyncio.create_task( + # agentic.detect_coordinator_actions(context, attachments_extension) + # ) + # coordinator_actions_task.add_done_callback( + # lambda t: t.exception() if t.done() and t.exception() else None + # ) + + # Run all of the tasks in parallel and wait for their return. + await asyncio.gather(*parallel_tasks, return_exceptions=True) - # Once all the other agents have had their turn, let's focus the conversation. - # background_tasks = [audience_task, gaps_task, coordinator_actions_task] - # await asyncio.gather(*background_tasks, return_exceptions=True) # task5 = asyncio.create_task(agentic.focus(context, attachments_extension)) # task5.add_done_callback(lambda t: t.exception() if t.done() and t.exception() else None) diff --git a/assistants/knowledge-transfer-assistant/assistant/data.py b/assistants/knowledge-transfer-assistant/assistant/data.py index 8f535c815..ca1385f1b 100644 --- a/assistants/knowledge-transfer-assistant/assistant/data.py +++ b/assistants/knowledge-transfer-assistant/assistant/data.py @@ -173,6 +173,44 @@ class ShareLog(BaseModel): entries: list[LogEntry] = Field(default_factory=list) # Chronological list of log entries +class TaskStatus(str, Enum): + """ + Enum for the status of a task. + """ + + PENDING = "pending" # Task not yet started + IN_PROGRESS = "in_progress" # Currently working on (limit to ONE task at a time) + COMPLETED = "completed" # Task finished successfully + CANCELLED = "cancelled" # Task no longer needed + + +class TaskPriority(str, Enum): + """ + Enum for the priority of a task. + """ + + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + + +class NewTaskInfo(BaseModel): + """ + A class to represent a new task to be added. + """ + + content: str # Description of the task + priority: TaskPriority = TaskPriority.MEDIUM # Default priority is 'medium' + status: TaskStatus = TaskStatus.PENDING # Default status is 'pending' + + +class TaskInfo(BaseModel): + task_id: str + content: str + status: TaskStatus + priority: TaskPriority + + class Share(BaseModel): share_id: str coordinator_conversation_id: str | None = None @@ -197,7 +235,7 @@ class Share(BaseModel): next_learning_actions: list[str] = Field(default_factory=list) # knowledge_organized: bool = False requests: list[InformationRequest] = Field(default_factory=list) - tasks: list[str] = Field(default_factory=list) + tasks: list[TaskInfo] = Field(default_factory=list) log: ShareLog | None = Field(default_factory=lambda: ShareLog()) diff --git a/assistants/knowledge-transfer-assistant/assistant/domain/knowledge_brief_manager.py b/assistants/knowledge-transfer-assistant/assistant/domain/knowledge_brief_manager.py index 220128cbf..75aa2959b 100644 --- a/assistants/knowledge-transfer-assistant/assistant/domain/knowledge_brief_manager.py +++ b/assistants/knowledge-transfer-assistant/assistant/domain/knowledge_brief_manager.py @@ -22,7 +22,7 @@ async def get_knowledge_brief( async def update_knowledge_brief( context: ConversationContext, title: str, - description: str, + content: str, timeline: str | None = None, ) -> KnowledgeBrief: share_id = await ShareManager.get_share_id(context) @@ -30,7 +30,7 @@ async def update_knowledge_brief( brief = KnowledgeBrief( title=title, - content=description, + content=content, timeline=timeline, created_by=current_user_id, updated_by=current_user_id, @@ -56,7 +56,7 @@ async def update_knowledge_brief( message=f"Created brief: {title}", ) - await Notifications.notify_all(context, share_id, "Knowledge brief has been updated") + await Notifications.notify_all(context, share_id, "Knowledge brief has been updated", {"content": content}) await Notifications.notify_all_state_update(context, [InspectorTab.BRIEF]) return brief diff --git a/assistants/knowledge-transfer-assistant/assistant/domain/tasks_manager.py b/assistants/knowledge-transfer-assistant/assistant/domain/tasks_manager.py index 3d630fe70..86d717eb9 100644 --- a/assistants/knowledge-transfer-assistant/assistant/domain/tasks_manager.py +++ b/assistants/knowledge-transfer-assistant/assistant/domain/tasks_manager.py @@ -1,5 +1,6 @@ from semantic_workbench_assistant.assistant_app import ConversationContext +from assistant.data import NewTaskInfo, TaskInfo from assistant.domain.share_manager import ShareManager from assistant.storage import ShareStorage @@ -8,7 +9,7 @@ class TasksManager: @staticmethod async def get_tasks( context: ConversationContext, - ) -> list[str]: + ) -> list[TaskInfo]: share_id = await ShareManager.get_share_id(context) if not share_id: return [] @@ -17,27 +18,37 @@ async def get_tasks( @staticmethod async def add_tasks( context: ConversationContext, - tasks: list[str], + tasks: list[NewTaskInfo], ) -> None: share_id = await ShareManager.get_share_id(context) if not share_id: return ShareStorage.add_tasks(share_id, tasks) + @staticmethod + async def update_task( + context: ConversationContext, + task: TaskInfo, + ) -> None: + share_id = await ShareManager.get_share_id(context) + if not share_id: + return + ShareStorage.update_task(share_id, task) + @staticmethod async def remove_task( context: ConversationContext, - task: str, + task_id: str, ) -> None: share_id = await ShareManager.get_share_id(context) if not share_id: return - ShareStorage.remove_task(share_id, task) + ShareStorage.remove_task(share_id, task_id) @staticmethod async def set_task_list( context: ConversationContext, - tasks: list[str], + tasks: list[TaskInfo], ) -> None: share_id = await ShareManager.get_share_id(context) if not share_id: diff --git a/assistants/knowledge-transfer-assistant/assistant/notifications.py b/assistants/knowledge-transfer-assistant/assistant/notifications.py index 927a063ed..baa0c592f 100644 --- a/assistants/knowledge-transfer-assistant/assistant/notifications.py +++ b/assistants/knowledge-transfer-assistant/assistant/notifications.py @@ -68,9 +68,14 @@ async def notify_self_and_other( logger.error(f"Failed to notify conversation {target_id}: {e}") @staticmethod - async def notify_all(context: ConversationContext, share_id: str, message: str) -> None: + async def notify_all( + context: ConversationContext, share_id: str, message: str, debug_data: dict[str, Any] | None = None + ) -> None: """Send text message notification to all knowledge transfer conversations.""" + if debug_data is None: + debug_data = {} + share = await ShareManager.get_share(context) if not share: return @@ -88,6 +93,7 @@ async def notify_all(context: ConversationContext, share_id: str, message: str) NewConversationMessage( content=message, message_type=MessageType.notice, + debug_data=debug_data, ) ) except Exception as e: diff --git a/assistants/knowledge-transfer-assistant/assistant/prompt_utils.py b/assistants/knowledge-transfer-assistant/assistant/prompt_utils.py index 3a9214ab9..c596f09c2 100644 --- a/assistants/knowledge-transfer-assistant/assistant/prompt_utils.py +++ b/assistants/knowledge-transfer-assistant/assistant/prompt_utils.py @@ -224,7 +224,7 @@ async def add_context_to_prompt( if ContextSection.TASKS in include: tasks = await TasksManager.get_tasks(context) if tasks: - tasks_data = json.dumps(tasks) + tasks_data = json.dumps([task.model_dump() for task in tasks]) prompt.contexts.append( DataContext( "Task List", @@ -278,7 +278,6 @@ async def add_context_to_prompt( DataContext( "Target Audience", audience_context, - "Description of the intended audience for this knowledge transfer.", ) ) @@ -425,7 +424,6 @@ class CoordinatorMessageList(BaseModel): DataContext( "Message History", coordinator_message_list_data, - "The conversation so far.", ) ) else: diff --git a/assistants/knowledge-transfer-assistant/assistant/storage.py b/assistants/knowledge-transfer-assistant/assistant/storage.py index ba360d3ee..78661a23d 100644 --- a/assistants/knowledge-transfer-assistant/assistant/storage.py +++ b/assistants/knowledge-transfer-assistant/assistant/storage.py @@ -1,4 +1,5 @@ import pathlib +import uuid from datetime import datetime from typing import Any @@ -22,8 +23,10 @@ KnowledgeDigest, LogEntry, LogEntryType, + NewTaskInfo, Share, ShareLog, + TaskInfo, ) from .utils import get_current_user @@ -83,14 +86,18 @@ def get_conversation_role_file_path(context: ConversationContext) -> pathlib.Pat class ConversationStorage: @staticmethod - def get_conversation_preferences_file_path(context: ConversationContext) -> pathlib.Path: + def get_conversation_preferences_file_path( + context: ConversationContext, + ) -> pathlib.Path: """Gets the path to the file that stores conversation preferences.""" storage_dir = storage_directory_for_context(context) storage_dir.mkdir(parents=True, exist_ok=True) return storage_dir / "conversation_preferences.json" @staticmethod - def read_conversation_preferences(context: ConversationContext) -> ConversationPreferences | None: + def read_conversation_preferences( + context: ConversationContext, + ) -> ConversationPreferences | None: path = ConversationStorage.get_conversation_preferences_file_path(context) return read_model(path, ConversationPreferences) @@ -113,33 +120,45 @@ def write_share(share_id: str, share: Share) -> pathlib.Path: return path @staticmethod - def read_tasks(share_id: str) -> list[str]: + def read_tasks(share_id: str) -> list[TaskInfo]: share = ShareStorage.read_share(share_id) if not share: return [] return share.tasks @staticmethod - def add_tasks(share_id: str, tasks: list[str]) -> None: + def add_tasks(share_id: str, tasks: list[NewTaskInfo]) -> None: + share = ShareStorage.read_share(share_id) + if not share: + raise NoShareException + tasks_with_ids = [TaskInfo(**task.model_dump(), task_id=str(uuid.uuid4())) for task in tasks] + share.tasks.extend(tasks_with_ids) + ShareStorage.write_share(share_id, share) + + @staticmethod + def update_task(share_id: str, task: TaskInfo) -> None: share = ShareStorage.read_share(share_id) if not share: raise NoShareException - share.tasks.extend(tasks) + tasks = [] + for existing_task in share.tasks: + if existing_task.task_id == task.task_id: + tasks.append(task) + else: + tasks.append(existing_task) + share.tasks = tasks ShareStorage.write_share(share_id, share) @staticmethod - def remove_task(share_id: str, task: str) -> None: + def remove_task(share_id: str, task_id: str) -> None: share = ShareStorage.read_share(share_id) if not share: raise NoShareException - if task in share.tasks: - share.tasks.remove(task) - ShareStorage.write_share(share_id, share) - else: - logger.warning(f"Thought '{task}' not found in share {share_id}.") + share.tasks = [task for task in share.tasks if task.task_id != task_id] + ShareStorage.write_share(share_id, share) @staticmethod - def set_all_tasks(share_id: str, tasks: list[str]) -> None: + def set_all_tasks(share_id: str, tasks: list[TaskInfo]) -> None: share = ShareStorage.read_share(share_id) if not share: raise NoShareException diff --git a/assistants/knowledge-transfer-assistant/assistant/text_includes/actor_instructions.md b/assistants/knowledge-transfer-assistant/assistant/text_includes/actor_instructions.md new file mode 100644 index 000000000..ca68202bb --- /dev/null +++ b/assistants/knowledge-transfer-assistant/assistant/text_includes/actor_instructions.md @@ -0,0 +1,57 @@ +# Role and Objective + +You are an autonomous AI assistant named the "Knowledge Transfer Assistant". You support a user in creating and refining a knowledge package that will be shared with an audience. + +You are an agent - keep going until you can no longer resolve any more tasks without additional input, before ending your turn and yielding back to the user. Only terminate your turn when you are sure that all tasks are solved or you need additional input from the user. + +You must never stop your turn without indicating what specific information is required from the user to proceed. + +DO NOT try to resolve all tasks in a single turn, as this can lead to a lack of depth in your resolutions. Instead, focus on resolving one important task at a time, ensuring that each resolution is thorough and helpful. + +You MUST plan extensively before each function call, and reflect extensively on the outcomes of the previous function calls. DO NOT do this entire process by making function calls only, as this can impair your ability to solve the problem and think insightfully. + +# Context + +You will be given the following context in your system messages to accomplish your tasks: + +- Task list: Tasks you are currently working on. You should work until all tasks are resolved. +- Audience and audience takeaways. +- Knowledge package: Messages, attachments, brief, and digest are all considered part of the knowledge package. They are all shared with the audience. +- Knowledge digest: This is a summary of all the information in the knowledge package and a scratchpad for keeping important information in context. +- Knowledge brief: A fairly detailed summary of the knowledge share that is prepared by the user and will be displayed at all times to the audience. It is intended to give the audience context about what is in the knowledge package, why it matters, and what they can expect to learn. +- Information requests: After a knowledge package is shared, the audience is able to request more information from the user. These audience requests are stored as "Information Requests". + +# Instructions + +## Tasks + +- Resolve ALL tasks from the task list beginning with the highest priority tasks. +- Track your progress on tasks using the `update_task` tool. +- No tasks should remain `pending`. If there are any `pending` tasks and no `in_progress` tasks, mark one as `in_progress` and begin resolving it. +- Update tasks to status `in_progress` immediately as you begin working on them. +- Update tasks to status `completed` or `cancelled` when you have resolved the task. +- If you can resolve any task with a single tool call, do it now. +- Tasks may require user input. If the task requires user input, include the specific required input in your final response. +- If a task has been resolved, IMMEDIATELY start working on the next task. + +1. **Task States**: Use these states to track progress: + - pending: Task not yet started + - in_progress: Currently working on (limit to ONE task at a time) + - completed: Task finished successfully + - cancelled: Task no longer needed + +2. **Task Management**: + - IMPORTANT: Update task status in real-time as you work + - Mark tasks complete IMMEDIATELY after finishing (don't batch completions) + - Only have ONE task in_progress at any time + - Complete current tasks before starting new ones + - Cancel tasks that become irrelevant + +3. **Task Breakdown**: + - Create specific, actionable items + - Break complex tasks into smaller, manageable steps + - Use clear, descriptive task names + +## Output + +Return a description of what you have accomplished in your turn and a list of pieces of specific information you need from the user in JSON format. If you have resolved all tasks and need nothing additional from the user, return an empty list. diff --git a/assistants/knowledge-transfer-assistant/assistant/text_includes/coordinator_instructions.md b/assistants/knowledge-transfer-assistant/assistant/text_includes/coordinator_instructions.md index 200d94340..4cb4f812e 100644 --- a/assistants/knowledge-transfer-assistant/assistant/text_includes/coordinator_instructions.md +++ b/assistants/knowledge-transfer-assistant/assistant/text_includes/coordinator_instructions.md @@ -1,56 +1,94 @@ # Role and Objective -You are an autonomous AI assistant named the "Knowledge Transfer Assistant" that supports a user in creating and refining a knowledge package that will be shared with an audience. You are an expert in knowledge transfer and management, and your primary goal is to help the user assemble, organize, and refine the knowledge package. +You are an autonomous AI assistant named the "Knowledge Transfer Assistant". You support a user in creating and refining a knowledge package that will be shared with an audience. -You are an agent - please keep going until the user's query is completely resolved, before ending your turn and yielding back to the user. Only terminate your turn when you are sure that the problem is solved. +You are an agent - keep going until the user's query is completely resolved, before ending your turn and yielding back to the user. Only terminate your turn when you are sure that the problem is solved or you need additional input from the user. -You are driven to guide the user by the state of the knowledge share as captured below, and, importantly, by tasks on your task list. You should always refer your task list and attempt to resolve tasks as you are guiding the user. +You must never stop your turn without letting the user know what to do next. Every message should include either a request for information or helpful guidance about what the user should do next. + +Guide the user through the knowledge transfer process by inspecting the state of the knowledge share and by tasks on your task list. DO NOT try to resolve all tasks in a single turn, as this can lead to a lack of depth in your resolutions. Instead, focus on resolving one important task at a time, ensuring that each resolution is thorough and helpful. You MUST plan extensively before each function call, and reflect extensively on the outcomes of the previous function calls. DO NOT do this entire process by making function calls only, as this can impair your ability to solve the problem and think insightfully. -# Context available to you +# Context -- Task list: Tasks to be completed to help the user. -- Information requests: After a knowledge package is shared, the audience is able to request more information from the user. These audience requests are stored as "Information Requests". -- Knowledge brief: A fairly detailed summary of the knowledge share that is prepared by the user and will be displayed at all times to the audience. It is intended to give the audience context about what is in the knowledge package, why it matters, and what they can expect to learn. -- Knowledge digest: This is a summary of all the information in the knowledge package and a scratchpad for keeping important information in context. +You will be given the following context in your system messages to accomplish your tasks: + +- Task list: Tasks you are currently working on. You should work until all tasks are resolved. +- Audience and audience takeaways. - Knowledge package: Messages, attachments, brief, and digest are all considered part of the knowledge package. They are all shared with the audience. +- Knowledge digest: This is a summary of all the information in the knowledge package and a scratchpad for keeping important information in context. +- Knowledge brief: A fairly detailed summary of the knowledge share that is prepared by the user and will be displayed at all times to the audience. It is intended to give the audience context about what is in the knowledge package, why it matters, and what they can expect to learn. +- Information requests: After a knowledge package is shared, the audience is able to request more information from the user. These audience requests are stored as "Information Requests". # Instructions +## Information Requests + - If there are active `NEW` information requests, alert the user and collaborate with the user resolve them. -- Collaborate with the user to complete tasks from the task list. + +## Tasks + +- Collaborate with the user to resolve ALL tasks from the task list beginning with the highest priority tasks. +- Track your progress on tasks using the `update_task` tool. +- No tasks should remain `pending`. If there are any `pending` tasks and no `in_progress` tasks, mark one as `in_progress` and begin resolving it. +- Update tasks to status `in_progress` immediately as you begin working on them. +- Successful resolution will result in the task status being updated to `completed` or `cancelled`. - If you can resolve any task with a single tool call, do it now. -- IMPORTANT! Remove tasks once they are completed. Use the `mark_task_completed` tool every time you complete a task. If any old tasks are still in the task list that have already been completed, REMOVE THEM TOO. -- Don't respond with information about tasks that you have completed. Respond only with ways to move the knowledge transfer process forward. +- Tasks may require user input. If the task requires user input, work with the user to resolve it. For example, if the task is to to collect specific information, collect that information from the user. If possible to group information collection tasks in a single turn, do so. +- If a task has been resolved, do NOT tell the user. IMMEDIATELY start working on the next task. +- Do NOT speak about the task list in your responses. The task list is something only you can see. The user doesn't care about what you are doing with the task list, they only care about the knowledge transfer. + +1. **Task States**: Use these states to track progress: + - pending: Task not yet started + - in_progress: Currently working on (limit to ONE task at a time) + - completed: Task finished successfully + - cancelled: Task no longer needed + +2. **Task Management**: + - IMPORTANT: Update task status in real-time as you work + - Mark tasks complete IMMEDIATELY after finishing (don't batch completions) + - Only have ONE task in_progress at any time + - Complete current tasks before starting new ones + - Cancel tasks that become irrelevant + +3. **Task Breakdown**: + - Create specific, actionable items + - Break complex tasks into smaller, manageable steps + - Use clear, descriptive task names + +## Audience takeaways + +- The audience and the intended audience takeaways must be defined. +- Sometimes you can define the audience and takeaways from the attachments the user uploads. But you'll need to confirm with the user that the intended audience and takeaways are defined properly. +- Takeaways should be specific as they are the primary means of understanding whether the knowledge captured in the knowledge package is complete. ## Knowledge Package -- Assist the user in capturing the knowledge to be shared through messages and attachments. +- Your main job is to collect enough information to satisfy the intended audience takeaways. Everything the audience needs should be included in the knowledge package. +- If the user has not provided enough information to satisfy the takeaways. Ask for specific additional information. - When files are attached in messages, acknowledge the upload and summarize the file contents if possible. -- Everything the intended audience needs to know should be included in the knowledge package. -- Help the user fill in gaps in the knowledge package as needed: - - Ensure the audience can takeaway what the user intends. - - If learning objectives are defined, ensure that the knowledge package covers all required outcomes. - -### Invitation - -- When appropriate, help the user create an invitation message to share the knowledge package with the audience. -- The user won't see the output of the `create_invitation` tool. You must show it to them in entirety. ### Knowledge Brief -- At the appropriate time, help the user write a knowledge brief. +- After some knowledge has been collected, help the user write a knowledge brief. +- Writing a knowledge brief will help you clarify the audience and audience takeaways and help you fill in knowledge package gaps. - The first time you mention the brief, explain to the user what it is and why it matters. - Update the brief proactively as the user provides more information. Don't ask for permission. - When discussing the brief, there is no need to explain its contents. The user can see it in their side panel. - A brief should not include audience, learning objectives, or outcomes, as these are stored separately. +### Invitation + +- After enough information has been collected to satisfy audience takeaways, help the user create an invitation message to share the knowledge package with the audience. +- The user won't see the output of the `create_invitation` tool. You must show it to them in entirety. + + # Conversation Flow -Allow the user to drive the interaction. However, your responsibility is to ensure that the knowledge package is complete and shared. Your task-list should help you know what to do next. When in doubt, this is a good order of things: +Ensure that the knowledge package is complete and shared. Your task-list should help you know what to do next. When in doubt, this is a good order of things: - Defined the intended audience takeaways. - Define the audience. diff --git a/assistants/knowledge-transfer-assistant/assistant/text_includes/detect_audience.md b/assistants/knowledge-transfer-assistant/assistant/text_includes/detect_audience.md index c8a322ff5..3dd666c91 100644 --- a/assistants/knowledge-transfer-assistant/assistant/text_includes/detect_audience.md +++ b/assistants/knowledge-transfer-assistant/assistant/text_includes/detect_audience.md @@ -1,17 +1,17 @@ -You are an AI assistant watching a conversation between a consultant and their client. The consultant is helping the client assemble a knowledge package for an audience. Your job is to ensure the audience is well defined adding tasks to the consultant's task list as needed. +You are an AI assistant helping a user assemble a knowledge package for an audience. Your job right now is to ensure the audience is well defined. It is important that the audience and the intended takeaways for the audience are well defined so that we can make sure all the information required for knowledge transfer has been captured. -The ONLY situations in which you should add a task to the consultant's task list: +Add a task to your task list on these conditions: -- If the audience has not been clearly defined, task the consultant to guide the user to define it. -- If the audience has been defined but no specific intended takeaways for that audience have been defined, task the consultant to guide the user in defining the intended takeaways. -- If the audience or takeaways have been defined generally, task the consultant to guide the user to make them more specific and concrete. -- If recent messages give additional information about the intended audience, task the consultant to update the audience with additional information (provide the specific information that needs to be added in the task). -- If recent messages give additional information about the intended audience takeaways, task the consultant to update their audience takeaways (provide the specific takeaway information to be updated in the task) +- If the audience has not been clearly defined, add a task to define it. +- If the audience has been defined but no specific intended takeaways for that audience have been defined, add a task to define the intended takeaways. +- If the audience or takeaways have been defined but are too general, add a task to make them more specific and concrete. +- If recent messages give additional information about the intended audience, add a task to update the audience with additional information (provide the specific information that needs to be added in the task). +- If recent messages give additional information about the intended audience takeaways, add a task to update the audience takeaways (provide the specific takeaway information to be updated in the task) -UNDER ALL OTHER CIRCUMSTANCES, you should reply with an empty list. Oftentimes there will be nothing related to the audience or takeaways in new messages. This is quite common in a conversation. Return with an empty list in this case. +UNDER ALL OTHER CIRCUMSTANCES, you should not add tasks. Just reply with an empty list. Oftentimes there will be nothing related to the audience or takeaways in new messages. This is quite common in a conversation. Return with an empty list in this case. -IMPORTANT! If the consultant is already tasked with what you want them to do, you don't need to task them again. The consultant's tasks are shown below. +IMPORTANT! If there are already tasks related to audience or audience takeaway definition, you don't need to add another task. -Respond with a list of new tasks for the consultant related to the audience or audience takeaways in JSON. +Respond with a list of new tasks for audience or audience takeaways in JSON. diff --git a/assistants/knowledge-transfer-assistant/assistant/text_includes/detect_coordinator_actions.md b/assistants/knowledge-transfer-assistant/assistant/text_includes/detect_coordinator_actions.md index 89bfcab00..cc5736976 100644 --- a/assistants/knowledge-transfer-assistant/assistant/text_includes/detect_coordinator_actions.md +++ b/assistants/knowledge-transfer-assistant/assistant/text_includes/detect_coordinator_actions.md @@ -2,7 +2,7 @@ You are an an AI assistant who is expert in the knowledge transfer process. You # Instructions -- With your understanding of the knowledge transfer process and the current state of where we are at, examine the task list and add additional tasks if they are needed. If no additional tasks are needed, don't add any. +- With your understanding of the knowledge transfer process and the current state of where we are at, examine the task list and add additional very SPECIFIC tasks if they are needed. If no additional SPECIFIC tasks are needed, don't add any. - If a knowledge brief has not been created, or needs to be updated based on recent info, add a task to do that. - If we don't have enough info in the brief, digest, messages, and attachments to meet audience takeaways, we need more information from the user. - If the package is ready for transfer, create an invitation so it can be shared. diff --git a/assistants/knowledge-transfer-assistant/assistant/text_includes/detect_knowledge_package_gaps.md b/assistants/knowledge-transfer-assistant/assistant/text_includes/detect_knowledge_package_gaps.md index a6f97e2a6..71abe5b98 100644 --- a/assistants/knowledge-transfer-assistant/assistant/text_includes/detect_knowledge_package_gaps.md +++ b/assistants/knowledge-transfer-assistant/assistant/text_includes/detect_knowledge_package_gaps.md @@ -1,4 +1,4 @@ -You are an AI assistant who is expert at finding required information that is missing. Information is currently being collected to transfer to a particular audience. Your job is to ensure the desired audience takeaways can be achieved with the information that has been collected so far. If not, you identify what additional information is required. +You are an AI assistant who is expert at finding necessary information that is missing from a knowledge package. Knowledge is currently being collected to transfer to a particular audience. Your job is to ensure the desired audience takeaways can be achieved with the information that has been collected so far. If not, you identify what additional SPECIFIC information is required. The knowledge package consists of the messages, the attachments, the knowledge digest, and the knowledge brief. @@ -9,6 +9,8 @@ A task list of items we know we need to do is also included. # Instructions - Examine the audience takeaways and the information we have collected so far. -- If there are specific, concrete pieces of information that our audience will require to takeaway what we want them to, add a task to collect that information. Be specific. -- If the knowledge gap you identify is already identified on the task list, do not return it. -- If the collected information is sufficient for our audience to take away what we want them to, return an empty list. +- If the audience or the audience takeaways have not been defined at all, don't do a gap analysis and just return an empty list. +- If there are SPECIFIC, concrete pieces of information that are necessary to satisfy the intended audience takeaways, return a request for that SPECIFIC information. Be specific. +- If the knowledge gap you identify is already in the task list, you have already identified it and don't need to do it again. +- Don't just consider the specific wording of the takeaways, instead be thoughtful about what additional information would be required to meet the implied takeaways. For example, if a takeaway is to "Understand project X", an implied takeaway might be that the website of project X should be in the knowledge package, or contact information for the organizers of project X should be included. Similarly, if the takeaway is something like "Know about event Y", then information about what the event is, when it is being held, where it is located, travel directions, cost, etc. should all be included unless the user specifies otherwise. These are all examples of SPECIFIC information. +- If the collected information is sufficient for our audience to take away what we want them to, return no gaps, just an empty list. diff --git a/assistants/knowledge-transfer-assistant/assistant/text_includes/knowledge_digest_instructions.txt b/assistants/knowledge-transfer-assistant/assistant/text_includes/knowledge_digest_instructions.txt index 371220619..765672762 100644 --- a/assistants/knowledge-transfer-assistant/assistant/text_includes/knowledge_digest_instructions.txt +++ b/assistants/knowledge-transfer-assistant/assistant/text_includes/knowledge_digest_instructions.txt @@ -1,5 +1,5 @@ -The user's assistant's knowledge digest contains information about this knowledge share that has been extracted from the previous conversation thread and attachments. -It serves as a persistent memory of important facts, decisions, and context. +The knowledge digest contains information about this knowledge package that has been extracted from the previous conversation thread and attachments. +It serves as a persistent memory of important facts and context. Key characteristics of this knowledge digest: @@ -7,9 +7,4 @@ Key characteristics of this knowledge digest: - It has been automatically curated to focus on high-value information relevant to the knowledge share. - It is maintained and updated as knowledge content is added. - It should be treated as a trusted source of contextual information for this knowledge share. - -When using the knowledge digest: - -- Prioritize this information when addressing questions or providing updates. -- Reference it to ensure consistency in your responses across the conversation. -- Use it to track important details that might otherwise be lost in the conversation history. +- Do not use it for task management or information requests. These things are managed separately. diff --git a/assistants/knowledge-transfer-assistant/assistant/text_includes/update_knowledge_digest.md b/assistants/knowledge-transfer-assistant/assistant/text_includes/update_knowledge_digest.md index 9eafda586..2526a7602 100644 --- a/assistants/knowledge-transfer-assistant/assistant/text_includes/update_knowledge_digest.md +++ b/assistants/knowledge-transfer-assistant/assistant/text_includes/update_knowledge_digest.md @@ -5,7 +5,7 @@ You, the assistant, maintain a "knowledge digest". Based on the last chat messag - The knowledge digest contains an outline of the knowledge contained within a knowledge share. It is not a summary, but an organized projection of all knowledge added to the packet from the coordinator's conversation and attachments. - The digest has a high information density. The digest contains no filler words or unnecessary content. - The digest includes all relevant information from the chat history: questions and answers, key concepts, decisions made during the knowledge transfer process, links, codes, and specific facts. -- This digest is the primary resource of information for the audience. +- This digest is the primary resource of information for the audience and should help satisfy all audience takeaways. ## What the knowledge digest is NOT @@ -15,14 +15,15 @@ You, the assistant, maintain a "knowledge digest". Based on the last chat messag ## Knowledge digest instructions +- If the knowledge digest does not need to be updated, just return - Provide updated content based upon information extracted from the last message in the chat history. - Maintain an accessible knowledge reference that helps others understand the shared information. -- Organize knowledge share facts and concepts. +- Organize facts and concepts. - Maintain an outline of the content at all times. If the latest message suggests a new or expanded outline, update the existing outline to reflect the new content. -- Do NOT supplement the knowledge share with your own information. +- Do NOT supplement the digest with your own information. - Use brief, clear explanations of complex topics. -- Preserve critical context and decisions. - Remove information that is no longer relevant. +- Do not use the digest for keeping track of tasks. - Do not include objectives and outcomes in the knowledge digest. They are maintained separately. - It's OK to leave the knowledge digest blank if there's nothing important to capture. -- Your output format must be: {content} +- Your output format must be: {content} if you have updated content, or if no changes need to be made. diff --git a/assistants/knowledge-transfer-assistant/assistant/text_includes/welcome_message_generation.txt b/assistants/knowledge-transfer-assistant/assistant/text_includes/welcome_message_generation.txt index 4ef051ae5..d3e93324c 100644 --- a/assistants/knowledge-transfer-assistant/assistant/text_includes/welcome_message_generation.txt +++ b/assistants/knowledge-transfer-assistant/assistant/text_includes/welcome_message_generation.txt @@ -1,9 +1,9 @@ -Create a welcome message specific to this shared knowledge bundle prepared by the coordinator. +Create a welcome message specific to this shared knowledge package. The welcome message should be in the same voice as the coordinator conversation as though it were from the coordinator. - Be welcoming. - Speak plainly. Use no filler words or unnecessary content. -- The welcome message should tell a person to read detailed information about the knowledge share in the side panel. Don't repeat detailed information in the welcome message. The welcome message should be grounding, but not overly duplicate the detailed information. +- The welcome message should tell a person to read detailed information about the intended takeaways and the shared knowledge in knowledge brief in the side panel. Don't repeat detailed information in the welcome message. The welcome message should be grounding, but not duplicating the detailed information. - Provide a top-level summary of what this shared knowledge is about (in a single sentence, a larger summary is included in the side panel) -- If there are learning objectives and outcomes in this knowledge share, provide a top-level summary of them (in a single sentence, they can read more details in the side panel). +- Provide a top-level summary of the intended takeaways (in a single sentence, they can read more details in the side panel). - Let the user know what you can do for them and suggest how the user should continue the conversation with you. -- Your output format should be markdown. Do NOT include any other commentary. Do NOT include backticks. Do NOT surround it with quotes. +- Your output format should be markdown. Do NOT include any other commentary. Do NOT include backticks. Do NOT surround your response with quotes. diff --git a/assistants/knowledge-transfer-assistant/assistant/tools/__init__.py b/assistants/knowledge-transfer-assistant/assistant/tools/__init__.py index 32ac218e0..765d54500 100644 --- a/assistants/knowledge-transfer-assistant/assistant/tools/__init__.py +++ b/assistants/knowledge-transfer-assistant/assistant/tools/__init__.py @@ -51,10 +51,20 @@ def _register_coordinator_tools(self): """Register coordinator-specific tools.""" self.tool_functions.add_function( - self.tasks.delete_task, - "mark_task_completed", + self.tasks.add_task, + "add_task", ) + self.tool_functions.add_function( + self.tasks.update_task, + "update_task", + ) + + # self.tool_functions.add_function( + # self.tasks.delete_task, + # "mark_task_completed", + # ) + self.tool_functions.add_function( self.share_setup.update_audience, "update_audience", @@ -66,8 +76,8 @@ def _register_coordinator_tools(self): ) self.tool_functions.add_function( - self.share_setup.create_invitation, - "create_invitation", + self.share_setup.create_invitation_message, + "create_invitation_message", ) # self.tool_functions.add_function( @@ -134,7 +144,7 @@ def _register_team_tools(self): "delete_information_request", ) self.tool_functions.add_function( - self.progress_tracking.mark_learning_outcome_achieved, + self.learning_outcomes.mark_learning_outcome_achieved, "mark_learning_outcome_achieved", ) self.tool_functions.add_function( diff --git a/assistants/knowledge-transfer-assistant/assistant/tools/learning_outcomes.py b/assistants/knowledge-transfer-assistant/assistant/tools/learning_outcomes.py index 0ac4083d5..4d27a8f6e 100644 --- a/assistants/knowledge-transfer-assistant/assistant/tools/learning_outcomes.py +++ b/assistants/knowledge-transfer-assistant/assistant/tools/learning_outcomes.py @@ -4,8 +4,22 @@ Tools for managing individual learning outcomes within objectives. """ -from assistant.domain import LearningObjectivesManager +from datetime import UTC, datetime + +from semantic_workbench_api_model.workbench_model import ( + MessageType, + NewConversationMessage, +) + +from assistant.data import ( + InspectorTab, + LearningOutcomeAchievement, + LogEntryType, +) +from assistant.domain import LearningObjectivesManager, ShareManager, TransferManager from assistant.logging import logger +from assistant.notifications import Notifications +from assistant.utils import get_current_user_id from .base import ToolsBase @@ -96,3 +110,123 @@ async def delete_learning_outcome(self, outcome_id: str) -> str: except Exception as e: logger.exception(f"Failed to delete learning outcome: {e}") return f"Failed to delete learning outcome: {e!s}" + + async def mark_learning_outcome_achieved(self, objective_id: str, outcome_id: str) -> str: + """ + Mark a learning outcome as achieved for tracking knowledge transfer progress. + + WHEN TO USE: + - When the user reports completing a specific learning task or deliverable + - When evidence has been provided that a learning outcome has been met + - When a milestone for one of the learning objectives has been achieved + - When tracking progress and updating the transfer status + + Each completed outcome moves the knowledge transfer closer to completion. When all outcomes are achieved, the transfer can be marked as complete. + + Args: + objective_id: The UUID of the learning objective + outcome_id: The UUID of the learning outcome within the objective + + Returns: + A message indicating success or failure + """ # noqa: E501 + try: + share = await ShareManager.get_share(self.context) + brief = share.brief + if not brief: + return "No knowledge brief found." + + # Find the objective by ID + objective = None + for obj in share.learning_objectives: + if obj.id == objective_id: + objective = obj + break + if not objective: + return f"Learning objective with ID '{objective_id}' not found." + + # Find the outcome by ID within the objective + outcome = None + for out in objective.learning_outcomes: + if out.id == outcome_id: + outcome = out + break + + if not outcome: + return f"Learning outcome with ID '{outcome_id}' not found in objective '{objective.name}'." + + conversation_id = str(self.context.id) + + # Check if already achieved by this conversation + if LearningObjectivesManager.is_outcome_achieved_by_conversation(share, outcome.id, conversation_id): + return f"Outcome '{outcome.description}' is already marked as achieved by this team member." + + # Ensure team conversation info exists + if conversation_id not in share.team_conversations: + return "Team conversation not properly registered. Please contact the coordinator." + + # Create achievement record + achievement = LearningOutcomeAchievement( + outcome_id=outcome.id, achieved=True, achieved_at=datetime.now(UTC) + ) + + # Add achievement to team conversation's achievements + share.team_conversations[conversation_id].outcome_achievements.append(achievement) + + # Update team conversation's last active timestamp + share.team_conversations[conversation_id].last_active_at = datetime.now(UTC) + + # Update metadata + current_user_id = await get_current_user_id(self.context) + share.updated_at = datetime.now(UTC) + share.updated_by = current_user_id + share.version += 1 + + # Save the updated knowledge package + await ShareManager.set_share(self.context, share) + + # Log the outcome achievement + await ShareManager.log_share_event( + context=self.context, + entry_type=LogEntryType.OUTCOME_ATTAINED.value, + message=f"Learning outcome achieved: {outcome.description}", + related_entity_id=None, + metadata={ + "objective_name": objective.name, + "outcome_description": outcome.description, + }, + ) + + # Notify linked conversations with a message + await Notifications.notify_all( + self.context, + share.share_id, + f"Learning outcome '{outcome.description}' for objective '{objective.name}' has been achieved.", + ) + await Notifications.notify_all_state_update( + self.context, + [InspectorTab.LEARNING, InspectorTab.BRIEF], + ) + + # Check if all outcomes are achieved for transfer completion + # Get the knowledge package to check completion status + if TransferManager._is_transfer_complete(share): + await self.context.send_messages( + NewConversationMessage( + content="🎉 All learning outcomes have been achieved! The knowledge transfer has been automatically marked as complete.", # noqa: E501 + message_type=MessageType.notice, + ) + ) + + await self.context.send_messages( + NewConversationMessage( + content=f"Learning outcome '{outcome.description}' for objective '{objective.name}' has been marked as achieved.", # noqa: E501 + message_type=MessageType.notice, + ) + ) + + return f"Learning outcome '{outcome.description}' for objective '{objective.name}' marked as achieved." + + except Exception as e: + logger.exception(f"Error marking learning outcome as achieved: {e}") + return "An error occurred while marking the learning outcome as achieved. Please try again later." diff --git a/assistants/knowledge-transfer-assistant/assistant/tools/progress_tracking.py b/assistants/knowledge-transfer-assistant/assistant/tools/progress_tracking.py index f6dd83214..ead7913ce 100644 --- a/assistants/knowledge-transfer-assistant/assistant/tools/progress_tracking.py +++ b/assistants/knowledge-transfer-assistant/assistant/tools/progress_tracking.py @@ -13,10 +13,9 @@ from assistant.data import ( InspectorTab, - LearningOutcomeAchievement, LogEntryType, ) -from assistant.domain import LearningObjectivesManager, ShareManager, TransferManager +from assistant.domain import LearningObjectivesManager, ShareManager from assistant.logging import logger from assistant.notifications import Notifications from assistant.utils import get_current_user_id @@ -27,126 +26,6 @@ class ProgressTrackingTools(ToolsBase): """Tools for tracking learning progress.""" - async def mark_learning_outcome_achieved(self, objective_id: str, outcome_id: str) -> str: - """ - Mark a learning outcome as achieved for tracking knowledge transfer progress. - - WHEN TO USE: - - When the user reports completing a specific learning task or deliverable - - When evidence has been provided that a learning outcome has been met - - When a milestone for one of the learning objectives has been achieved - - When tracking progress and updating the transfer status - - Each completed outcome moves the knowledge transfer closer to completion. When all outcomes are achieved, the transfer can be marked as complete. - - Args: - objective_id: The UUID of the learning objective - outcome_id: The UUID of the learning outcome within the objective - - Returns: - A message indicating success or failure - """ # noqa: E501 - try: - share = await ShareManager.get_share(self.context) - brief = share.brief - if not brief: - return "No knowledge brief found." - - # Find the objective by ID - objective = None - for obj in share.learning_objectives: - if obj.id == objective_id: - objective = obj - break - if not objective: - return f"Learning objective with ID '{objective_id}' not found." - - # Find the outcome by ID within the objective - outcome = None - for out in objective.learning_outcomes: - if out.id == outcome_id: - outcome = out - break - - if not outcome: - return f"Learning outcome with ID '{outcome_id}' not found in objective '{objective.name}'." - - conversation_id = str(self.context.id) - - # Check if already achieved by this conversation - if LearningObjectivesManager.is_outcome_achieved_by_conversation(share, outcome.id, conversation_id): - return f"Outcome '{outcome.description}' is already marked as achieved by this team member." - - # Ensure team conversation info exists - if conversation_id not in share.team_conversations: - return "Team conversation not properly registered. Please contact the coordinator." - - # Create achievement record - achievement = LearningOutcomeAchievement( - outcome_id=outcome.id, achieved=True, achieved_at=datetime.now(UTC) - ) - - # Add achievement to team conversation's achievements - share.team_conversations[conversation_id].outcome_achievements.append(achievement) - - # Update team conversation's last active timestamp - share.team_conversations[conversation_id].last_active_at = datetime.now(UTC) - - # Update metadata - current_user_id = await get_current_user_id(self.context) - share.updated_at = datetime.now(UTC) - share.updated_by = current_user_id - share.version += 1 - - # Save the updated knowledge package - await ShareManager.set_share(self.context, share) - - # Log the outcome achievement - await ShareManager.log_share_event( - context=self.context, - entry_type=LogEntryType.OUTCOME_ATTAINED.value, - message=f"Learning outcome achieved: {outcome.description}", - related_entity_id=None, - metadata={ - "objective_name": objective.name, - "outcome_description": outcome.description, - }, - ) - - # Notify linked conversations with a message - await Notifications.notify_all( - self.context, - share.share_id, - f"Learning outcome '{outcome.description}' for objective '{objective.name}' has been achieved.", - ) - await Notifications.notify_all_state_update( - self.context, - [InspectorTab.LEARNING, InspectorTab.BRIEF], - ) - - # Check if all outcomes are achieved for transfer completion - # Get the knowledge package to check completion status - if TransferManager._is_transfer_complete(share): - await self.context.send_messages( - NewConversationMessage( - content="🎉 All learning outcomes have been achieved! The knowledge transfer has been automatically marked as complete.", # noqa: E501 - message_type=MessageType.notice, - ) - ) - - await self.context.send_messages( - NewConversationMessage( - content=f"Learning outcome '{outcome.description}' for objective '{objective.name}' has been marked as achieved.", # noqa: E501 - message_type=MessageType.notice, - ) - ) - - return f"Learning outcome '{outcome.description}' for objective '{objective.name}' marked as achieved." - - except Exception as e: - logger.exception(f"Error marking learning outcome as achieved: {e}") - return "An error occurred while marking the learning outcome as achieved. Please try again later." - async def report_transfer_completion(self) -> str: """ Report that the knowledge transfer is complete, concluding the transfer lifecycle. diff --git a/assistants/knowledge-transfer-assistant/assistant/tools/share_setup.py b/assistants/knowledge-transfer-assistant/assistant/tools/share_setup.py index 34800dc14..0dc881a6a 100644 --- a/assistants/knowledge-transfer-assistant/assistant/tools/share_setup.py +++ b/assistants/knowledge-transfer-assistant/assistant/tools/share_setup.py @@ -89,14 +89,15 @@ async def update_audience_takeaways(self, takeaways: list[str]) -> str: # except Exception as e: # return f"Failed to update knowledge organization status: {e!s}" - async def update_brief(self, title: str, description: str) -> str: + async def update_brief(self, title: str, content: str) -> str: """ - Update a brief with a title and description. The brief should avoid + Update a brief with a title and content. The brief should avoid filler words and unnecessary content. Args: title: The title of the brief description: A description of the knowledge share to be given to recipients as context. + content: The content of the brief. Returns: A message indicating success or failure @@ -105,7 +106,7 @@ async def update_brief(self, title: str, description: str) -> str: await KnowledgeBriefManager.update_knowledge_brief( context=self.context, title=title, - description=description, + content=content, ) return "Brief updated successfully." except Exception as e: @@ -140,12 +141,12 @@ async def set_learning_intention(self, is_for_specific_outcomes: bool) -> str: except Exception as e: return f"Failed to update learning intention: {e!s}" - async def create_invitation(self) -> str: + async def create_invitation_message(self) -> str: """ - Create an invitation for the knowledge transfer. + Create an invitation message to send to audience members for the knowledge transfer. Args: - invitation_text: The text of the invitation to be sent to participants. + invitation_text: The text of the invitation to be sent to the audience. Returns: A message indicating success or failure diff --git a/assistants/knowledge-transfer-assistant/assistant/tools/tasks.py b/assistants/knowledge-transfer-assistant/assistant/tools/tasks.py index 65321a19a..3cd373e0c 100644 --- a/assistants/knowledge-transfer-assistant/assistant/tools/tasks.py +++ b/assistants/knowledge-transfer-assistant/assistant/tools/tasks.py @@ -1,4 +1,4 @@ -from assistant.data import InspectorTab +from assistant.data import InspectorTab, NewTaskInfo, TaskInfo, TaskPriority, TaskStatus from assistant.domain.tasks_manager import TasksManager from assistant.logging import logger from assistant.notifications import Notifications @@ -7,19 +7,80 @@ class TaskTools(ToolsBase): - async def delete_task(self, task: str) -> str: + async def add_task(self, status: str, priority: str, content: str) -> str: + """ + Add a new task to the task list. + + Args: + status (str): The new status of the task. pending, in_progress, completed, or cancelled. + priority (TaskPriority): The new priority of the task. low, medium, or high. + content (str): The content of the task to update. + + Returns: + Message indicating success or failure + """ + try: + task_info = NewTaskInfo( + status=TaskStatus(status), + priority=TaskPriority(priority), + content=content, + ) + await TasksManager.add_tasks(self.context, [task_info]) + await Notifications.notify( + self.context, + "Task added.", + debug_data={"task_info": task_info}, + ) + await Notifications.notify_state_update(self.context, [InspectorTab.DEBUG]) + return "Task added successfully." + except Exception as e: + logger.exception(f"Failed to add task: {e}") + return f"Failed to add task: {e!s}" + + async def update_task(self, task_id: str, status: str, priority: str, content: str) -> str: + """ + Update a task's status, priority, or content. Use this for managing the task list. This should be called every time work has been done on a task or when the task needs to be updated. + + Args: + status (str): The new status of the task. pending, in_progress, completed, or cancelled. + priority (TaskPriority): The new priority of the task. low, medium, or high. + content (str): The content of the task to update. + + Returns: + Message indicating success or failure + """ # noqa: E501 + try: + task_info = TaskInfo( + task_id=task_id, + status=TaskStatus(status), + priority=TaskPriority(priority), + content=content, + ) + await TasksManager.update_task(self.context, task_info) + await Notifications.notify( + self.context, + "Task updated.", + debug_data={"task_info": task_info}, + ) + await Notifications.notify_state_update(self.context, [InspectorTab.DEBUG]) + return f"Task {task_info.task_id} updated successfully." + except Exception as e: + logger.exception(f"Failed to update task: {e}") + return f"Failed to update task: {e!s}" + + async def delete_task(self, task_id: str) -> str: """ Mark a task completed. This should be called EVERY TIME a task has been completed. Args: - task (str): The task to mark completed. Must be the full text of the task as it appears in the task list. + task (str): The task UUID to mark completed. Returns: Message indicating success or failure """ try: - await TasksManager.remove_task(self.context, task) - await Notifications.notify(self.context, "Task marked completed.", debug_data={"task": task}) - await Notifications.notify_all_state_update(self.context, [InspectorTab.DEBUG]) - message = f"Task marked completed: {task}" + await TasksManager.remove_task(self.context, task_id) + await Notifications.notify(self.context, "Task marked completed.", debug_data={"task": task_id}) + await Notifications.notify_state_update(self.context, [InspectorTab.DEBUG]) + message = f"Task marked completed: {task_id}" logger.info(message) return "Marked completed." except Exception as e: diff --git a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/common.py b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/common.py index fe53b390b..c60c7b88f 100644 --- a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/common.py +++ b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/common.py @@ -2,7 +2,7 @@ Common utilities for inspector modules. """ -from assistant.data import RequestPriority, RequestStatus, Share +from assistant.data import RequestPriority, RequestStatus, Share, TaskPriority, TaskStatus from assistant.domain import TransferManager @@ -18,6 +18,27 @@ def get_status_emoji(status: RequestStatus) -> str: return status_emojis.get(status, "❓") # Unknown status fallback +def task_priority_emoji(priority: TaskPriority) -> str: + """Get emoji representation for task priority.""" + priority_emojis = { + TaskPriority.LOW: "🔹", # Low priority - blue diamond + TaskPriority.MEDIUM: "🔶", # Medium priority - orange diamond + TaskPriority.HIGH: "🔴", # High priority - red circle + } + return priority_emojis.get(priority, "🔹") # Default to low priority emoji + + +def task_status_emoji(status: TaskStatus) -> str: + """Get emoji representation for task status.""" + status_emojis = { + TaskStatus.PENDING: "⏳", # Pending - hourglass + TaskStatus.IN_PROGRESS: "⚙️", # In Progress - gear + TaskStatus.COMPLETED: "✅", # Completed - check mark + TaskStatus.CANCELLED: "❌", # Cancelled - cross mark + } + return status_emojis.get(status, "❓") # Unknown status fallback + + def get_priority_emoji(priority: RequestPriority) -> str: """Get emoji representation for request priority.""" priority_emojis = { diff --git a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/debug.py b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/debug.py index 3af02c660..51ce23a66 100644 --- a/assistants/knowledge-transfer-assistant/assistant/ui_tabs/debug.py +++ b/assistants/knowledge-transfer-assistant/assistant/ui_tabs/debug.py @@ -11,6 +11,7 @@ from assistant.domain import KnowledgeDigestManager, ShareManager, TransferManager from assistant.domain.conversation_preferences_manager import ConversationPreferencesManager from assistant.domain.tasks_manager import TasksManager +from assistant.ui_tabs.common import task_priority_emoji, task_status_emoji class DebugInspector: @@ -71,12 +72,12 @@ async def _format_debug_info(self, context: ConversationContext, share: Share) - style = await ConversationPreferencesManager.get_preferred_communication_style(context) lines.append(f"- **Preferred Communication Style:** {style}") - # Assistant tasks + # Tasks lines.append("## Assistant task list") tasks = await TasksManager.get_tasks(context) if tasks: - for thought in tasks: - lines.append(f"- {thought}") + for task in tasks: + lines.append(f"- {task_status_emoji(task.status)} {task_priority_emoji(task.priority)} {task.content}") lines.append("") else: lines.append("_No tasks recorded yet._") diff --git a/assistants/knowledge-transfer-assistant/docs/design/control_options.md b/assistants/knowledge-transfer-assistant/docs/design/control_options.md new file mode 100644 index 000000000..024ffbd51 --- /dev/null +++ b/assistants/knowledge-transfer-assistant/docs/design/control_options.md @@ -0,0 +1,52 @@ +# Designing KTA assistant architecture + +## The simple job at hand + +Help prepare a knowledge package. +- Define audience and takeaways + +## Task organization breakdown + +- ToDo (structured task list) +- Tasks +- Tool calls + +## Types of context + +- kt-process: The knowledge transfer process while in coordinator role. +- share: state of the knowledge share +- kb: State of the knowledge base +- messages: previous conversation +- task-list + +## Different roles/recipes/types-of-assistants + +### Task completer + +- Role: Conversational assistant +- Instructions + - Collaborate with user to complete tasks. +- Context + - kt-process + - share + - kb + - messages +- Tools +- Output + +- Audience +- Knowledge Base management +- Gaps detector +- Invitation +- Information requests +- Knowledge digest +- Knowledge brief +- Welcome message generation + +## Thoughts + +- Should the +- Who executes? + - If the agents... they need to do so reliably and give a trace. + +"Now I should start thinking about..."