diff --git a/coaching/pyproject.toml b/coaching/pyproject.toml index fb3c6645..4b3a4dda 100644 --- a/coaching/pyproject.toml +++ b/coaching/pyproject.toml @@ -134,6 +134,11 @@ ignore = [ "N999", # Invalid module name (triggered by repo path in CI) ] +# Align with repo-root pyproject.toml so isort classifies coaching.* as first-party +# when Ruff discovers this config (e.g. `ruff check` from coaching/). +[tool.ruff.lint.isort] +known-first-party = ["coaching", "shared"] + [tool.ruff.lint.per-file-ignores] "__init__.py" = ["F401"] "**/tests/**/*" = ["B011", "ARG001", "ARG002", "S101", "B007", "B017", "SIM118", "RUF043"] diff --git a/coaching/src/api/dependencies/ai_engine.py b/coaching/src/api/dependencies/ai_engine.py index b3bf67ae..89c5a1ba 100644 --- a/coaching/src/api/dependencies/ai_engine.py +++ b/coaching/src/api/dependencies/ai_engine.py @@ -10,6 +10,8 @@ import boto3 import structlog +from fastapi import Header + from coaching.src.api.handlers.generic_ai_handler import GenericAIHandler from coaching.src.application.ai_engine.response_serializer import ResponseSerializer from coaching.src.application.ai_engine.unified_ai_engine import UnifiedAIEngine @@ -25,7 +27,6 @@ from coaching.src.repositories.topic_repository import TopicRepository from coaching.src.services.s3_prompt_storage import S3PromptStorage from coaching.src.services.template_parameter_processor import TemplateParameterProcessor -from fastapi import Header from shared.services.aws_helpers import get_bedrock_client logger = structlog.get_logger() diff --git a/coaching/src/api/dependencies/async_execution.py b/coaching/src/api/dependencies/async_execution.py index 91bfa1ce..9244093c 100644 --- a/coaching/src/api/dependencies/async_execution.py +++ b/coaching/src/api/dependencies/async_execution.py @@ -6,6 +6,7 @@ import boto3 import structlog + from coaching.src.api.dependencies.ai_engine import get_unified_ai_engine from coaching.src.core.config_multitenant import settings from coaching.src.infrastructure.repositories.dynamodb_job_repository import DynamoDBJobRepository diff --git a/coaching/src/api/dependencies/coaching_message_job.py b/coaching/src/api/dependencies/coaching_message_job.py index 4ae99af7..fd7ccb39 100644 --- a/coaching/src/api/dependencies/coaching_message_job.py +++ b/coaching/src/api/dependencies/coaching_message_job.py @@ -6,6 +6,7 @@ import boto3 import structlog + from coaching.src.api.dependencies.ai_engine import ( get_llm_usage_recording_service, get_provider_factory, diff --git a/coaching/src/api/dependencies/sql_template_generation.py b/coaching/src/api/dependencies/sql_template_generation.py index ae565d90..359adb9b 100644 --- a/coaching/src/api/dependencies/sql_template_generation.py +++ b/coaching/src/api/dependencies/sql_template_generation.py @@ -6,6 +6,7 @@ import boto3 import structlog + from coaching.src.core.config_multitenant import settings from coaching.src.integration.sql_template.cdata_mcp_client import ( CDataMcpClient, diff --git a/coaching/src/api/handlers/eventbridge_handler.py b/coaching/src/api/handlers/eventbridge_handler.py index cbdc7124..88ec248c 100644 --- a/coaching/src/api/handlers/eventbridge_handler.py +++ b/coaching/src/api/handlers/eventbridge_handler.py @@ -13,9 +13,10 @@ from typing import Any import structlog +from pydantic import ValidationError + from coaching.src.api.models.ai_job_kickoff import ApiAiJobRequestedDetail from coaching.src.core.config_multitenant import settings -from pydantic import ValidationError logger = structlog.get_logger() diff --git a/coaching/src/api/handlers/generic_ai_handler.py b/coaching/src/api/handlers/generic_ai_handler.py index 0b59375c..45d8aacc 100644 --- a/coaching/src/api/handlers/generic_ai_handler.py +++ b/coaching/src/api/handlers/generic_ai_handler.py @@ -1,505 +1,506 @@ -"""Generic AI Handler - Unified handler for all topic-driven AI endpoints. - -This module provides a generic handler that routes all AI requests through -the UnifiedAIEngine, eliminating the need for endpoint-specific service classes. -""" - -from typing import TYPE_CHECKING, Any - -import structlog -from coaching.src.api.models.auth import UserContext -from coaching.src.application.ai_engine.response_serializer import SerializationError -from coaching.src.application.ai_engine.unified_ai_engine import ( - ParameterValidationError, - PromptRenderError, - TopicNotFoundError, - UnifiedAIEngine, - UnifiedAIEngineError, -) -from coaching.src.core.topic_registry import get_endpoint_definition -from fastapi import HTTPException, status -from pydantic import BaseModel - -if TYPE_CHECKING: - from coaching.src.services.template_parameter_processor import TemplateParameterProcessor - -logger = structlog.get_logger() - - -class GenericAIHandler: - """Generic handler for all AI endpoints using UnifiedAIEngine. - - This handler replaces individual service classes (AlignmentService, - StrategyService, etc.) with a unified, topic-driven approach. - - Key Features: - - Single handler for all 44 endpoints - - Automatic topic lookup via endpoint registry - - Type-safe request/response handling - - Consistent error handling and logging - - Support for both single-shot and conversation flows - """ - - def __init__(self, ai_engine: UnifiedAIEngine) -> None: - """Initialize generic handler. - - Args: - ai_engine: Unified AI engine instance - """ - self.ai_engine = ai_engine - self.logger = logger.bind(service="generic_ai_handler") - - async def handle_single_shot( - self, - *, - http_method: str, - endpoint_path: str, - request_body: BaseModel, - user_context: UserContext, - response_model: type[BaseModel], - template_processor: "TemplateParameterProcessor | None" = None, - ) -> BaseModel: - """Handle single-shot AI request. - - This is the primary method for all non-conversation endpoints. - - Flow: - 1. Lookup endpoint definition in registry - 2. Extract topic_id - 3. Convert request to parameters dict - 4. Execute via UnifiedAIEngine - 5. Return typed response - - Args: - http_method: HTTP method (GET, POST, etc.) - endpoint_path: API endpoint path - request_body: Validated request model - user_context: User authentication context - response_model: Expected response model class - template_processor: Optional processor for automatic parameter enrichment - (created per-request with user's JWT token) - - Returns: - Instance of response_model with AI-generated data - - Raises: - HTTPException: For all error conditions (404, 400, 500) - """ - self.logger.info( - "Handling single-shot request", - method=http_method, - path=endpoint_path, - user_id=user_context.user_id, - ) - - try: - # Step 1: Lookup endpoint definition - endpoint_def = get_endpoint_definition(http_method, endpoint_path) - if endpoint_def is None: - self.logger.error( - "Endpoint not found in registry", - method=http_method, - path=endpoint_path, - ) - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Endpoint {http_method}:{endpoint_path} not registered", - ) - - if not endpoint_def.is_active: - self.logger.warning( - "Endpoint is inactive", - topic_id=endpoint_def.topic_id, - path=endpoint_path, - ) - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail=f"Endpoint {endpoint_path} is temporarily unavailable", - ) - - topic_id = endpoint_def.topic_id - - # Step 2: Convert request to parameters - parameters = self._extract_parameters(request_body, user_context) - - # Step 3: Execute via UnifiedAIEngine - self.logger.debug( - "Executing AI request", - topic_id=topic_id, - param_count=len(parameters), - ) - - result = await self.ai_engine.execute_single_shot( - topic_id=topic_id, - parameters=parameters, - response_model=response_model, - user_id=user_context.user_id, - tenant_id=user_context.tenant_id, - template_processor=template_processor, - ) - - self.logger.info( - "Single-shot request completed", - topic_id=topic_id, - result_type=type(result).__name__, - ) - - return result - - except TopicNotFoundError as e: - self.logger.error("Topic not found", topic_id=e.topic_id) - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail=f"Topic configuration not found: {e.topic_id}. Please check the topic ID and ensure it is properly configured.", - ) from e - - except ParameterValidationError as e: - self.logger.error( - "Parameter validation failed", - topic_id=e.topic_id, - missing_params=e.missing_params, - ) - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Missing required parameters: {', '.join(e.missing_params)}", - ) from e - - except PromptRenderError as e: - self.logger.error( - "Prompt rendering failed", - topic_id=e.topic_id, - prompt_type=e.prompt_type, - ) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to render AI prompt", - ) from e - - except SerializationError as e: - self.logger.error( - "Response serialization failed", - topic_id=e.topic_id, - response_model=e.response_model, - ) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to serialize AI response", - ) from e - - except UnifiedAIEngineError as e: - self.logger.error("AI engine error", topic_id=e.topic_id, error=str(e)) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="AI processing failed", - ) from e - - except Exception as e: - self.logger.error( - "Unexpected error in generic handler", - method=http_method, - path=endpoint_path, - error=str(e), - exc_info=True, - ) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Internal server error", - ) from e - - async def get_initial_prompt(self, topic_id: str) -> str: - """Get initial prompt for a topic. - - Args: - topic_id: Topic identifier - - Returns: - System prompt content - - Raises: - HTTPException: If topic or prompt not found - """ - try: - return await self.ai_engine.get_initial_prompt(topic_id) - except TopicNotFoundError as e: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Topic not found: {e.topic_id}", - ) from e - except UnifiedAIEngineError as e: - self.logger.error("Failed to get initial prompt", error=str(e)) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to get initial prompt", - ) from e - - async def handle_conversation_initiate( - self, - *, - topic_id: str, - user_context: UserContext, - initial_parameters: dict[str, Any] | None = None, - ) -> Any: - """Handle conversation initiation. - - Args: - topic_id: Topic for conversation - user_context: User authentication context - initial_parameters: Optional initial context - - Returns: - Conversation entity - - Raises: - HTTPException: For all error conditions - """ - self.logger.info( - "Initiating conversation", - topic_id=topic_id, - user_id=user_context.user_id, - ) - - try: - from coaching.src.core.types import create_tenant_id, create_user_id - - conversation = await self.ai_engine.initiate_conversation( - topic_id=topic_id, - user_id=create_user_id(user_context.user_id), - tenant_id=create_tenant_id(user_context.tenant_id), - initial_parameters=initial_parameters, - ) - - return conversation - - except TopicNotFoundError as e: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Topic not found: {e.topic_id}", - ) from e - - except UnifiedAIEngineError as e: - self.logger.error("Conversation initiation failed", error=str(e)) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to initiate conversation", - ) from e - - async def handle_conversation_message( - self, - *, - conversation_id: str, - user_message: str, - user_context: UserContext, - ) -> dict[str, Any]: - """Handle conversation message. - - Args: - conversation_id: Conversation identifier - user_message: User's message - user_context: User authentication context - - Returns: - Dictionary with AI response - - Raises: - HTTPException: For all error conditions - """ - self.logger.info( - "Sending conversation message", - conversation_id=conversation_id, - user_id=user_context.user_id, - ) - - try: - from coaching.src.core.types import ( - ConversationId, - create_tenant_id, - ) - - response = await self.ai_engine.send_message( - conversation_id=ConversationId(conversation_id), - user_message=user_message, - tenant_id=create_tenant_id(user_context.tenant_id), - ) - - return response - - except UnifiedAIEngineError as e: - self.logger.error("Message send failed", error=str(e)) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to process message", - ) from e - - async def handle_conversation_pause( - self, - *, - conversation_id: str, - user_context: UserContext, - ) -> None: - """Handle conversation pause. - - Args: - conversation_id: Conversation identifier - user_context: User authentication context - - Raises: - HTTPException: For all error conditions - """ - self.logger.info( - "Pausing conversation", - conversation_id=conversation_id, - user_id=user_context.user_id, - ) - - try: - from coaching.src.core.types import ( - ConversationId, - create_tenant_id, - ) - - await self.ai_engine.pause_conversation( - conversation_id=ConversationId(conversation_id), - tenant_id=create_tenant_id(user_context.tenant_id), - ) - - except UnifiedAIEngineError as e: - self.logger.error("Pause failed", error=str(e)) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to pause conversation", - ) from e - - async def handle_conversation_resume( - self, - *, - conversation_id: str, - user_context: UserContext, - ) -> Any: - """Handle conversation resume. - - Args: - conversation_id: Conversation identifier - user_context: User authentication context - - Returns: - Resumed Conversation entity - - Raises: - HTTPException: For all error conditions - """ - self.logger.info( - "Resuming conversation", - conversation_id=conversation_id, - user_id=user_context.user_id, - ) - - try: - from coaching.src.core.types import ( - ConversationId, - create_tenant_id, - ) - - conversation = await self.ai_engine.resume_conversation( - conversation_id=ConversationId(conversation_id), - tenant_id=create_tenant_id(user_context.tenant_id), - ) - - return conversation - - except UnifiedAIEngineError as e: - self.logger.error("Resume failed", error=str(e)) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to resume conversation", - ) from e - - async def handle_conversation_complete( - self, - *, - conversation_id: str, - user_context: UserContext, - ) -> Any: - """Handle conversation completion. - - Args: - conversation_id: Conversation identifier - user_context: User authentication context - - Returns: - Completed Conversation entity - - Raises: - HTTPException: For all error conditions - """ - self.logger.info( - "Completing conversation", - conversation_id=conversation_id, - user_id=user_context.user_id, - ) - - try: - from coaching.src.core.types import ( - ConversationId, - create_tenant_id, - ) - - conversation = await self.ai_engine.complete_conversation( - conversation_id=ConversationId(conversation_id), - tenant_id=create_tenant_id(user_context.tenant_id), - ) - - return conversation - - except UnifiedAIEngineError as e: - self.logger.error("Completion failed", error=str(e)) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to complete conversation", - ) from e - - def _extract_parameters( - self, request_body: BaseModel, user_context: UserContext - ) -> dict[str, Any]: - """Extract parameters from request body and user context. - - Converts Pydantic model to dictionary and adds user context. - - Args: - request_body: Request model - user_context: User authentication context - - Returns: - Dictionary of parameters for prompt injection - """ - # Convert request to dict - params = request_body.model_dump() - - # Add user context (available to all prompts) - # Note: user_name is resolved via get_user_context retrieval method if not in payload - params["user_id"] = user_context.user_id - params["tenant_id"] = user_context.tenant_id - - return params - - -# Convenience functions for dependency injection - - -async def create_generic_handler(ai_engine: UnifiedAIEngine) -> GenericAIHandler: - """Create generic handler instance. - - This is a factory function for FastAPI dependency injection. - - Args: - ai_engine: Unified AI engine instance - - Returns: - Configured GenericAIHandler - """ - return GenericAIHandler(ai_engine=ai_engine) - - -__all__ = [ - "GenericAIHandler", - "create_generic_handler", -] +"""Generic AI Handler - Unified handler for all topic-driven AI endpoints. + +This module provides a generic handler that routes all AI requests through +the UnifiedAIEngine, eliminating the need for endpoint-specific service classes. +""" + +from typing import TYPE_CHECKING, Any + +import structlog +from fastapi import HTTPException, status +from pydantic import BaseModel + +from coaching.src.api.models.auth import UserContext +from coaching.src.application.ai_engine.response_serializer import SerializationError +from coaching.src.application.ai_engine.unified_ai_engine import ( + ParameterValidationError, + PromptRenderError, + TopicNotFoundError, + UnifiedAIEngine, + UnifiedAIEngineError, +) +from coaching.src.core.topic_registry import get_endpoint_definition + +if TYPE_CHECKING: + from coaching.src.services.template_parameter_processor import TemplateParameterProcessor + +logger = structlog.get_logger() + + +class GenericAIHandler: + """Generic handler for all AI endpoints using UnifiedAIEngine. + + This handler replaces individual service classes (AlignmentService, + StrategyService, etc.) with a unified, topic-driven approach. + + Key Features: + - Single handler for all 44 endpoints + - Automatic topic lookup via endpoint registry + - Type-safe request/response handling + - Consistent error handling and logging + - Support for both single-shot and conversation flows + """ + + def __init__(self, ai_engine: UnifiedAIEngine) -> None: + """Initialize generic handler. + + Args: + ai_engine: Unified AI engine instance + """ + self.ai_engine = ai_engine + self.logger = logger.bind(service="generic_ai_handler") + + async def handle_single_shot( + self, + *, + http_method: str, + endpoint_path: str, + request_body: BaseModel, + user_context: UserContext, + response_model: type[BaseModel], + template_processor: "TemplateParameterProcessor | None" = None, + ) -> BaseModel: + """Handle single-shot AI request. + + This is the primary method for all non-conversation endpoints. + + Flow: + 1. Lookup endpoint definition in registry + 2. Extract topic_id + 3. Convert request to parameters dict + 4. Execute via UnifiedAIEngine + 5. Return typed response + + Args: + http_method: HTTP method (GET, POST, etc.) + endpoint_path: API endpoint path + request_body: Validated request model + user_context: User authentication context + response_model: Expected response model class + template_processor: Optional processor for automatic parameter enrichment + (created per-request with user's JWT token) + + Returns: + Instance of response_model with AI-generated data + + Raises: + HTTPException: For all error conditions (404, 400, 500) + """ + self.logger.info( + "Handling single-shot request", + method=http_method, + path=endpoint_path, + user_id=user_context.user_id, + ) + + try: + # Step 1: Lookup endpoint definition + endpoint_def = get_endpoint_definition(http_method, endpoint_path) + if endpoint_def is None: + self.logger.error( + "Endpoint not found in registry", + method=http_method, + path=endpoint_path, + ) + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Endpoint {http_method}:{endpoint_path} not registered", + ) + + if not endpoint_def.is_active: + self.logger.warning( + "Endpoint is inactive", + topic_id=endpoint_def.topic_id, + path=endpoint_path, + ) + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=f"Endpoint {endpoint_path} is temporarily unavailable", + ) + + topic_id = endpoint_def.topic_id + + # Step 2: Convert request to parameters + parameters = self._extract_parameters(request_body, user_context) + + # Step 3: Execute via UnifiedAIEngine + self.logger.debug( + "Executing AI request", + topic_id=topic_id, + param_count=len(parameters), + ) + + result = await self.ai_engine.execute_single_shot( + topic_id=topic_id, + parameters=parameters, + response_model=response_model, + user_id=user_context.user_id, + tenant_id=user_context.tenant_id, + template_processor=template_processor, + ) + + self.logger.info( + "Single-shot request completed", + topic_id=topic_id, + result_type=type(result).__name__, + ) + + return result + + except TopicNotFoundError as e: + self.logger.error("Topic not found", topic_id=e.topic_id) + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Topic configuration not found: {e.topic_id}. Please check the topic ID and ensure it is properly configured.", + ) from e + + except ParameterValidationError as e: + self.logger.error( + "Parameter validation failed", + topic_id=e.topic_id, + missing_params=e.missing_params, + ) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Missing required parameters: {', '.join(e.missing_params)}", + ) from e + + except PromptRenderError as e: + self.logger.error( + "Prompt rendering failed", + topic_id=e.topic_id, + prompt_type=e.prompt_type, + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to render AI prompt", + ) from e + + except SerializationError as e: + self.logger.error( + "Response serialization failed", + topic_id=e.topic_id, + response_model=e.response_model, + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to serialize AI response", + ) from e + + except UnifiedAIEngineError as e: + self.logger.error("AI engine error", topic_id=e.topic_id, error=str(e)) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="AI processing failed", + ) from e + + except Exception as e: + self.logger.error( + "Unexpected error in generic handler", + method=http_method, + path=endpoint_path, + error=str(e), + exc_info=True, + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Internal server error", + ) from e + + async def get_initial_prompt(self, topic_id: str) -> str: + """Get initial prompt for a topic. + + Args: + topic_id: Topic identifier + + Returns: + System prompt content + + Raises: + HTTPException: If topic or prompt not found + """ + try: + return await self.ai_engine.get_initial_prompt(topic_id) + except TopicNotFoundError as e: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Topic not found: {e.topic_id}", + ) from e + except UnifiedAIEngineError as e: + self.logger.error("Failed to get initial prompt", error=str(e)) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to get initial prompt", + ) from e + + async def handle_conversation_initiate( + self, + *, + topic_id: str, + user_context: UserContext, + initial_parameters: dict[str, Any] | None = None, + ) -> Any: + """Handle conversation initiation. + + Args: + topic_id: Topic for conversation + user_context: User authentication context + initial_parameters: Optional initial context + + Returns: + Conversation entity + + Raises: + HTTPException: For all error conditions + """ + self.logger.info( + "Initiating conversation", + topic_id=topic_id, + user_id=user_context.user_id, + ) + + try: + from coaching.src.core.types import create_tenant_id, create_user_id + + conversation = await self.ai_engine.initiate_conversation( + topic_id=topic_id, + user_id=create_user_id(user_context.user_id), + tenant_id=create_tenant_id(user_context.tenant_id), + initial_parameters=initial_parameters, + ) + + return conversation + + except TopicNotFoundError as e: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Topic not found: {e.topic_id}", + ) from e + + except UnifiedAIEngineError as e: + self.logger.error("Conversation initiation failed", error=str(e)) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to initiate conversation", + ) from e + + async def handle_conversation_message( + self, + *, + conversation_id: str, + user_message: str, + user_context: UserContext, + ) -> dict[str, Any]: + """Handle conversation message. + + Args: + conversation_id: Conversation identifier + user_message: User's message + user_context: User authentication context + + Returns: + Dictionary with AI response + + Raises: + HTTPException: For all error conditions + """ + self.logger.info( + "Sending conversation message", + conversation_id=conversation_id, + user_id=user_context.user_id, + ) + + try: + from coaching.src.core.types import ( + ConversationId, + create_tenant_id, + ) + + response = await self.ai_engine.send_message( + conversation_id=ConversationId(conversation_id), + user_message=user_message, + tenant_id=create_tenant_id(user_context.tenant_id), + ) + + return response + + except UnifiedAIEngineError as e: + self.logger.error("Message send failed", error=str(e)) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to process message", + ) from e + + async def handle_conversation_pause( + self, + *, + conversation_id: str, + user_context: UserContext, + ) -> None: + """Handle conversation pause. + + Args: + conversation_id: Conversation identifier + user_context: User authentication context + + Raises: + HTTPException: For all error conditions + """ + self.logger.info( + "Pausing conversation", + conversation_id=conversation_id, + user_id=user_context.user_id, + ) + + try: + from coaching.src.core.types import ( + ConversationId, + create_tenant_id, + ) + + await self.ai_engine.pause_conversation( + conversation_id=ConversationId(conversation_id), + tenant_id=create_tenant_id(user_context.tenant_id), + ) + + except UnifiedAIEngineError as e: + self.logger.error("Pause failed", error=str(e)) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to pause conversation", + ) from e + + async def handle_conversation_resume( + self, + *, + conversation_id: str, + user_context: UserContext, + ) -> Any: + """Handle conversation resume. + + Args: + conversation_id: Conversation identifier + user_context: User authentication context + + Returns: + Resumed Conversation entity + + Raises: + HTTPException: For all error conditions + """ + self.logger.info( + "Resuming conversation", + conversation_id=conversation_id, + user_id=user_context.user_id, + ) + + try: + from coaching.src.core.types import ( + ConversationId, + create_tenant_id, + ) + + conversation = await self.ai_engine.resume_conversation( + conversation_id=ConversationId(conversation_id), + tenant_id=create_tenant_id(user_context.tenant_id), + ) + + return conversation + + except UnifiedAIEngineError as e: + self.logger.error("Resume failed", error=str(e)) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to resume conversation", + ) from e + + async def handle_conversation_complete( + self, + *, + conversation_id: str, + user_context: UserContext, + ) -> Any: + """Handle conversation completion. + + Args: + conversation_id: Conversation identifier + user_context: User authentication context + + Returns: + Completed Conversation entity + + Raises: + HTTPException: For all error conditions + """ + self.logger.info( + "Completing conversation", + conversation_id=conversation_id, + user_id=user_context.user_id, + ) + + try: + from coaching.src.core.types import ( + ConversationId, + create_tenant_id, + ) + + conversation = await self.ai_engine.complete_conversation( + conversation_id=ConversationId(conversation_id), + tenant_id=create_tenant_id(user_context.tenant_id), + ) + + return conversation + + except UnifiedAIEngineError as e: + self.logger.error("Completion failed", error=str(e)) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to complete conversation", + ) from e + + def _extract_parameters( + self, request_body: BaseModel, user_context: UserContext + ) -> dict[str, Any]: + """Extract parameters from request body and user context. + + Converts Pydantic model to dictionary and adds user context. + + Args: + request_body: Request model + user_context: User authentication context + + Returns: + Dictionary of parameters for prompt injection + """ + # Convert request to dict + params = request_body.model_dump() + + # Add user context (available to all prompts) + # Note: user_name is resolved via get_user_context retrieval method if not in payload + params["user_id"] = user_context.user_id + params["tenant_id"] = user_context.tenant_id + + return params + + +# Convenience functions for dependency injection + + +async def create_generic_handler(ai_engine: UnifiedAIEngine) -> GenericAIHandler: + """Create generic handler instance. + + This is a factory function for FastAPI dependency injection. + + Args: + ai_engine: Unified AI engine instance + + Returns: + Configured GenericAIHandler + """ + return GenericAIHandler(ai_engine=ai_engine) + + +__all__ = [ + "GenericAIHandler", + "create_generic_handler", +] diff --git a/coaching/src/api/legacy_dependencies.py b/coaching/src/api/legacy_dependencies.py index 54963e61..02503820 100644 --- a/coaching/src/api/legacy_dependencies.py +++ b/coaching/src/api/legacy_dependencies.py @@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, Any import structlog + from coaching.src.api.auth import get_current_context from coaching.src.application.analysis.alignment_service import AlignmentAnalysisService from coaching.src.application.analysis.base_analysis_service import BaseAnalysisService @@ -14,9 +15,12 @@ from coaching.src.application.analysis.strategy_service import StrategyAnalysisService if TYPE_CHECKING: - from coaching.src.services.model_config_service import ModelConfigService from mypy_boto3_dynamodb import DynamoDBServiceResource + from coaching.src.services.model_config_service import ModelConfigService + +from fastapi import Depends + from coaching.src.application.conversation.conversation_service import ( ConversationApplicationService, ) @@ -36,7 +40,6 @@ from coaching.src.services.llm_template_service import LLMTemplateService from coaching.src.services.prompt_service import PromptService from coaching.src.services.s3_prompt_storage import S3PromptStorage -from fastapi import Depends from shared.models.multitenant import RequestContext from shared.services.aws_helpers import ( get_bedrock_client, diff --git a/coaching/src/api/main.py b/coaching/src/api/main.py index 703475b9..77eb5fa1 100644 --- a/coaching/src/api/main.py +++ b/coaching/src/api/main.py @@ -7,6 +7,11 @@ from typing import Any import structlog +from fastapi import FastAPI, Request, Response +from fastapi.middleware.cors import CORSMiddleware +from mangum import Mangum +from starlette.middleware.base import BaseHTTPMiddleware + from coaching.src.api.middleware import ( ErrorHandlingMiddleware, LoggingMiddleware, @@ -23,10 +28,6 @@ multitenant_conversations, ) from coaching.src.core.config_multitenant import settings -from fastapi import FastAPI, Request, Response -from fastapi.middleware.cors import CORSMiddleware -from mangum import Mangum -from starlette.middleware.base import BaseHTTPMiddleware # Configure Python logging for Lambda - Lambda captures stderr logging.basicConfig( diff --git a/coaching/src/api/middleware/admin_auth.py b/coaching/src/api/middleware/admin_auth.py index 5732b832..732dbe25 100644 --- a/coaching/src/api/middleware/admin_auth.py +++ b/coaching/src/api/middleware/admin_auth.py @@ -1,47 +1,48 @@ -"""Admin authentication and authorization middleware.""" - -import structlog -from coaching.src.api.auth import get_current_context -from fastapi import Depends, HTTPException, status -from shared.models.multitenant import RequestContext, UserRole - -logger = structlog.get_logger() - - -def require_admin_access( - context: RequestContext = Depends(get_current_context), -) -> RequestContext: - """ - Verify that the current user has admin access. - - Args: - context: Current request context with user and permissions - - Returns: - RequestContext if admin access is granted - - Raises: - HTTPException: 403 if user lacks admin permissions - """ - if context.role not in [UserRole.ADMIN, UserRole.OWNER]: - logger.warning( - "Admin access denied", - user_id=context.user_id, - tenant_id=context.tenant_id, - role=context.role, - ) - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Admin access required. This operation requires administrative privileges.", - ) - - logger.debug( - "Admin access granted", - user_id=context.user_id, - tenant_id=context.tenant_id, - ) - - return context - - -__all__ = ["require_admin_access"] +"""Admin authentication and authorization middleware.""" + +import structlog +from fastapi import Depends, HTTPException, status + +from coaching.src.api.auth import get_current_context +from shared.models.multitenant import RequestContext, UserRole + +logger = structlog.get_logger() + + +def require_admin_access( + context: RequestContext = Depends(get_current_context), +) -> RequestContext: + """ + Verify that the current user has admin access. + + Args: + context: Current request context with user and permissions + + Returns: + RequestContext if admin access is granted + + Raises: + HTTPException: 403 if user lacks admin permissions + """ + if context.role not in [UserRole.ADMIN, UserRole.OWNER]: + logger.warning( + "Admin access denied", + user_id=context.user_id, + tenant_id=context.tenant_id, + role=context.role, + ) + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Admin access required. This operation requires administrative privileges.", + ) + + logger.debug( + "Admin access granted", + user_id=context.user_id, + tenant_id=context.tenant_id, + ) + + return context + + +__all__ = ["require_admin_access"] diff --git a/coaching/src/api/middleware/error_handling.py b/coaching/src/api/middleware/error_handling.py index bce2f321..63f06be8 100644 --- a/coaching/src/api/middleware/error_handling.py +++ b/coaching/src/api/middleware/error_handling.py @@ -1,156 +1,157 @@ -"""Error handling middleware for API (Phase 7). - -This middleware provides centralized error handling, transforming -domain exceptions into appropriate HTTP responses with proper status -codes and error messages. -""" - -import structlog -from coaching.src.domain.exceptions.base_exception import DomainException -from coaching.src.domain.exceptions.conversation_exceptions import ( - ConversationNotActive, - ConversationNotFound, -) -from fastapi import Request, status -from fastapi.exceptions import RequestValidationError -from fastapi.responses import JSONResponse -from pydantic import ValidationError -from starlette.middleware.base import BaseHTTPMiddleware - -logger = structlog.get_logger() - - -class ErrorHandlingMiddleware(BaseHTTPMiddleware): - """Middleware to handle exceptions and return appropriate HTTP responses. - - Note: BaseHTTPMiddleware exists at runtime but type stubs are incomplete. - - This middleware catches exceptions raised during request processing - and converts them to structured JSON error responses with appropriate - HTTP status codes. - """ - - async def dispatch(self, request: Request, call_next): # type: ignore[no-untyped-def] - """Process request and handle any exceptions. - - Args: - request: FastAPI request object - call_next: Next middleware/handler in chain - - Returns: - Response from handler or error response - """ - try: - response = await call_next(request) - return response - - except ConversationNotFound as e: - logger.warning( - "Conversation not found", - conversation_id=str(e.context.get("conversation_id")), - tenant_id=str(e.context.get("tenant_id")), - path=request.url.path, - ) - return JSONResponse( - status_code=status.HTTP_404_NOT_FOUND, - content={ - "error": "conversation_not_found", - "message": str(e), - "conversation_id": e.context.get("conversation_id"), - }, - ) - - except ConversationNotActive as e: - logger.warning( - "Conversation not active", - conversation_id=str(e.context.get("conversation_id")), - current_status=e.context.get("current_status"), - path=request.url.path, - ) - return JSONResponse( - status_code=status.HTTP_409_CONFLICT, - content={ - "error": "conversation_not_active", - "message": str(e), - "conversation_id": e.context.get("conversation_id"), - "current_status": e.context.get("current_status"), - }, - ) - - except DomainException as e: - logger.warning( - "Domain exception", - error_code=e.code, - error=str(e), - path=request.url.path, - ) - return JSONResponse( - status_code=status.HTTP_400_BAD_REQUEST, - content={ - "error": e.code.lower(), - "message": e.message, - "context": e.context, - }, - ) - - except (ValidationError, RequestValidationError) as e: - logger.warning( - "Request validation error", - errors=str(e), - path=request.url.path, - ) - return JSONResponse( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - content={ - "error": "validation_error", - "message": "Request validation failed", - "details": e.errors() if hasattr(e, "errors") else str(e), - }, - ) - - except PermissionError as e: - logger.warning( - "Permission denied", - error=str(e), - path=request.url.path, - ) - return JSONResponse( - status_code=status.HTTP_403_FORBIDDEN, - content={ - "error": "permission_denied", - "message": str(e), - }, - ) - - except ValueError as e: - logger.warning( - "Value error", - error=str(e), - path=request.url.path, - ) - return JSONResponse( - status_code=status.HTTP_400_BAD_REQUEST, - content={ - "error": "invalid_request", - "message": str(e), - }, - ) - - except Exception as e: - logger.error( - "Unhandled exception in API", - error=str(e), - error_type=type(e).__name__, - path=request.url.path, - exc_info=True, - ) - return JSONResponse( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - content={ - "error": "internal_server_error", - "message": "An unexpected error occurred. Please try again later.", - }, - ) - - -__all__ = ["ErrorHandlingMiddleware"] +"""Error handling middleware for API (Phase 7). + +This middleware provides centralized error handling, transforming +domain exceptions into appropriate HTTP responses with proper status +codes and error messages. +""" + +import structlog +from fastapi import Request, status +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse +from pydantic import ValidationError +from starlette.middleware.base import BaseHTTPMiddleware + +from coaching.src.domain.exceptions.base_exception import DomainException +from coaching.src.domain.exceptions.conversation_exceptions import ( + ConversationNotActive, + ConversationNotFound, +) + +logger = structlog.get_logger() + + +class ErrorHandlingMiddleware(BaseHTTPMiddleware): + """Middleware to handle exceptions and return appropriate HTTP responses. + + Note: BaseHTTPMiddleware exists at runtime but type stubs are incomplete. + + This middleware catches exceptions raised during request processing + and converts them to structured JSON error responses with appropriate + HTTP status codes. + """ + + async def dispatch(self, request: Request, call_next): # type: ignore[no-untyped-def] + """Process request and handle any exceptions. + + Args: + request: FastAPI request object + call_next: Next middleware/handler in chain + + Returns: + Response from handler or error response + """ + try: + response = await call_next(request) + return response + + except ConversationNotFound as e: + logger.warning( + "Conversation not found", + conversation_id=str(e.context.get("conversation_id")), + tenant_id=str(e.context.get("tenant_id")), + path=request.url.path, + ) + return JSONResponse( + status_code=status.HTTP_404_NOT_FOUND, + content={ + "error": "conversation_not_found", + "message": str(e), + "conversation_id": e.context.get("conversation_id"), + }, + ) + + except ConversationNotActive as e: + logger.warning( + "Conversation not active", + conversation_id=str(e.context.get("conversation_id")), + current_status=e.context.get("current_status"), + path=request.url.path, + ) + return JSONResponse( + status_code=status.HTTP_409_CONFLICT, + content={ + "error": "conversation_not_active", + "message": str(e), + "conversation_id": e.context.get("conversation_id"), + "current_status": e.context.get("current_status"), + }, + ) + + except DomainException as e: + logger.warning( + "Domain exception", + error_code=e.code, + error=str(e), + path=request.url.path, + ) + return JSONResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content={ + "error": e.code.lower(), + "message": e.message, + "context": e.context, + }, + ) + + except (ValidationError, RequestValidationError) as e: + logger.warning( + "Request validation error", + errors=str(e), + path=request.url.path, + ) + return JSONResponse( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + content={ + "error": "validation_error", + "message": "Request validation failed", + "details": e.errors() if hasattr(e, "errors") else str(e), + }, + ) + + except PermissionError as e: + logger.warning( + "Permission denied", + error=str(e), + path=request.url.path, + ) + return JSONResponse( + status_code=status.HTTP_403_FORBIDDEN, + content={ + "error": "permission_denied", + "message": str(e), + }, + ) + + except ValueError as e: + logger.warning( + "Value error", + error=str(e), + path=request.url.path, + ) + return JSONResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content={ + "error": "invalid_request", + "message": str(e), + }, + ) + + except Exception as e: + logger.error( + "Unhandled exception in API", + error=str(e), + error_type=type(e).__name__, + path=request.url.path, + exc_info=True, + ) + return JSONResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content={ + "error": "internal_server_error", + "message": "An unexpected error occurred. Please try again later.", + }, + ) + + +__all__ = ["ErrorHandlingMiddleware"] diff --git a/coaching/src/api/models/ai_job_kickoff.py b/coaching/src/api/models/ai_job_kickoff.py index 20f7b783..3bc10b95 100644 --- a/coaching/src/api/models/ai_job_kickoff.py +++ b/coaching/src/api/models/ai_job_kickoff.py @@ -5,9 +5,10 @@ from datetime import datetime from typing import Any, Literal -from coaching.src.api.models.async_ai import AuthContext from pydantic import BaseModel, ConfigDict, Field, model_validator +from coaching.src.api.models.async_ai import AuthContext + class ApiAiJobRequestedDetail(BaseModel): """EventBridge `detail` for `ai.job.requested` (email_insight kickoff). diff --git a/coaching/src/api/models/analysis.py b/coaching/src/api/models/analysis.py index 5853cf3d..e7a61049 100644 --- a/coaching/src/api/models/analysis.py +++ b/coaching/src/api/models/analysis.py @@ -7,9 +7,10 @@ from datetime import datetime from typing import Any -from coaching.src.core.constants import AnalysisType from pydantic import BaseModel, Field, field_validator +from coaching.src.core.constants import AnalysisType + # Request Models diff --git a/coaching/src/api/models/async_ai.py b/coaching/src/api/models/async_ai.py index bb401589..3879dda5 100644 --- a/coaching/src/api/models/async_ai.py +++ b/coaching/src/api/models/async_ai.py @@ -7,9 +7,10 @@ from datetime import UTC, datetime from typing import Any +from pydantic import BaseModel, ConfigDict, Field, model_validator + from coaching.src.api.models.job_status_contract import api_contract_status_for_job_status from coaching.src.domain.entities.ai_job import AIJob -from pydantic import BaseModel, ConfigDict, Field, model_validator class AuthContext(BaseModel): diff --git a/coaching/src/api/models/conversations.py b/coaching/src/api/models/conversations.py index a60ee571..853c14a2 100644 --- a/coaching/src/api/models/conversations.py +++ b/coaching/src/api/models/conversations.py @@ -1,320 +1,321 @@ -"""API models for conversation endpoints. - -This module provides Pydantic models for conversation-related API requests and responses. -These models handle API-layer concerns (serialization, validation, documentation). -""" - -from datetime import datetime -from typing import Any - -from coaching.src.core.constants import CoachingTopic, ConversationStatus -from pydantic import BaseModel, Field, field_validator - -# Request Models - - -class InitiateConversationRequest(BaseModel): - """Request to initiate a new coaching conversation. - - This model validates and structures the initial conversation request. - Note: user_id and tenant_id are extracted from JWT token, not from request body. - """ - - topic: CoachingTopic = Field( - ..., - description="Coaching topic to focus on", - examples=[CoachingTopic.CORE_VALUES], - ) - context: dict[str, Any] = Field( - default_factory=dict, - description="Additional context for the conversation", - examples=[{"prior_sessions": 3, "last_topic": "purpose"}], - ) - language: str = Field( - default="en", - max_length=5, - description="Language code for the conversation", - examples=["en", "es", "fr"], - ) - - @field_validator("language") - @classmethod - def validate_language(cls, v: str) -> str: - """Validate language code.""" - return v.lower().strip() - - -class MessageRequest(BaseModel): - """Request to send a message in an existing conversation. - - This model validates user messages sent during a conversation. - """ - - user_message: str = Field( - ..., - min_length=1, - max_length=4000, - description="User's message content", - examples=["I value honesty and transparency"], - ) - metadata: dict[str, Any] = Field( - default_factory=dict, - description="Additional metadata for the message", - examples=[{"source": "web_app", "session_duration": 120}], - ) - - @field_validator("user_message") - @classmethod - def validate_message(cls, v: str) -> str: - """Validate message content.""" - if not v.strip(): - raise ValueError("Message cannot be empty or whitespace") - return v.strip() - - -class PauseConversationRequest(BaseModel): - """Request to pause an active conversation. - - This model captures the reason for pausing, useful for analytics. - """ - - reason: str | None = Field( - default=None, - max_length=500, - description="Optional reason for pausing", - examples=["Need to gather more information", "Break requested by user"], - ) - - -class CompleteConversationRequest(BaseModel): - """Request to mark a conversation as complete. - - This model captures user feedback at conversation end. - """ - - feedback: str | None = Field( - default=None, - max_length=1000, - description="Optional user feedback", - examples=["Very helpful session, gained clarity on my values"], - ) - rating: int | None = Field( - default=None, - ge=1, - le=5, - description="Optional rating (1-5 stars)", - examples=[5], - ) - - -# Response Models - - -class ConversationResponse(BaseModel): - """Response for conversation initiation. - - This model provides the initial state and first question to the user. - """ - - conversation_id: str = Field( - ..., - description="Unique identifier for the conversation", - examples=["conv_abc123"], - ) - user_id: str = Field( - ..., - description="User identifier", - examples=["user_123"], - ) - tenant_id: str = Field( - ..., - description="Tenant identifier", - examples=["tenant_456"], - ) - topic: CoachingTopic = Field( - ..., - description="Coaching topic", - ) - status: ConversationStatus = Field( - ..., - description="Current conversation status", - ) - initial_message: str = Field( - ..., - description="Initial coach message/question", - examples=["Welcome! Let's explore your core values together. What matters most to you?"], - ) - progress: float = Field( - ..., - ge=0.0, - le=1.0, - description="Conversation progress (0.0-1.0)", - examples=[0.1], - ) - created_at: datetime = Field( - ..., - description="Conversation creation timestamp", - ) - - model_config = { - "json_schema_extra": { - "example": { - "conversation_id": "conv_abc123", - "user_id": "user_123", - "tenant_id": "tenant_456", - "topic": "core_values", - "status": "active", - "current_phase": "introduction", - "initial_message": "Welcome! Let's explore your core values.", - "progress": 0.1, - "created_at": "2025-10-10T21:00:00Z", - } - } - } - - -class MessageResponse(BaseModel): - """Response for a message in an ongoing conversation. - - This model provides the AI's response and conversation state updates. - """ - - conversation_id: str = Field( - ..., - description="Conversation identifier", - ) - ai_response: str = Field( - ..., - description="AI coach's response", - examples=["That's wonderful! Honesty is a powerful core value."], - ) - follow_up_question: str | None = Field( - default=None, - description="Optional follow-up question", - examples=["How does honesty show up in your daily work?"], - ) - progress: float = Field( - ..., - ge=0.0, - le=1.0, - description="Conversation progress", - examples=[0.3], - ) - is_complete: bool = Field( - ..., - description="Whether conversation is complete", - examples=[False], - ) - insights: list[str] = Field( - default_factory=list, - description="Insights extracted from this exchange", - examples=[["User values honesty", "Looking for authenticity in work"]], - ) - identified_values: list[str] = Field( - default_factory=list, - description="Core values identified", - examples=[["Honesty", "Transparency", "Integrity"]], - ) - next_steps: list[str] | None = Field( - default=None, - description="Suggested next steps (if conversation complete)", - examples=[["Reflect on how these values align with your goals"]], - ) - - -class ConversationSummary(BaseModel): - """Summary of a conversation for list views. - - This model provides essential conversation information for overview/list endpoints. - """ - - conversation_id: str = Field(..., description="Conversation identifier") - user_id: str = Field(..., description="User identifier") - tenant_id: str = Field(..., description="Tenant identifier") - topic: CoachingTopic = Field(..., description="Coaching topic") - status: ConversationStatus = Field(..., description="Conversation status") - progress: float = Field(..., ge=0.0, le=1.0, description="Progress") - message_count: int = Field(..., ge=0, description="Number of messages") - created_at: datetime = Field(..., description="Creation timestamp") - updated_at: datetime = Field(..., description="Last update timestamp") - completed_at: datetime | None = Field(default=None, description="Completion timestamp") - - -class ConversationListResponse(BaseModel): - """Response for listing conversations. - - This model provides paginated conversation lists. - """ - - conversations: list[ConversationSummary] = Field( - ..., - description="List of conversation summaries", - ) - total: int = Field( - ..., - ge=0, - description="Total number of conversations", - ) - page: int = Field( - default=1, - ge=1, - description="Current page number", - ) - page_size: int = Field( - default=20, - ge=1, - le=100, - description="Number of items per page", - ) - has_more: bool = Field( - ..., - description="Whether there are more pages", - ) - - -class ConversationDetailResponse(BaseModel): - """Detailed response for a specific conversation. - - This model provides complete conversation details including message history. - """ - - conversation_id: str = Field(..., description="Conversation identifier") - user_id: str = Field(..., description="User identifier") - tenant_id: str = Field(..., description="Tenant identifier") - topic: CoachingTopic = Field(..., description="Coaching topic") - status: ConversationStatus = Field(..., description="Conversation status") - progress: float = Field(..., ge=0.0, le=1.0, description="Progress") - messages: list[dict[str, Any]] = Field( - default_factory=list, - description="Conversation messages", - ) - insights: list[str] = Field( - default_factory=list, - description="Accumulated insights", - ) - identified_values: list[str] = Field( - default_factory=list, - description="Identified core values", - ) - created_at: datetime = Field(..., description="Creation timestamp") - updated_at: datetime = Field(..., description="Last update timestamp") - completed_at: datetime | None = Field(default=None, description="Completion timestamp") - metadata: dict[str, Any] = Field( - default_factory=dict, - description="Additional metadata", - ) - - -__all__ = [ - "CompleteConversationRequest", - "ConversationDetailResponse", - "ConversationListResponse", - # Responses - "ConversationResponse", - "ConversationSummary", - # Requests - "InitiateConversationRequest", - "MessageRequest", - "MessageResponse", - "PauseConversationRequest", -] +"""API models for conversation endpoints. + +This module provides Pydantic models for conversation-related API requests and responses. +These models handle API-layer concerns (serialization, validation, documentation). +""" + +from datetime import datetime +from typing import Any + +from pydantic import BaseModel, Field, field_validator + +from coaching.src.core.constants import CoachingTopic, ConversationStatus + +# Request Models + + +class InitiateConversationRequest(BaseModel): + """Request to initiate a new coaching conversation. + + This model validates and structures the initial conversation request. + Note: user_id and tenant_id are extracted from JWT token, not from request body. + """ + + topic: CoachingTopic = Field( + ..., + description="Coaching topic to focus on", + examples=[CoachingTopic.CORE_VALUES], + ) + context: dict[str, Any] = Field( + default_factory=dict, + description="Additional context for the conversation", + examples=[{"prior_sessions": 3, "last_topic": "purpose"}], + ) + language: str = Field( + default="en", + max_length=5, + description="Language code for the conversation", + examples=["en", "es", "fr"], + ) + + @field_validator("language") + @classmethod + def validate_language(cls, v: str) -> str: + """Validate language code.""" + return v.lower().strip() + + +class MessageRequest(BaseModel): + """Request to send a message in an existing conversation. + + This model validates user messages sent during a conversation. + """ + + user_message: str = Field( + ..., + min_length=1, + max_length=4000, + description="User's message content", + examples=["I value honesty and transparency"], + ) + metadata: dict[str, Any] = Field( + default_factory=dict, + description="Additional metadata for the message", + examples=[{"source": "web_app", "session_duration": 120}], + ) + + @field_validator("user_message") + @classmethod + def validate_message(cls, v: str) -> str: + """Validate message content.""" + if not v.strip(): + raise ValueError("Message cannot be empty or whitespace") + return v.strip() + + +class PauseConversationRequest(BaseModel): + """Request to pause an active conversation. + + This model captures the reason for pausing, useful for analytics. + """ + + reason: str | None = Field( + default=None, + max_length=500, + description="Optional reason for pausing", + examples=["Need to gather more information", "Break requested by user"], + ) + + +class CompleteConversationRequest(BaseModel): + """Request to mark a conversation as complete. + + This model captures user feedback at conversation end. + """ + + feedback: str | None = Field( + default=None, + max_length=1000, + description="Optional user feedback", + examples=["Very helpful session, gained clarity on my values"], + ) + rating: int | None = Field( + default=None, + ge=1, + le=5, + description="Optional rating (1-5 stars)", + examples=[5], + ) + + +# Response Models + + +class ConversationResponse(BaseModel): + """Response for conversation initiation. + + This model provides the initial state and first question to the user. + """ + + conversation_id: str = Field( + ..., + description="Unique identifier for the conversation", + examples=["conv_abc123"], + ) + user_id: str = Field( + ..., + description="User identifier", + examples=["user_123"], + ) + tenant_id: str = Field( + ..., + description="Tenant identifier", + examples=["tenant_456"], + ) + topic: CoachingTopic = Field( + ..., + description="Coaching topic", + ) + status: ConversationStatus = Field( + ..., + description="Current conversation status", + ) + initial_message: str = Field( + ..., + description="Initial coach message/question", + examples=["Welcome! Let's explore your core values together. What matters most to you?"], + ) + progress: float = Field( + ..., + ge=0.0, + le=1.0, + description="Conversation progress (0.0-1.0)", + examples=[0.1], + ) + created_at: datetime = Field( + ..., + description="Conversation creation timestamp", + ) + + model_config = { + "json_schema_extra": { + "example": { + "conversation_id": "conv_abc123", + "user_id": "user_123", + "tenant_id": "tenant_456", + "topic": "core_values", + "status": "active", + "current_phase": "introduction", + "initial_message": "Welcome! Let's explore your core values.", + "progress": 0.1, + "created_at": "2025-10-10T21:00:00Z", + } + } + } + + +class MessageResponse(BaseModel): + """Response for a message in an ongoing conversation. + + This model provides the AI's response and conversation state updates. + """ + + conversation_id: str = Field( + ..., + description="Conversation identifier", + ) + ai_response: str = Field( + ..., + description="AI coach's response", + examples=["That's wonderful! Honesty is a powerful core value."], + ) + follow_up_question: str | None = Field( + default=None, + description="Optional follow-up question", + examples=["How does honesty show up in your daily work?"], + ) + progress: float = Field( + ..., + ge=0.0, + le=1.0, + description="Conversation progress", + examples=[0.3], + ) + is_complete: bool = Field( + ..., + description="Whether conversation is complete", + examples=[False], + ) + insights: list[str] = Field( + default_factory=list, + description="Insights extracted from this exchange", + examples=[["User values honesty", "Looking for authenticity in work"]], + ) + identified_values: list[str] = Field( + default_factory=list, + description="Core values identified", + examples=[["Honesty", "Transparency", "Integrity"]], + ) + next_steps: list[str] | None = Field( + default=None, + description="Suggested next steps (if conversation complete)", + examples=[["Reflect on how these values align with your goals"]], + ) + + +class ConversationSummary(BaseModel): + """Summary of a conversation for list views. + + This model provides essential conversation information for overview/list endpoints. + """ + + conversation_id: str = Field(..., description="Conversation identifier") + user_id: str = Field(..., description="User identifier") + tenant_id: str = Field(..., description="Tenant identifier") + topic: CoachingTopic = Field(..., description="Coaching topic") + status: ConversationStatus = Field(..., description="Conversation status") + progress: float = Field(..., ge=0.0, le=1.0, description="Progress") + message_count: int = Field(..., ge=0, description="Number of messages") + created_at: datetime = Field(..., description="Creation timestamp") + updated_at: datetime = Field(..., description="Last update timestamp") + completed_at: datetime | None = Field(default=None, description="Completion timestamp") + + +class ConversationListResponse(BaseModel): + """Response for listing conversations. + + This model provides paginated conversation lists. + """ + + conversations: list[ConversationSummary] = Field( + ..., + description="List of conversation summaries", + ) + total: int = Field( + ..., + ge=0, + description="Total number of conversations", + ) + page: int = Field( + default=1, + ge=1, + description="Current page number", + ) + page_size: int = Field( + default=20, + ge=1, + le=100, + description="Number of items per page", + ) + has_more: bool = Field( + ..., + description="Whether there are more pages", + ) + + +class ConversationDetailResponse(BaseModel): + """Detailed response for a specific conversation. + + This model provides complete conversation details including message history. + """ + + conversation_id: str = Field(..., description="Conversation identifier") + user_id: str = Field(..., description="User identifier") + tenant_id: str = Field(..., description="Tenant identifier") + topic: CoachingTopic = Field(..., description="Coaching topic") + status: ConversationStatus = Field(..., description="Conversation status") + progress: float = Field(..., ge=0.0, le=1.0, description="Progress") + messages: list[dict[str, Any]] = Field( + default_factory=list, + description="Conversation messages", + ) + insights: list[str] = Field( + default_factory=list, + description="Accumulated insights", + ) + identified_values: list[str] = Field( + default_factory=list, + description="Identified core values", + ) + created_at: datetime = Field(..., description="Creation timestamp") + updated_at: datetime = Field(..., description="Last update timestamp") + completed_at: datetime | None = Field(default=None, description="Completion timestamp") + metadata: dict[str, Any] = Field( + default_factory=dict, + description="Additional metadata", + ) + + +__all__ = [ + "CompleteConversationRequest", + "ConversationDetailResponse", + "ConversationListResponse", + # Responses + "ConversationResponse", + "ConversationSummary", + # Requests + "InitiateConversationRequest", + "MessageRequest", + "MessageResponse", + "PauseConversationRequest", +] diff --git a/coaching/src/api/multitenant_dependencies.py b/coaching/src/api/multitenant_dependencies.py index 682045f9..75ba2d1b 100644 --- a/coaching/src/api/multitenant_dependencies.py +++ b/coaching/src/api/multitenant_dependencies.py @@ -5,6 +5,7 @@ import structlog from fastapi import Depends + from shared.services.aws_helpers import get_bedrock_client as get_bedrock_client_helper from shared.services.aws_helpers import get_dynamodb_resource from shared.services.aws_helpers import get_s3_client as get_s3_client_helper diff --git a/coaching/src/api/routes/_archived/conversations.py b/coaching/src/api/routes/_archived/conversations.py index a46d0083..0299bf1b 100644 --- a/coaching/src/api/routes/_archived/conversations.py +++ b/coaching/src/api/routes/_archived/conversations.py @@ -1,515 +1,516 @@ -"""Conversation API routes using new architecture (Phase 7). - -This module provides REST API endpoints for conversation management, -integrating with the Phase 4-6 application services and domain layer. -""" - -from typing import cast - -import structlog -from coaching.src.api.auth import get_current_user -from coaching.src.api.dependencies import ( - get_conversation_service, -) -from coaching.src.api.dependencies.ai_engine import get_generic_handler -from coaching.src.api.handlers.generic_ai_handler import GenericAIHandler -from coaching.src.api.models.auth import UserContext -from coaching.src.api.models.conversations import ( - CompleteConversationRequest, - ConversationDetailResponse, - ConversationListResponse, - ConversationResponse, - ConversationSummary, - InitiateConversationRequest, - MessageRequest, - MessageResponse, - PauseConversationRequest, -) -from coaching.src.application.conversation.conversation_service import ( - ConversationApplicationService, -) -from coaching.src.core.constants import CoachingTopic -from coaching.src.core.types import ConversationId, TenantId, UserId -from coaching.src.domain.entities.conversation import Conversation -from coaching.src.domain.exceptions.conversation_exceptions import ( - ConversationNotActive, - ConversationNotFound, -) -from coaching.src.domain.exceptions.topic_exceptions import TopicNotFoundError -from fastapi import APIRouter, Depends, HTTPException, Path, Query, status - -logger = structlog.get_logger() -router = APIRouter(prefix="/conversations", tags=["conversations"]) - - -@router.post("/initiate", response_model=ConversationResponse, status_code=status.HTTP_201_CREATED) -async def initiate_conversation( - request: InitiateConversationRequest, - user: UserContext = Depends(get_current_user), - handler: GenericAIHandler = Depends(get_generic_handler), -) -> ConversationResponse: - """Initiate a new coaching conversation. - - This endpoint creates a new conversation for the authenticated user - and generates an initial coaching prompt. - - **Authentication**: Bearer token required - **user_id and tenant_id**: Extracted from JWT token - - Args: - request: Conversation initiation request - user: Authenticated user context (from JWT) - handler: Generic AI handler for topic-driven execution - - Returns: - ConversationResponse with conversation details and initial message - - Raises: - HTTPException 401: If authentication fails - HTTPException 500: If conversation creation fails - """ - try: - logger.info( - "Initiating conversation", - user_id=user.user_id, - tenant_id=user.tenant_id, - topic=request.topic.value, - ) - - # Get initial prompt (system prompt) - initial_prompt = await handler.get_initial_prompt(request.topic.value) - - # Start conversation using generic handler - conversation = cast( - Conversation, - await handler.handle_conversation_initiate( - topic_id=request.topic.value, - user_context=user, - initial_parameters=request.context, - ), - ) - - logger.info( - "Conversation initiated successfully", - conversation_id=conversation.conversation_id, - user_id=user.user_id, - ) - - # Build response - return ConversationResponse( - conversation_id=conversation.conversation_id, - user_id=conversation.user_id, - tenant_id=conversation.tenant_id, - topic=CoachingTopic(conversation.topic), - status=conversation.status, - initial_message=initial_prompt, - progress=conversation.calculate_progress_percentage() / 100.0, - created_at=conversation.created_at, - ) - - except TopicNotFoundError as e: - logger.error( - "Topic not found for conversation initiation", - user_id=user.user_id, - topic=request.topic.value, - error=str(e), - ) - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail=f"Topic configuration not found: {request.topic.value}. Please check the topic ID and ensure it is properly configured.", - ) from e - except HTTPException: - raise - except Exception as e: - logger.error( - "Failed to initiate conversation", - user_id=user.user_id, - error=str(e), - exc_info=True, - ) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to initiate conversation", - ) from e - - -@router.post("/{conversation_id}/message", response_model=MessageResponse) -async def send_message( - request: MessageRequest, - conversation_id: str = Path(..., description="Conversation ID"), - user: UserContext = Depends(get_current_user), - handler: GenericAIHandler = Depends(get_generic_handler), - conversation_service: ConversationApplicationService = Depends(get_conversation_service), -) -> MessageResponse: - """Send a message in an existing conversation. - - This endpoint adds a user message to the conversation and generates - an AI coaching response. - - **Authentication**: Bearer token required - - Args: - conversation_id: Unique conversation identifier - request: Message request with user content - user: Authenticated user context - handler: Generic AI handler - conversation_service: Conversation service for fetching updated state - - Returns: - MessageResponse with AI response and conversation state - - Raises: - HTTPException 404: If conversation not found - HTTPException 403: If user doesn't own conversation - HTTPException 409: If conversation is not active - """ - try: - logger.info( - "Processing message", - conversation_id=conversation_id, - user_id=user.user_id, - message_length=len(request.user_message), - ) - - # Send message via generic handler - response_data = await handler.handle_conversation_message( - conversation_id=conversation_id, - user_message=request.user_message, - user_context=user, - ) - - # Fetch updated conversation to get full state - conversation = await conversation_service.get_conversation( - conversation_id=ConversationId(conversation_id), - tenant_id=TenantId(user.tenant_id), - ) - - # Verify ownership - if conversation.user_id != user.user_id: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Not authorized to access this conversation", - ) - - logger.info( - "Message processed successfully", - conversation_id=conversation_id, - message_count=len(conversation.messages), - is_complete=response_data.get("is_complete", False), - ) - - # Extract insights from conversation - user_messages = [msg for msg in conversation.messages if msg.is_from_user()] - insights = [f"User mentioned: {msg.content[:50]}..." for msg in user_messages[-3:]] - - return MessageResponse( - conversation_id=conversation.conversation_id, - ai_response=str(response_data.get("content", "")), - follow_up_question=None, # Could be extracted from AI response - progress=conversation.calculate_progress_percentage() / 100.0, - is_complete=bool(response_data.get("is_complete", False)), - insights=insights, - identified_values=[], # Could be extracted from context - next_steps=None, - ) - - except ConversationNotFound as e: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Conversation {conversation_id} not found", - ) from e - except ConversationNotActive as e: - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail=f"Conversation is not active: {e}", - ) from e - except Exception as e: - logger.error( - "Failed to process message", - conversation_id=conversation_id, - error=str(e), - exc_info=True, - ) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to process message", - ) from e - - -@router.get("/{conversation_id}", response_model=ConversationDetailResponse) -async def get_conversation( - conversation_id: str = Path(..., description="Conversation ID"), - user: UserContext = Depends(get_current_user), - conversation_service: ConversationApplicationService = Depends(get_conversation_service), -) -> ConversationDetailResponse: - """Get detailed information about a specific conversation. - - **Authentication**: Bearer token required - - Args: - conversation_id: Unique conversation identifier - user: Authenticated user context - conversation_service: Conversation application service - - Returns: - ConversationDetailResponse with full conversation details - - Raises: - HTTPException 404: If conversation not found - HTTPException 403: If user doesn't own conversation - """ - try: - logger.info( - "Fetching conversation", - conversation_id=conversation_id, - user_id=user.user_id, - ) - - conversation = await conversation_service.get_conversation( - conversation_id=ConversationId(conversation_id), - tenant_id=TenantId(user.tenant_id), - ) - - # Verify ownership - if conversation.user_id != user.user_id: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Not authorized to access this conversation", - ) - - # Build detailed response - return ConversationDetailResponse( - conversation_id=conversation.conversation_id, - user_id=conversation.user_id, - tenant_id=conversation.tenant_id, - topic=CoachingTopic(conversation.topic), - status=conversation.status, - progress=conversation.calculate_progress_percentage() / 100.0, - messages=[ - { - "role": msg.role.value, - "content": msg.content, - "timestamp": msg.timestamp.isoformat(), - "metadata": msg.metadata, - } - for msg in conversation.messages - ], - insights=conversation.context.insights, - identified_values=[], # Extract from context if available - created_at=conversation.created_at, - updated_at=conversation.updated_at, - completed_at=conversation.completed_at, - metadata=conversation.metadata, - ) - - except ConversationNotFound as e: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Conversation {conversation_id} not found", - ) from e - except Exception as e: - logger.error( - "Failed to fetch conversation", - conversation_id=conversation_id, - error=str(e), - exc_info=True, - ) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to fetch conversation", - ) from e - - -@router.get("/", response_model=ConversationListResponse) -async def list_conversations( - user: UserContext = Depends(get_current_user), - page: int = Query(1, ge=1, description="Page number"), - page_size: int = Query(20, ge=1, le=100, description="Items per page"), - active_only: bool = Query(False, description="Only active conversations"), - conversation_service: ConversationApplicationService = Depends(get_conversation_service), -) -> ConversationListResponse: - """List conversations for the authenticated user. - - **Authentication**: Bearer token required - - Args: - user: Authenticated user context - page: Page number for pagination - page_size: Number of items per page - active_only: Filter to only active conversations - conversation_service: Conversation application service - - Returns: - ConversationListResponse with paginated conversation list - """ - try: - logger.info( - "Listing conversations", - user_id=user.user_id, - page=page, - page_size=page_size, - active_only=active_only, - ) - - conversations = await conversation_service.list_user_conversations( - user_id=UserId(user.user_id), - tenant_id=TenantId(user.tenant_id), - limit=page_size, - active_only=active_only, - ) - - # Build summaries - summaries = [ - ConversationSummary( - conversation_id=conv.conversation_id, - user_id=conv.user_id, - tenant_id=conv.tenant_id, - topic=CoachingTopic(conv.topic), - status=conv.status, - progress=conv.calculate_progress_percentage() / 100.0, - message_count=len(conv.messages), - created_at=conv.created_at, - updated_at=conv.updated_at, - completed_at=conv.completed_at, - ) - for conv in conversations - ] - - return ConversationListResponse( - conversations=summaries, - total=len(summaries), # TODO: Get actual total from repository - page=page, - page_size=page_size, - has_more=len(summaries) == page_size, - ) - - except Exception as e: - logger.error( - "Failed to list conversations", - user_id=user.user_id, - error=str(e), - exc_info=True, - ) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to list conversations", - ) from e - - -@router.post("/{conversation_id}/pause", status_code=status.HTTP_204_NO_CONTENT) -async def pause_conversation( - conversation_id: str = Path(..., description="Conversation ID"), - request: PauseConversationRequest = PauseConversationRequest(), - user: UserContext = Depends(get_current_user), - handler: GenericAIHandler = Depends(get_generic_handler), -) -> None: - """Pause an active conversation. - - **Authentication**: Bearer token required - - Args: - conversation_id: Unique conversation identifier - request: Pause request with optional reason - user: Authenticated user context - handler: Generic AI handler - - Raises: - HTTPException 404: If conversation not found - HTTPException 403: If user doesn't own conversation - HTTPException 409: If conversation cannot be paused - """ - try: - logger.info( - "Pausing conversation", - conversation_id=conversation_id, - user_id=user.user_id, - reason=request.reason, - ) - - await handler.handle_conversation_pause( - conversation_id=conversation_id, - user_context=user, - ) - - logger.info("Conversation paused successfully", conversation_id=conversation_id) - - except ConversationNotFound as e: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Conversation {conversation_id} not found", - ) from e - - except Exception as e: - logger.error( - "Failed to pause conversation", - conversation_id=conversation_id, - error=str(e), - exc_info=True, - ) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to pause conversation", - ) from e - - -@router.post("/{conversation_id}/complete", status_code=status.HTTP_204_NO_CONTENT) -async def complete_conversation( - conversation_id: str = Path(..., description="Conversation ID"), - request: CompleteConversationRequest = CompleteConversationRequest(), - user: UserContext = Depends(get_current_user), - handler: GenericAIHandler = Depends(get_generic_handler), -) -> None: - """Mark a conversation as complete. - - **Authentication**: Bearer token required - - Args: - conversation_id: Unique conversation identifier - request: Completion request with optional feedback - user: Authenticated user context - handler: Generic AI handler - - Raises: - HTTPException 404: If conversation not found - HTTPException 403: If user doesn't own conversation - """ - try: - logger.info( - "Completing conversation", - conversation_id=conversation_id, - user_id=user.user_id, - rating=request.rating, - ) - - await handler.handle_conversation_complete( - conversation_id=conversation_id, - user_context=user, - ) - - # TODO: Store feedback and rating if provided - - logger.info("Conversation completed successfully", conversation_id=conversation_id) - - except ConversationNotFound as e: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Conversation {conversation_id} not found", - ) from e - - except Exception as e: - logger.error( - "Failed to complete conversation", - conversation_id=conversation_id, - error=str(e), - exc_info=True, - ) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to complete conversation", - ) from e - - -__all__ = ["router"] - - -__all__ = ["router"] +"""Conversation API routes using new architecture (Phase 7). + +This module provides REST API endpoints for conversation management, +integrating with the Phase 4-6 application services and domain layer. +""" + +from typing import cast + +import structlog +from fastapi import APIRouter, Depends, HTTPException, Path, Query, status + +from coaching.src.api.auth import get_current_user +from coaching.src.api.dependencies import ( + get_conversation_service, +) +from coaching.src.api.dependencies.ai_engine import get_generic_handler +from coaching.src.api.handlers.generic_ai_handler import GenericAIHandler +from coaching.src.api.models.auth import UserContext +from coaching.src.api.models.conversations import ( + CompleteConversationRequest, + ConversationDetailResponse, + ConversationListResponse, + ConversationResponse, + ConversationSummary, + InitiateConversationRequest, + MessageRequest, + MessageResponse, + PauseConversationRequest, +) +from coaching.src.application.conversation.conversation_service import ( + ConversationApplicationService, +) +from coaching.src.core.constants import CoachingTopic +from coaching.src.core.types import ConversationId, TenantId, UserId +from coaching.src.domain.entities.conversation import Conversation +from coaching.src.domain.exceptions.conversation_exceptions import ( + ConversationNotActive, + ConversationNotFound, +) +from coaching.src.domain.exceptions.topic_exceptions import TopicNotFoundError + +logger = structlog.get_logger() +router = APIRouter(prefix="/conversations", tags=["conversations"]) + + +@router.post("/initiate", response_model=ConversationResponse, status_code=status.HTTP_201_CREATED) +async def initiate_conversation( + request: InitiateConversationRequest, + user: UserContext = Depends(get_current_user), + handler: GenericAIHandler = Depends(get_generic_handler), +) -> ConversationResponse: + """Initiate a new coaching conversation. + + This endpoint creates a new conversation for the authenticated user + and generates an initial coaching prompt. + + **Authentication**: Bearer token required + **user_id and tenant_id**: Extracted from JWT token + + Args: + request: Conversation initiation request + user: Authenticated user context (from JWT) + handler: Generic AI handler for topic-driven execution + + Returns: + ConversationResponse with conversation details and initial message + + Raises: + HTTPException 401: If authentication fails + HTTPException 500: If conversation creation fails + """ + try: + logger.info( + "Initiating conversation", + user_id=user.user_id, + tenant_id=user.tenant_id, + topic=request.topic.value, + ) + + # Get initial prompt (system prompt) + initial_prompt = await handler.get_initial_prompt(request.topic.value) + + # Start conversation using generic handler + conversation = cast( + Conversation, + await handler.handle_conversation_initiate( + topic_id=request.topic.value, + user_context=user, + initial_parameters=request.context, + ), + ) + + logger.info( + "Conversation initiated successfully", + conversation_id=conversation.conversation_id, + user_id=user.user_id, + ) + + # Build response + return ConversationResponse( + conversation_id=conversation.conversation_id, + user_id=conversation.user_id, + tenant_id=conversation.tenant_id, + topic=CoachingTopic(conversation.topic), + status=conversation.status, + initial_message=initial_prompt, + progress=conversation.calculate_progress_percentage() / 100.0, + created_at=conversation.created_at, + ) + + except TopicNotFoundError as e: + logger.error( + "Topic not found for conversation initiation", + user_id=user.user_id, + topic=request.topic.value, + error=str(e), + ) + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Topic configuration not found: {request.topic.value}. Please check the topic ID and ensure it is properly configured.", + ) from e + except HTTPException: + raise + except Exception as e: + logger.error( + "Failed to initiate conversation", + user_id=user.user_id, + error=str(e), + exc_info=True, + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to initiate conversation", + ) from e + + +@router.post("/{conversation_id}/message", response_model=MessageResponse) +async def send_message( + request: MessageRequest, + conversation_id: str = Path(..., description="Conversation ID"), + user: UserContext = Depends(get_current_user), + handler: GenericAIHandler = Depends(get_generic_handler), + conversation_service: ConversationApplicationService = Depends(get_conversation_service), +) -> MessageResponse: + """Send a message in an existing conversation. + + This endpoint adds a user message to the conversation and generates + an AI coaching response. + + **Authentication**: Bearer token required + + Args: + conversation_id: Unique conversation identifier + request: Message request with user content + user: Authenticated user context + handler: Generic AI handler + conversation_service: Conversation service for fetching updated state + + Returns: + MessageResponse with AI response and conversation state + + Raises: + HTTPException 404: If conversation not found + HTTPException 403: If user doesn't own conversation + HTTPException 409: If conversation is not active + """ + try: + logger.info( + "Processing message", + conversation_id=conversation_id, + user_id=user.user_id, + message_length=len(request.user_message), + ) + + # Send message via generic handler + response_data = await handler.handle_conversation_message( + conversation_id=conversation_id, + user_message=request.user_message, + user_context=user, + ) + + # Fetch updated conversation to get full state + conversation = await conversation_service.get_conversation( + conversation_id=ConversationId(conversation_id), + tenant_id=TenantId(user.tenant_id), + ) + + # Verify ownership + if conversation.user_id != user.user_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not authorized to access this conversation", + ) + + logger.info( + "Message processed successfully", + conversation_id=conversation_id, + message_count=len(conversation.messages), + is_complete=response_data.get("is_complete", False), + ) + + # Extract insights from conversation + user_messages = [msg for msg in conversation.messages if msg.is_from_user()] + insights = [f"User mentioned: {msg.content[:50]}..." for msg in user_messages[-3:]] + + return MessageResponse( + conversation_id=conversation.conversation_id, + ai_response=str(response_data.get("content", "")), + follow_up_question=None, # Could be extracted from AI response + progress=conversation.calculate_progress_percentage() / 100.0, + is_complete=bool(response_data.get("is_complete", False)), + insights=insights, + identified_values=[], # Could be extracted from context + next_steps=None, + ) + + except ConversationNotFound as e: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Conversation {conversation_id} not found", + ) from e + except ConversationNotActive as e: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Conversation is not active: {e}", + ) from e + except Exception as e: + logger.error( + "Failed to process message", + conversation_id=conversation_id, + error=str(e), + exc_info=True, + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to process message", + ) from e + + +@router.get("/{conversation_id}", response_model=ConversationDetailResponse) +async def get_conversation( + conversation_id: str = Path(..., description="Conversation ID"), + user: UserContext = Depends(get_current_user), + conversation_service: ConversationApplicationService = Depends(get_conversation_service), +) -> ConversationDetailResponse: + """Get detailed information about a specific conversation. + + **Authentication**: Bearer token required + + Args: + conversation_id: Unique conversation identifier + user: Authenticated user context + conversation_service: Conversation application service + + Returns: + ConversationDetailResponse with full conversation details + + Raises: + HTTPException 404: If conversation not found + HTTPException 403: If user doesn't own conversation + """ + try: + logger.info( + "Fetching conversation", + conversation_id=conversation_id, + user_id=user.user_id, + ) + + conversation = await conversation_service.get_conversation( + conversation_id=ConversationId(conversation_id), + tenant_id=TenantId(user.tenant_id), + ) + + # Verify ownership + if conversation.user_id != user.user_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not authorized to access this conversation", + ) + + # Build detailed response + return ConversationDetailResponse( + conversation_id=conversation.conversation_id, + user_id=conversation.user_id, + tenant_id=conversation.tenant_id, + topic=CoachingTopic(conversation.topic), + status=conversation.status, + progress=conversation.calculate_progress_percentage() / 100.0, + messages=[ + { + "role": msg.role.value, + "content": msg.content, + "timestamp": msg.timestamp.isoformat(), + "metadata": msg.metadata, + } + for msg in conversation.messages + ], + insights=conversation.context.insights, + identified_values=[], # Extract from context if available + created_at=conversation.created_at, + updated_at=conversation.updated_at, + completed_at=conversation.completed_at, + metadata=conversation.metadata, + ) + + except ConversationNotFound as e: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Conversation {conversation_id} not found", + ) from e + except Exception as e: + logger.error( + "Failed to fetch conversation", + conversation_id=conversation_id, + error=str(e), + exc_info=True, + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to fetch conversation", + ) from e + + +@router.get("/", response_model=ConversationListResponse) +async def list_conversations( + user: UserContext = Depends(get_current_user), + page: int = Query(1, ge=1, description="Page number"), + page_size: int = Query(20, ge=1, le=100, description="Items per page"), + active_only: bool = Query(False, description="Only active conversations"), + conversation_service: ConversationApplicationService = Depends(get_conversation_service), +) -> ConversationListResponse: + """List conversations for the authenticated user. + + **Authentication**: Bearer token required + + Args: + user: Authenticated user context + page: Page number for pagination + page_size: Number of items per page + active_only: Filter to only active conversations + conversation_service: Conversation application service + + Returns: + ConversationListResponse with paginated conversation list + """ + try: + logger.info( + "Listing conversations", + user_id=user.user_id, + page=page, + page_size=page_size, + active_only=active_only, + ) + + conversations = await conversation_service.list_user_conversations( + user_id=UserId(user.user_id), + tenant_id=TenantId(user.tenant_id), + limit=page_size, + active_only=active_only, + ) + + # Build summaries + summaries = [ + ConversationSummary( + conversation_id=conv.conversation_id, + user_id=conv.user_id, + tenant_id=conv.tenant_id, + topic=CoachingTopic(conv.topic), + status=conv.status, + progress=conv.calculate_progress_percentage() / 100.0, + message_count=len(conv.messages), + created_at=conv.created_at, + updated_at=conv.updated_at, + completed_at=conv.completed_at, + ) + for conv in conversations + ] + + return ConversationListResponse( + conversations=summaries, + total=len(summaries), # TODO: Get actual total from repository + page=page, + page_size=page_size, + has_more=len(summaries) == page_size, + ) + + except Exception as e: + logger.error( + "Failed to list conversations", + user_id=user.user_id, + error=str(e), + exc_info=True, + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to list conversations", + ) from e + + +@router.post("/{conversation_id}/pause", status_code=status.HTTP_204_NO_CONTENT) +async def pause_conversation( + conversation_id: str = Path(..., description="Conversation ID"), + request: PauseConversationRequest = PauseConversationRequest(), + user: UserContext = Depends(get_current_user), + handler: GenericAIHandler = Depends(get_generic_handler), +) -> None: + """Pause an active conversation. + + **Authentication**: Bearer token required + + Args: + conversation_id: Unique conversation identifier + request: Pause request with optional reason + user: Authenticated user context + handler: Generic AI handler + + Raises: + HTTPException 404: If conversation not found + HTTPException 403: If user doesn't own conversation + HTTPException 409: If conversation cannot be paused + """ + try: + logger.info( + "Pausing conversation", + conversation_id=conversation_id, + user_id=user.user_id, + reason=request.reason, + ) + + await handler.handle_conversation_pause( + conversation_id=conversation_id, + user_context=user, + ) + + logger.info("Conversation paused successfully", conversation_id=conversation_id) + + except ConversationNotFound as e: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Conversation {conversation_id} not found", + ) from e + + except Exception as e: + logger.error( + "Failed to pause conversation", + conversation_id=conversation_id, + error=str(e), + exc_info=True, + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to pause conversation", + ) from e + + +@router.post("/{conversation_id}/complete", status_code=status.HTTP_204_NO_CONTENT) +async def complete_conversation( + conversation_id: str = Path(..., description="Conversation ID"), + request: CompleteConversationRequest = CompleteConversationRequest(), + user: UserContext = Depends(get_current_user), + handler: GenericAIHandler = Depends(get_generic_handler), +) -> None: + """Mark a conversation as complete. + + **Authentication**: Bearer token required + + Args: + conversation_id: Unique conversation identifier + request: Completion request with optional feedback + user: Authenticated user context + handler: Generic AI handler + + Raises: + HTTPException 404: If conversation not found + HTTPException 403: If user doesn't own conversation + """ + try: + logger.info( + "Completing conversation", + conversation_id=conversation_id, + user_id=user.user_id, + rating=request.rating, + ) + + await handler.handle_conversation_complete( + conversation_id=conversation_id, + user_context=user, + ) + + # TODO: Store feedback and rating if provided + + logger.info("Conversation completed successfully", conversation_id=conversation_id) + + except ConversationNotFound as e: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Conversation {conversation_id} not found", + ) from e + + except Exception as e: + logger.error( + "Failed to complete conversation", + conversation_id=conversation_id, + error=str(e), + exc_info=True, + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to complete conversation", + ) from e + + +__all__ = ["router"] + + +__all__ = ["router"] diff --git a/coaching/src/api/routes/admin/health.py b/coaching/src/api/routes/admin/health.py index dd055de3..ba5351b6 100644 --- a/coaching/src/api/routes/admin/health.py +++ b/coaching/src/api/routes/admin/health.py @@ -12,6 +12,8 @@ from typing import Literal import structlog +from fastapi import APIRouter, Depends + from coaching.src.api.dependencies import ( get_topic_repository, ) @@ -24,7 +26,6 @@ ServiceStatuses, ) from coaching.src.repositories.topic_repository import TopicRepository -from fastapi import APIRouter, Depends from shared.models.schemas import ApiResponse from shared.services.aws_helpers import get_bedrock_client, get_s3_client diff --git a/coaching/src/api/routes/admin/interactions.py b/coaching/src/api/routes/admin/interactions.py index a884a835..027a49d4 100644 --- a/coaching/src/api/routes/admin/interactions.py +++ b/coaching/src/api/routes/admin/interactions.py @@ -1,177 +1,178 @@ -"""Admin API routes for LLM interactions management. - -Endpoint Usage Status: -- GET /interactions: USED BY Admin - InteractionsPage (useLLMInteractions) -- GET /interactions/{code}: USED BY Admin - InteractionDetailsModal (useLLMInteraction) -""" - -import structlog -from coaching.src.api.auth import get_current_context -from coaching.src.api.middleware.admin_auth import require_admin_access -from coaching.src.core.llm_interactions import ( - InteractionCategory, - get_interaction, - list_interactions, -) -from coaching.src.models.admin_responses import ( - ActiveConfigurationInfo, - LLMInteractionDetail, - LLMInteractionInfo, - LLMInteractionsResponse, -) -from fastapi import APIRouter, Depends, HTTPException, Path, Query -from shared.models.multitenant import RequestContext -from shared.models.schemas import ApiResponse - -logger = structlog.get_logger() -router = APIRouter() - - -@router.get("/interactions", response_model=ApiResponse[LLMInteractionsResponse]) -async def list_llm_interactions( - category: str | None = Query(None, description="Filter by category"), - active_only: bool = Query(True, description="Only return active interactions"), - context: RequestContext = Depends(get_current_context), - _admin: RequestContext = Depends(require_admin_access), -) -> ApiResponse[LLMInteractionsResponse]: - """ - Get all available LLM interactions with their parameters. - - This endpoint returns all LLM interaction types defined in the system, - including their required and optional parameters, category, and handler class. - - **Permissions Required:** ADMIN_ACCESS - - **Query Parameters:** - - category: Filter by interaction category (analysis, coaching, operations, insights, onboarding) - - active_only: Only return active interactions (default: true) - - **Returns:** - - List of available LLM interactions - - Total count of interactions - """ - logger.info( - "Fetching LLM interactions list", - admin_user_id=context.user_id, - category=category, - active_only=active_only, - ) - - try: - # Convert category string to enum if provided - category_enum: InteractionCategory | None = None - if category: - try: - category_enum = InteractionCategory(category.lower()) - except ValueError: - valid_categories = [c.value for c in InteractionCategory] - raise HTTPException( - status_code=400, - detail=f"Invalid category '{category}'. Valid categories: {valid_categories}", - ) from None - - # Get interactions from registry - interactions = list_interactions(category=category_enum) - - # Convert to response models - interaction_infos = [ - LLMInteractionInfo( - code=interaction.code, - description=interaction.description, - category=interaction.category.value, - requiredParameters=interaction.required_parameters, - optionalParameters=interaction.optional_parameters, - handlerClass=interaction.handler_class, - ) - for interaction in interactions - ] - - response_data = LLMInteractionsResponse( - interactions=interaction_infos, - totalCount=len(interaction_infos), - ) - - logger.info( - "LLM interactions list retrieved", - admin_user_id=context.user_id, - total_count=len(interaction_infos), - category=category, - ) - - return ApiResponse(success=True, data=response_data) - - except Exception as e: - logger.error("Error listing LLM interactions", error=str(e), exc_info=True) - raise HTTPException(status_code=500, detail="Failed to list LLM interactions") from e - - -@router.get("/interactions/{interaction_code}", response_model=ApiResponse[LLMInteractionDetail]) -async def get_llm_interaction_details( - interaction_code: str = Path(..., description="Interaction code"), - context: RequestContext = Depends(get_current_context), - _admin: RequestContext = Depends(require_admin_access), -) -> ApiResponse[LLMInteractionDetail]: - """ - Get detailed information about a specific LLM interaction. - - Returns interaction details including active configurations that use this interaction. - - **Permissions Required:** ADMIN_ACCESS - - **Path Parameters:** - - interaction_code: Unique interaction code (e.g., "ALIGNMENT_ANALYSIS") - - **Returns:** - - Interaction details including parameters - - List of active configurations using this interaction - """ - logger.info( - "Fetching LLM interaction details", - admin_user_id=context.user_id, - interaction_code=interaction_code, - ) - - try: - # Get interaction from registry - interaction = get_interaction(interaction_code) - - # TODO: Fetch active configurations from configuration repository - # For now, return empty list until configuration endpoints are implemented - active_configurations: list[ActiveConfigurationInfo] = [] - - response_data = LLMInteractionDetail( - code=interaction.code, - description=interaction.description, - category=interaction.category.value, - requiredParameters=interaction.required_parameters, - optionalParameters=interaction.optional_parameters, - handlerClass=interaction.handler_class, - activeConfigurations=active_configurations, - ) - - logger.info( - "LLM interaction details retrieved", - admin_user_id=context.user_id, - interaction_code=interaction_code, - ) - - return ApiResponse(success=True, data=response_data) - - except KeyError: - logger.warning( - "LLM interaction not found", - admin_user_id=context.user_id, - interaction_code=interaction_code, - ) - raise HTTPException( - status_code=404, - detail=f"Interaction not found: {interaction_code}", - ) from None - except Exception as e: - logger.error( - "Error fetching LLM interaction details", - error=str(e), - interaction_code=interaction_code, - exc_info=True, - ) - raise HTTPException(status_code=500, detail="Failed to fetch interaction details") from e +"""Admin API routes for LLM interactions management. + +Endpoint Usage Status: +- GET /interactions: USED BY Admin - InteractionsPage (useLLMInteractions) +- GET /interactions/{code}: USED BY Admin - InteractionDetailsModal (useLLMInteraction) +""" + +import structlog +from fastapi import APIRouter, Depends, HTTPException, Path, Query + +from coaching.src.api.auth import get_current_context +from coaching.src.api.middleware.admin_auth import require_admin_access +from coaching.src.core.llm_interactions import ( + InteractionCategory, + get_interaction, + list_interactions, +) +from coaching.src.models.admin_responses import ( + ActiveConfigurationInfo, + LLMInteractionDetail, + LLMInteractionInfo, + LLMInteractionsResponse, +) +from shared.models.multitenant import RequestContext +from shared.models.schemas import ApiResponse + +logger = structlog.get_logger() +router = APIRouter() + + +@router.get("/interactions", response_model=ApiResponse[LLMInteractionsResponse]) +async def list_llm_interactions( + category: str | None = Query(None, description="Filter by category"), + active_only: bool = Query(True, description="Only return active interactions"), + context: RequestContext = Depends(get_current_context), + _admin: RequestContext = Depends(require_admin_access), +) -> ApiResponse[LLMInteractionsResponse]: + """ + Get all available LLM interactions with their parameters. + + This endpoint returns all LLM interaction types defined in the system, + including their required and optional parameters, category, and handler class. + + **Permissions Required:** ADMIN_ACCESS + + **Query Parameters:** + - category: Filter by interaction category (analysis, coaching, operations, insights, onboarding) + - active_only: Only return active interactions (default: true) + + **Returns:** + - List of available LLM interactions + - Total count of interactions + """ + logger.info( + "Fetching LLM interactions list", + admin_user_id=context.user_id, + category=category, + active_only=active_only, + ) + + try: + # Convert category string to enum if provided + category_enum: InteractionCategory | None = None + if category: + try: + category_enum = InteractionCategory(category.lower()) + except ValueError: + valid_categories = [c.value for c in InteractionCategory] + raise HTTPException( + status_code=400, + detail=f"Invalid category '{category}'. Valid categories: {valid_categories}", + ) from None + + # Get interactions from registry + interactions = list_interactions(category=category_enum) + + # Convert to response models + interaction_infos = [ + LLMInteractionInfo( + code=interaction.code, + description=interaction.description, + category=interaction.category.value, + requiredParameters=interaction.required_parameters, + optionalParameters=interaction.optional_parameters, + handlerClass=interaction.handler_class, + ) + for interaction in interactions + ] + + response_data = LLMInteractionsResponse( + interactions=interaction_infos, + totalCount=len(interaction_infos), + ) + + logger.info( + "LLM interactions list retrieved", + admin_user_id=context.user_id, + total_count=len(interaction_infos), + category=category, + ) + + return ApiResponse(success=True, data=response_data) + + except Exception as e: + logger.error("Error listing LLM interactions", error=str(e), exc_info=True) + raise HTTPException(status_code=500, detail="Failed to list LLM interactions") from e + + +@router.get("/interactions/{interaction_code}", response_model=ApiResponse[LLMInteractionDetail]) +async def get_llm_interaction_details( + interaction_code: str = Path(..., description="Interaction code"), + context: RequestContext = Depends(get_current_context), + _admin: RequestContext = Depends(require_admin_access), +) -> ApiResponse[LLMInteractionDetail]: + """ + Get detailed information about a specific LLM interaction. + + Returns interaction details including active configurations that use this interaction. + + **Permissions Required:** ADMIN_ACCESS + + **Path Parameters:** + - interaction_code: Unique interaction code (e.g., "ALIGNMENT_ANALYSIS") + + **Returns:** + - Interaction details including parameters + - List of active configurations using this interaction + """ + logger.info( + "Fetching LLM interaction details", + admin_user_id=context.user_id, + interaction_code=interaction_code, + ) + + try: + # Get interaction from registry + interaction = get_interaction(interaction_code) + + # TODO: Fetch active configurations from configuration repository + # For now, return empty list until configuration endpoints are implemented + active_configurations: list[ActiveConfigurationInfo] = [] + + response_data = LLMInteractionDetail( + code=interaction.code, + description=interaction.description, + category=interaction.category.value, + requiredParameters=interaction.required_parameters, + optionalParameters=interaction.optional_parameters, + handlerClass=interaction.handler_class, + activeConfigurations=active_configurations, + ) + + logger.info( + "LLM interaction details retrieved", + admin_user_id=context.user_id, + interaction_code=interaction_code, + ) + + return ApiResponse(success=True, data=response_data) + + except KeyError: + logger.warning( + "LLM interaction not found", + admin_user_id=context.user_id, + interaction_code=interaction_code, + ) + raise HTTPException( + status_code=404, + detail=f"Interaction not found: {interaction_code}", + ) from None + except Exception as e: + logger.error( + "Error fetching LLM interaction details", + error=str(e), + interaction_code=interaction_code, + exc_info=True, + ) + raise HTTPException(status_code=500, detail="Failed to fetch interaction details") from e diff --git a/coaching/src/api/routes/admin/llm_usage.py b/coaching/src/api/routes/admin/llm_usage.py index 2f58ca5c..b78952b8 100644 --- a/coaching/src/api/routes/admin/llm_usage.py +++ b/coaching/src/api/routes/admin/llm_usage.py @@ -3,6 +3,9 @@ from datetime import UTC, datetime import structlog +from fastapi import APIRouter, Depends, Query +from pydantic import BaseModel + from coaching.src.api.auth import get_current_context from coaching.src.api.dependencies.ai_engine import get_llm_usage_repository from coaching.src.api.middleware.admin_auth import require_admin_access @@ -15,8 +18,6 @@ from coaching.src.infrastructure.repositories.dynamodb_llm_usage_repository import ( DynamoDBLlmUsageRepository, ) -from fastapi import APIRouter, Depends, Query -from pydantic import BaseModel from shared.models.multitenant import RequestContext from shared.models.schemas import ApiResponse diff --git a/coaching/src/api/routes/admin/models.py b/coaching/src/api/routes/admin/models.py index c2c2423a..83b75beb 100644 --- a/coaching/src/api/routes/admin/models.py +++ b/coaching/src/api/routes/admin/models.py @@ -1,234 +1,235 @@ -"""Admin API routes for AI model and topic management. - -Endpoint Usage Status: -- GET /models: USED BY Admin - AIManagementPage (useAIModels), TopicMetadataEditor (useModels) -- PUT /models/{model_id}: DEPRECATED - UI doesn't call -""" - -from typing import Any - -import structlog -from coaching.src.api.dependencies import get_model_config_service -from coaching.src.api.middleware.admin_auth import require_admin_access -from coaching.src.core.llm_models import LLMProvider, list_models -from coaching.src.models.admin_requests import UpdateModelConfigRequest -from coaching.src.models.admin_responses import ( - LLMModelInfo, - LLMModelsResponse, -) -from coaching.src.services.audit_log_service import AuditLogService -from coaching.src.services.model_config_service import ModelConfigService -from fastapi import APIRouter, Body, Depends, HTTPException, Path -from shared.models.multitenant import RequestContext -from shared.models.schemas import ApiResponse - -logger = structlog.get_logger() -router = APIRouter() - - -@router.get("/models", response_model=ApiResponse[LLMModelsResponse]) -async def list_ai_models( - provider: str | None = None, - active_only: bool = True, - capability: str | None = None, - context: RequestContext = Depends(require_admin_access), -) -> ApiResponse[LLMModelsResponse]: - """ - Get all supported LLM models from MODEL_REGISTRY. - - This endpoint returns configuration for all AI models available in the system, - including pricing, capabilities, and active status. Data is sourced from - MODEL_REGISTRY in coaching/src/core/llm_models.py. - - **Permissions Required:** ADMIN_ACCESS - - **Query Parameters:** - - provider: Filter by provider (bedrock, anthropic, openai) - - active_only: Only return active models (default: true) - - capability: Filter by capability (chat, analysis, streaming, function_calling, vision) - - **Returns:** - - List of available LLM models - - List of unique providers - - Total count of models - """ - logger.info( - "Fetching LLM models from MODEL_REGISTRY", - admin_user_id=context.user_id, - provider=provider, - active_only=active_only, - capability=capability, - ) - - try: - # Convert provider string to enum if provided - provider_enum = None - if provider: - try: - provider_enum = LLMProvider(provider.lower()) - except ValueError: - valid_providers = [p.value for p in LLMProvider] - raise HTTPException( - status_code=400, - detail=f"Invalid provider '{provider}'. Valid providers: {valid_providers}", - ) from None - - # Get models from MODEL_REGISTRY - registry_models = list_models( - provider=provider_enum, - active_only=active_only, - capability=capability, - ) - - # Convert to response format - model_infos = [ - LLMModelInfo( - code=model.code, - provider=model.provider.value, - modelName=model.model_name, - version=model.version, - capabilities=model.capabilities, - maxTokens=model.max_tokens, - costPer1kTokens=model.cost_per_1k_tokens, - isActive=model.is_active, - ) - for model in registry_models - ] - - # Get unique providers - unique_providers = sorted({model.provider.value for model in registry_models}) - - response_data = LLMModelsResponse( - models=model_infos, - providers=unique_providers, - totalCount=len(model_infos), - ) - - logger.info( - "LLM models retrieved from MODEL_REGISTRY", - admin_user_id=context.user_id, - total_count=len(model_infos), - providers=unique_providers, - ) - - return ApiResponse(success=True, data=response_data) - - except HTTPException: - raise - except Exception as e: - logger.error("Error listing LLM models", error=str(e), exc_info=True) - raise HTTPException(status_code=500, detail="Failed to list LLM models") from e - - -@router.put("/models/{model_id}", response_model=ApiResponse[dict[str, Any]]) -async def update_model_configuration( - model_id: str = Path(..., description="Unique model identifier"), - request: UpdateModelConfigRequest = Body(...), - context: RequestContext = Depends(require_admin_access), - model_config_service: ModelConfigService = Depends(get_model_config_service), -) -> ApiResponse[dict[str, Any]]: - """ - Update configuration for a specific AI model. - - This endpoint allows admins to modify model settings including - pricing, operational status, and other parameters. - - **Permissions Required:** ADMIN_ACCESS - - **Parameters:** - - `modelId`: Unique model identifier - - **Request Body:** (all fields optional) - - `display_name`: Human-readable model name - - `is_active`: Whether model is available for use - - `input_cost_per_1k_tokens`: Cost per 1000 input tokens - - `output_cost_per_1k_tokens`: Cost per 1000 output tokens - - `context_window`: Maximum context window size - - `max_tokens`: Maximum output tokens - - `supports_streaming`: Whether model supports streaming - - `metadata`: Additional configuration data - - `reason`: Reason for configuration change - - **Returns:** - - Confirmation of update with changed fields - """ - logger.info( - "Updating model configuration", - model_id=model_id, - admin_user_id=context.user_id, - ) - - audit_service = AuditLogService() - - try: - # Build updates dictionary (exclude None values and reason) - updates = {} - request_dict = request.model_dump(exclude_none=True) - - # Remove reason from updates (it's for audit only) - reason = request_dict.pop("reason", None) - - # Track what changed - changes: dict[str, str] = {} - - for key, value in request_dict.items(): - updates[key] = value - changes[key] = f"updated to {value}" - - if not updates: - raise HTTPException( - status_code=400, - detail="No updates provided. At least one field must be changed.", - ) - - # Update the configuration - try: - await model_config_service.update_config(model_id, updates) - except ValueError as e: - raise HTTPException( - status_code=404, - detail=str(e), - ) from e - - # Log audit event - await audit_service.log_model_updated( - user_id=context.user_id, - tenant_id=context.tenant_id, - model_id=model_id, - changes=changes, - reason=reason, - ) - - logger.info( - "Model configuration updated", - model_id=model_id, - updated_fields=list(updates.keys()), - admin_user_id=context.user_id, - ) - - return ApiResponse( - success=True, - data={ - "message": f"Model '{model_id}' configuration updated successfully", - "model_id": model_id, - "updated_fields": list(updates.keys()), - }, - ) - - except HTTPException: - raise - except Exception as e: - logger.error( - "Failed to update model configuration", - model_id=model_id, - error=str(e), - admin_user_id=context.user_id, - ) - return ApiResponse( - success=False, - data=None, - error=f"Failed to update model configuration: {e!s}", - ) - - -__all__ = ["router"] +"""Admin API routes for AI model and topic management. + +Endpoint Usage Status: +- GET /models: USED BY Admin - AIManagementPage (useAIModels), TopicMetadataEditor (useModels) +- PUT /models/{model_id}: DEPRECATED - UI doesn't call +""" + +from typing import Any + +import structlog +from fastapi import APIRouter, Body, Depends, HTTPException, Path + +from coaching.src.api.dependencies import get_model_config_service +from coaching.src.api.middleware.admin_auth import require_admin_access +from coaching.src.core.llm_models import LLMProvider, list_models +from coaching.src.models.admin_requests import UpdateModelConfigRequest +from coaching.src.models.admin_responses import ( + LLMModelInfo, + LLMModelsResponse, +) +from coaching.src.services.audit_log_service import AuditLogService +from coaching.src.services.model_config_service import ModelConfigService +from shared.models.multitenant import RequestContext +from shared.models.schemas import ApiResponse + +logger = structlog.get_logger() +router = APIRouter() + + +@router.get("/models", response_model=ApiResponse[LLMModelsResponse]) +async def list_ai_models( + provider: str | None = None, + active_only: bool = True, + capability: str | None = None, + context: RequestContext = Depends(require_admin_access), +) -> ApiResponse[LLMModelsResponse]: + """ + Get all supported LLM models from MODEL_REGISTRY. + + This endpoint returns configuration for all AI models available in the system, + including pricing, capabilities, and active status. Data is sourced from + MODEL_REGISTRY in coaching/src/core/llm_models.py. + + **Permissions Required:** ADMIN_ACCESS + + **Query Parameters:** + - provider: Filter by provider (bedrock, anthropic, openai) + - active_only: Only return active models (default: true) + - capability: Filter by capability (chat, analysis, streaming, function_calling, vision) + + **Returns:** + - List of available LLM models + - List of unique providers + - Total count of models + """ + logger.info( + "Fetching LLM models from MODEL_REGISTRY", + admin_user_id=context.user_id, + provider=provider, + active_only=active_only, + capability=capability, + ) + + try: + # Convert provider string to enum if provided + provider_enum = None + if provider: + try: + provider_enum = LLMProvider(provider.lower()) + except ValueError: + valid_providers = [p.value for p in LLMProvider] + raise HTTPException( + status_code=400, + detail=f"Invalid provider '{provider}'. Valid providers: {valid_providers}", + ) from None + + # Get models from MODEL_REGISTRY + registry_models = list_models( + provider=provider_enum, + active_only=active_only, + capability=capability, + ) + + # Convert to response format + model_infos = [ + LLMModelInfo( + code=model.code, + provider=model.provider.value, + modelName=model.model_name, + version=model.version, + capabilities=model.capabilities, + maxTokens=model.max_tokens, + costPer1kTokens=model.cost_per_1k_tokens, + isActive=model.is_active, + ) + for model in registry_models + ] + + # Get unique providers + unique_providers = sorted({model.provider.value for model in registry_models}) + + response_data = LLMModelsResponse( + models=model_infos, + providers=unique_providers, + totalCount=len(model_infos), + ) + + logger.info( + "LLM models retrieved from MODEL_REGISTRY", + admin_user_id=context.user_id, + total_count=len(model_infos), + providers=unique_providers, + ) + + return ApiResponse(success=True, data=response_data) + + except HTTPException: + raise + except Exception as e: + logger.error("Error listing LLM models", error=str(e), exc_info=True) + raise HTTPException(status_code=500, detail="Failed to list LLM models") from e + + +@router.put("/models/{model_id}", response_model=ApiResponse[dict[str, Any]]) +async def update_model_configuration( + model_id: str = Path(..., description="Unique model identifier"), + request: UpdateModelConfigRequest = Body(...), + context: RequestContext = Depends(require_admin_access), + model_config_service: ModelConfigService = Depends(get_model_config_service), +) -> ApiResponse[dict[str, Any]]: + """ + Update configuration for a specific AI model. + + This endpoint allows admins to modify model settings including + pricing, operational status, and other parameters. + + **Permissions Required:** ADMIN_ACCESS + + **Parameters:** + - `modelId`: Unique model identifier + + **Request Body:** (all fields optional) + - `display_name`: Human-readable model name + - `is_active`: Whether model is available for use + - `input_cost_per_1k_tokens`: Cost per 1000 input tokens + - `output_cost_per_1k_tokens`: Cost per 1000 output tokens + - `context_window`: Maximum context window size + - `max_tokens`: Maximum output tokens + - `supports_streaming`: Whether model supports streaming + - `metadata`: Additional configuration data + - `reason`: Reason for configuration change + + **Returns:** + - Confirmation of update with changed fields + """ + logger.info( + "Updating model configuration", + model_id=model_id, + admin_user_id=context.user_id, + ) + + audit_service = AuditLogService() + + try: + # Build updates dictionary (exclude None values and reason) + updates = {} + request_dict = request.model_dump(exclude_none=True) + + # Remove reason from updates (it's for audit only) + reason = request_dict.pop("reason", None) + + # Track what changed + changes: dict[str, str] = {} + + for key, value in request_dict.items(): + updates[key] = value + changes[key] = f"updated to {value}" + + if not updates: + raise HTTPException( + status_code=400, + detail="No updates provided. At least one field must be changed.", + ) + + # Update the configuration + try: + await model_config_service.update_config(model_id, updates) + except ValueError as e: + raise HTTPException( + status_code=404, + detail=str(e), + ) from e + + # Log audit event + await audit_service.log_model_updated( + user_id=context.user_id, + tenant_id=context.tenant_id, + model_id=model_id, + changes=changes, + reason=reason, + ) + + logger.info( + "Model configuration updated", + model_id=model_id, + updated_fields=list(updates.keys()), + admin_user_id=context.user_id, + ) + + return ApiResponse( + success=True, + data={ + "message": f"Model '{model_id}' configuration updated successfully", + "model_id": model_id, + "updated_fields": list(updates.keys()), + }, + ) + + except HTTPException: + raise + except Exception as e: + logger.error( + "Failed to update model configuration", + model_id=model_id, + error=str(e), + admin_user_id=context.user_id, + ) + return ApiResponse( + success=False, + data=None, + error=f"Failed to update model configuration: {e!s}", + ) + + +__all__ = ["router"] diff --git a/coaching/src/api/routes/admin/prompts.py b/coaching/src/api/routes/admin/prompts.py index a9d3516f..9003a0cf 100644 --- a/coaching/src/api/routes/admin/prompts.py +++ b/coaching/src/api/routes/admin/prompts.py @@ -16,6 +16,8 @@ from typing import Annotated import structlog +from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query, status + from coaching.src.api.dependencies import get_s3_prompt_storage, get_topic_repository from coaching.src.api.middleware.admin_auth import require_admin_access from coaching.src.core.llm_models import DEFAULT_MODEL_CODE @@ -38,7 +40,6 @@ ) from coaching.src.repositories.topic_repository import TopicRepository from coaching.src.services.s3_prompt_storage import S3PromptStorage -from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query, status from shared.models.multitenant import RequestContext from shared.models.schemas import ApiResponse diff --git a/coaching/src/api/routes/admin/system_config.py b/coaching/src/api/routes/admin/system_config.py index 78fa996a..d0089307 100644 --- a/coaching/src/api/routes/admin/system_config.py +++ b/coaching/src/api/routes/admin/system_config.py @@ -8,12 +8,13 @@ from typing import Any import structlog +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel, Field + from coaching.src.api.auth import get_current_context from coaching.src.api.models.auth import UserContext from coaching.src.core.llm_models import MODEL_REGISTRY from coaching.src.services.parameter_store_service import get_parameter_store_service -from fastapi import APIRouter, Depends, HTTPException, status -from pydantic import BaseModel, Field logger = structlog.get_logger() diff --git a/coaching/src/api/routes/admin/topics.py b/coaching/src/api/routes/admin/topics.py index 532e970a..7c9dc227 100644 --- a/coaching/src/api/routes/admin/topics.py +++ b/coaching/src/api/routes/admin/topics.py @@ -23,6 +23,9 @@ from typing import Annotated, Any import structlog +from fastapi import APIRouter, Depends, HTTPException, Path, Query, Response, status +from pydantic import BaseModel, Field + from coaching.src.api.dependencies import ( get_s3_prompt_storage, get_topic_repository, @@ -90,8 +93,6 @@ ) from coaching.src.repositories.topic_repository import TopicRepository from coaching.src.services.s3_prompt_storage import S3PromptStorage -from fastapi import APIRouter, Depends, HTTPException, Path, Query, Response, status -from pydantic import BaseModel, Field from shared.models.multitenant import RequestContext from shared.models.schemas import ApiResponse diff --git a/coaching/src/api/routes/ai_execute.py b/coaching/src/api/routes/ai_execute.py index d45f468d..7fde216e 100644 --- a/coaching/src/api/routes/ai_execute.py +++ b/coaching/src/api/routes/ai_execute.py @@ -15,6 +15,8 @@ from typing import Any import structlog +from fastapi import APIRouter, Depends, HTTPException, Path, status + from coaching.src.api.dependencies.ai_engine import get_unified_ai_engine from coaching.src.api.models.ai_execute import ( GenericAIRequest, @@ -42,7 +44,6 @@ get_topic_by_topic_id, list_all_topics, ) -from fastapi import APIRouter, Depends, HTTPException, Path, status logger = structlog.get_logger() diff --git a/coaching/src/api/routes/ai_execute_async.py b/coaching/src/api/routes/ai_execute_async.py index 9f03125a..3585f3d8 100644 --- a/coaching/src/api/routes/ai_execute_async.py +++ b/coaching/src/api/routes/ai_execute_async.py @@ -11,6 +11,8 @@ """ import structlog +from fastapi import APIRouter, Depends, Header, HTTPException, Path, status + from coaching.src.api.auth import get_current_user, get_tenant_for_async_job_access from coaching.src.api.dependencies.async_execution import get_async_execution_service from coaching.src.api.models.async_ai import ( @@ -27,7 +29,6 @@ JobNotFoundError, JobValidationError, ) -from fastapi import APIRouter, Depends, Header, HTTPException, Path, status logger = structlog.get_logger() diff --git a/coaching/src/api/routes/analysis.py b/coaching/src/api/routes/analysis.py index 4ae82c47..ca2025f7 100644 --- a/coaching/src/api/routes/analysis.py +++ b/coaching/src/api/routes/analysis.py @@ -18,6 +18,8 @@ from typing import cast import structlog +from fastapi import APIRouter, Depends, status + from coaching.src.api.auth import get_current_user from coaching.src.api.dependencies.ai_engine import ( create_template_processor, @@ -36,7 +38,6 @@ StrategyAnalysisResponse, ) from coaching.src.api.models.auth import UserContext -from fastapi import APIRouter, Depends, status logger = structlog.get_logger() router = APIRouter(prefix="/analysis", tags=["analysis"]) diff --git a/coaching/src/api/routes/business_data.py b/coaching/src/api/routes/business_data.py index 4dfd748c..ee547b4b 100644 --- a/coaching/src/api/routes/business_data.py +++ b/coaching/src/api/routes/business_data.py @@ -1,87 +1,88 @@ -"""Business data and metrics API routes (Issue #65).""" - -from typing import Generic, TypeVar, cast - -import structlog -from coaching.src.api.auth import get_current_user -from coaching.src.api.dependencies.ai_engine import ( - create_template_processor, - get_generic_handler, - get_jwt_token, -) -from coaching.src.api.handlers.generic_ai_handler import GenericAIHandler -from coaching.src.api.models.auth import UserContext -from coaching.src.api.models.business_data import ( - BusinessMetricsRequest, - BusinessMetricsResponse, -) -from fastapi import APIRouter, Depends, HTTPException, status -from pydantic import BaseModel - -logger = structlog.get_logger(__name__) -router = APIRouter(tags=["business-data"]) - -T = TypeVar("T") - - -class ApiResponse(BaseModel, Generic[T]): - """Generic API response wrapper.""" - - success: bool - data: T - - -@router.get( - "/business-data", - response_model=ApiResponse[BusinessMetricsResponse], - status_code=status.HTTP_200_OK, -) -async def get_business_data_summary( - context: UserContext = Depends(get_current_user), - handler: GenericAIHandler = Depends(get_generic_handler), - jwt_token: str | None = Depends(get_jwt_token), -) -> ApiResponse[BusinessMetricsResponse]: - """Get current business data summary for the tenant. - - Migrated to unified topic-driven architecture. - Uses 'business_metrics' topic. - """ - logger.info("business_data.fetch.started", user_id=context.user_id, tenant_id=context.tenant_id) - - try: - # Create empty request for GET - request = BusinessMetricsRequest() - - template_processor = create_template_processor(jwt_token) if jwt_token else None - - result = await handler.handle_single_shot( - http_method="GET", - endpoint_path="/multitenant/conversations/business-data", - request_body=request, - user_context=context, - response_model=BusinessMetricsResponse, - template_processor=template_processor, - ) - - logger.info( - "business_data.fetch.completed", user_id=context.user_id, tenant_id=context.tenant_id - ) - return ApiResponse(success=True, data=cast(BusinessMetricsResponse, result)) - - except HTTPException: - raise - except Exception as e: - logger.error( - "business_data.fetch.failed", - error=str(e), - user_id=context.user_id, - tenant_id=context.tenant_id, - exc_info=True, - ) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to retrieve business data summary", - ) from e - - -__all__ = ["router"] +"""Business data and metrics API routes (Issue #65).""" + +from typing import Generic, TypeVar, cast + +import structlog +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel + +from coaching.src.api.auth import get_current_user +from coaching.src.api.dependencies.ai_engine import ( + create_template_processor, + get_generic_handler, + get_jwt_token, +) +from coaching.src.api.handlers.generic_ai_handler import GenericAIHandler +from coaching.src.api.models.auth import UserContext +from coaching.src.api.models.business_data import ( + BusinessMetricsRequest, + BusinessMetricsResponse, +) + +logger = structlog.get_logger(__name__) +router = APIRouter(tags=["business-data"]) + +T = TypeVar("T") + + +class ApiResponse(BaseModel, Generic[T]): + """Generic API response wrapper.""" + + success: bool + data: T + + +@router.get( + "/business-data", + response_model=ApiResponse[BusinessMetricsResponse], + status_code=status.HTTP_200_OK, +) +async def get_business_data_summary( + context: UserContext = Depends(get_current_user), + handler: GenericAIHandler = Depends(get_generic_handler), + jwt_token: str | None = Depends(get_jwt_token), +) -> ApiResponse[BusinessMetricsResponse]: + """Get current business data summary for the tenant. + + Migrated to unified topic-driven architecture. + Uses 'business_metrics' topic. + """ + logger.info("business_data.fetch.started", user_id=context.user_id, tenant_id=context.tenant_id) + + try: + # Create empty request for GET + request = BusinessMetricsRequest() + + template_processor = create_template_processor(jwt_token) if jwt_token else None + + result = await handler.handle_single_shot( + http_method="GET", + endpoint_path="/multitenant/conversations/business-data", + request_body=request, + user_context=context, + response_model=BusinessMetricsResponse, + template_processor=template_processor, + ) + + logger.info( + "business_data.fetch.completed", user_id=context.user_id, tenant_id=context.tenant_id + ) + return ApiResponse(success=True, data=cast(BusinessMetricsResponse, result)) + + except HTTPException: + raise + except Exception as e: + logger.error( + "business_data.fetch.failed", + error=str(e), + user_id=context.user_id, + tenant_id=context.tenant_id, + exc_info=True, + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to retrieve business data summary", + ) from e + + +__all__ = ["router"] diff --git a/coaching/src/api/routes/coaching.py b/coaching/src/api/routes/coaching.py index 936c5820..dc80af66 100644 --- a/coaching/src/api/routes/coaching.py +++ b/coaching/src/api/routes/coaching.py @@ -10,11 +10,12 @@ """ import structlog +from fastapi import APIRouter, Depends +from pydantic import BaseModel + from coaching.src.api.auth import get_current_context from coaching.src.models.requests import CoachingRequest from coaching.src.models.responses import CoachingResponse -from fastapi import APIRouter, Depends -from pydantic import BaseModel from shared.models.multitenant import RequestContext from shared.models.schemas import ApiResponse diff --git a/coaching/src/api/routes/coaching_ai.py b/coaching/src/api/routes/coaching_ai.py index 485d5961..7dfa4877 100644 --- a/coaching/src/api/routes/coaching_ai.py +++ b/coaching/src/api/routes/coaching_ai.py @@ -21,6 +21,8 @@ from typing import cast import structlog +from fastapi import APIRouter, Depends, status + from coaching.src.api.auth import get_current_user from coaching.src.api.dependencies.ai_engine import ( create_template_processor, @@ -45,7 +47,6 @@ StrategySuggestionsRequest, StrategySuggestionsResponse, ) -from fastapi import APIRouter, Depends, status from shared.models.schemas import ApiResponse logger = structlog.get_logger() diff --git a/coaching/src/api/routes/coaching_sessions.py b/coaching/src/api/routes/coaching_sessions.py index 4a7048c2..d2d29b65 100644 --- a/coaching/src/api/routes/coaching_sessions.py +++ b/coaching/src/api/routes/coaching_sessions.py @@ -40,6 +40,9 @@ from typing import Any import structlog +from fastapi import APIRouter, Depends, Header, HTTPException, Query +from pydantic import BaseModel, Field + from coaching.src.api.auth import get_current_context from coaching.src.api.dependencies.ai_engine import create_template_processor from coaching.src.api.multitenant_dependencies import ( @@ -79,8 +82,6 @@ TopicNotActiveError, TopicsWithStatusResponse, ) -from fastapi import APIRouter, Depends, Header, HTTPException, Query -from pydantic import BaseModel, Field from shared.models.multitenant import RequestContext from shared.models.schemas import ApiResponse from shared.services.eventbridge_client import EventBridgePublisher diff --git a/coaching/src/api/routes/health.py b/coaching/src/api/routes/health.py index b244b972..e92bd470 100644 --- a/coaching/src/api/routes/health.py +++ b/coaching/src/api/routes/health.py @@ -10,10 +10,11 @@ from typing import Any import structlog +from fastapi import APIRouter, Depends + from coaching.src.api.multitenant_dependencies import get_redis_client from coaching.src.core.config_multitenant import settings from coaching.src.models.responses import HealthCheckResponse, ReadinessCheckResponse, ServiceStatus -from fastapi import APIRouter, Depends from shared.models.schemas import ApiResponse from shared.services.aws_helpers import get_bedrock_client, get_s3_client diff --git a/coaching/src/api/routes/insights.py b/coaching/src/api/routes/insights.py index f3ff6b60..bd5f19ca 100644 --- a/coaching/src/api/routes/insights.py +++ b/coaching/src/api/routes/insights.py @@ -1,193 +1,194 @@ -"""Insights API routes - partially migrated to topic-driven architecture (Issue #113). - -Migration Status: -- generate: Migrated to topic-driven (insights_generation topic) - USED BY: FE - Dashboard.tsx (generateCoachingInsights) -- categories, priorities, dismiss, acknowledge, summary: DEPRECATED - Not called by FE -""" - -from typing import cast - -import structlog -from coaching.src.api.auth import get_current_context, get_current_user -from coaching.src.api.dependencies import get_insights_service -from coaching.src.api.dependencies.ai_engine import ( - create_template_processor, - get_generic_handler, - get_jwt_token, -) -from coaching.src.api.handlers.generic_ai_handler import GenericAIHandler -from coaching.src.api.models.auth import UserContext -from coaching.src.models.responses import ( - InsightActionResponse, - InsightResponse, - InsightsSummaryResponse, -) -from coaching.src.services.insights_service import InsightsService -from fastapi import APIRouter, Depends, Query -from pydantic import BaseModel -from shared.models.multitenant import RequestContext -from shared.models.schemas import ApiResponse, PaginatedResponse - -logger = structlog.get_logger() -router = APIRouter() - - -class InsightsGenerationRequest(BaseModel): - """Request model for insights generation.""" - - page: int = 1 - page_size: int = 20 - category: str | None = None - priority: str | None = None - status: str | None = None - - -@router.post("/generate", response_model=PaginatedResponse[InsightResponse]) -async def generate_coaching_insights( - page: int = Query(1, ge=1, description="Page number"), - page_size: int = Query(20, ge=1, le=100, description="Items per page"), - category: str | None = Query(None, description="Filter by category"), - priority: str | None = Query(None, description="Filter by priority"), - status: str | None = Query(None, description="Filter by status"), - user: UserContext = Depends(get_current_user), - handler: GenericAIHandler = Depends(get_generic_handler), - jwt_token: str | None = Depends(get_jwt_token), -) -> PaginatedResponse[InsightResponse]: - """Generate fresh coaching insights using topic-driven architecture. - - Migrated to unified topic-driven architecture (Issue #113). - Uses 'insights_generation' topic for consistent prompt management. - - This endpoint generates NEW insights from real-time business data. - """ - logger.info( - "Generating coaching insights", - user_id=user.user_id, - page=page, - page_size=page_size, - category=category, - priority=priority, - status=status, - ) - - # Create request model from query parameters - request = InsightsGenerationRequest( - page=page, page_size=page_size, category=category, priority=priority, status=status - ) - - template_processor = create_template_processor(jwt_token) if jwt_token else None - - result = await handler.handle_single_shot( - http_method="POST", - endpoint_path="/insights/generate", - request_body=request, - user_context=user, - response_model=PaginatedResponse[InsightResponse], - template_processor=template_processor, - ) - return cast(PaginatedResponse[InsightResponse], result) - - -@router.get("/categories", response_model=ApiResponse[list[str]]) -async def get_insight_categories( - _context: RequestContext = Depends(get_current_context), - service: InsightsService = Depends(get_insights_service), -) -> ApiResponse[list[str]]: - """Get available insight categories.""" - try: - categories = await service.get_categories() - return ApiResponse(success=True, data=categories) - except Exception as e: - logger.error(f"Error getting insight categories: {e}") - return ApiResponse(success=False, error="Failed to retrieve categories", data=[]) - - -@router.get("/priorities", response_model=ApiResponse[list[str]]) -async def get_insight_priorities( - _context: RequestContext = Depends(get_current_context), - service: InsightsService = Depends(get_insights_service), -) -> ApiResponse[list[str]]: - """Get available insight priorities.""" - try: - priorities = await service.get_priorities() - return ApiResponse(success=True, data=priorities) - except Exception as e: - logger.error(f"Error getting insight priorities: {e}") - return ApiResponse(success=False, error="Failed to retrieve priorities", data=[]) - - -@router.post("/{insight_id}/dismiss", response_model=ApiResponse[InsightActionResponse]) -async def dismiss_insight( - insight_id: str, - context: RequestContext = Depends(get_current_context), - service: InsightsService = Depends(get_insights_service), -) -> ApiResponse[InsightActionResponse]: - """Dismiss an insight (mark as not relevant).""" - logger.info( - "Dismissing insight", - insight_id=insight_id, - user_id=context.user_id, - tenant_id=context.tenant_id, - ) - - try: - await service.dismiss_insight(insight_id, context.user_id) - - action_response = InsightActionResponse(insight_id=insight_id, status="dismissed") - return ApiResponse( - success=True, - data=action_response, - message="Insight dismissed successfully", - ) - - except Exception as e: - logger.error(f"Error dismissing insight: {e}") - return ApiResponse(success=False, error="Failed to dismiss insight") - - -@router.post("/{insight_id}/acknowledge", response_model=ApiResponse[InsightActionResponse]) -async def acknowledge_insight( - insight_id: str, - context: RequestContext = Depends(get_current_context), - service: InsightsService = Depends(get_insights_service), -) -> ApiResponse[InsightActionResponse]: - """Acknowledge an insight (mark as reviewed).""" - logger.info( - "Acknowledging insight", - insight_id=insight_id, - user_id=context.user_id, - tenant_id=context.tenant_id, - ) - - try: - await service.acknowledge_insight(insight_id, context.user_id) - - action_response = InsightActionResponse(insight_id=insight_id, status="acknowledged") - return ApiResponse( - success=True, - data=action_response, - message="Insight acknowledged successfully", - ) - - except Exception as e: - logger.error(f"Error acknowledging insight: {e}") - return ApiResponse(success=False, error="Failed to acknowledge insight") - - -@router.get("/summary", response_model=ApiResponse[InsightsSummaryResponse]) -async def get_insights_summary( - context: RequestContext = Depends(get_current_context), - service: InsightsService = Depends(get_insights_service), -) -> ApiResponse[InsightsSummaryResponse]: - """Get insights summary with counts by category and priority.""" - logger.info("Fetching insights summary", user_id=context.user_id, tenant_id=context.tenant_id) - - try: - summary = await service.get_insights_summary(context.user_id) - - return ApiResponse(success=True, data=summary) - - except Exception as e: - logger.error(f"Error getting insights summary: {e}") - return ApiResponse(success=False, error="Failed to retrieve insights summary") +"""Insights API routes - partially migrated to topic-driven architecture (Issue #113). + +Migration Status: +- generate: Migrated to topic-driven (insights_generation topic) + USED BY: FE - Dashboard.tsx (generateCoachingInsights) +- categories, priorities, dismiss, acknowledge, summary: DEPRECATED - Not called by FE +""" + +from typing import cast + +import structlog +from fastapi import APIRouter, Depends, Query +from pydantic import BaseModel + +from coaching.src.api.auth import get_current_context, get_current_user +from coaching.src.api.dependencies import get_insights_service +from coaching.src.api.dependencies.ai_engine import ( + create_template_processor, + get_generic_handler, + get_jwt_token, +) +from coaching.src.api.handlers.generic_ai_handler import GenericAIHandler +from coaching.src.api.models.auth import UserContext +from coaching.src.models.responses import ( + InsightActionResponse, + InsightResponse, + InsightsSummaryResponse, +) +from coaching.src.services.insights_service import InsightsService +from shared.models.multitenant import RequestContext +from shared.models.schemas import ApiResponse, PaginatedResponse + +logger = structlog.get_logger() +router = APIRouter() + + +class InsightsGenerationRequest(BaseModel): + """Request model for insights generation.""" + + page: int = 1 + page_size: int = 20 + category: str | None = None + priority: str | None = None + status: str | None = None + + +@router.post("/generate", response_model=PaginatedResponse[InsightResponse]) +async def generate_coaching_insights( + page: int = Query(1, ge=1, description="Page number"), + page_size: int = Query(20, ge=1, le=100, description="Items per page"), + category: str | None = Query(None, description="Filter by category"), + priority: str | None = Query(None, description="Filter by priority"), + status: str | None = Query(None, description="Filter by status"), + user: UserContext = Depends(get_current_user), + handler: GenericAIHandler = Depends(get_generic_handler), + jwt_token: str | None = Depends(get_jwt_token), +) -> PaginatedResponse[InsightResponse]: + """Generate fresh coaching insights using topic-driven architecture. + + Migrated to unified topic-driven architecture (Issue #113). + Uses 'insights_generation' topic for consistent prompt management. + + This endpoint generates NEW insights from real-time business data. + """ + logger.info( + "Generating coaching insights", + user_id=user.user_id, + page=page, + page_size=page_size, + category=category, + priority=priority, + status=status, + ) + + # Create request model from query parameters + request = InsightsGenerationRequest( + page=page, page_size=page_size, category=category, priority=priority, status=status + ) + + template_processor = create_template_processor(jwt_token) if jwt_token else None + + result = await handler.handle_single_shot( + http_method="POST", + endpoint_path="/insights/generate", + request_body=request, + user_context=user, + response_model=PaginatedResponse[InsightResponse], + template_processor=template_processor, + ) + return cast(PaginatedResponse[InsightResponse], result) + + +@router.get("/categories", response_model=ApiResponse[list[str]]) +async def get_insight_categories( + _context: RequestContext = Depends(get_current_context), + service: InsightsService = Depends(get_insights_service), +) -> ApiResponse[list[str]]: + """Get available insight categories.""" + try: + categories = await service.get_categories() + return ApiResponse(success=True, data=categories) + except Exception as e: + logger.error(f"Error getting insight categories: {e}") + return ApiResponse(success=False, error="Failed to retrieve categories", data=[]) + + +@router.get("/priorities", response_model=ApiResponse[list[str]]) +async def get_insight_priorities( + _context: RequestContext = Depends(get_current_context), + service: InsightsService = Depends(get_insights_service), +) -> ApiResponse[list[str]]: + """Get available insight priorities.""" + try: + priorities = await service.get_priorities() + return ApiResponse(success=True, data=priorities) + except Exception as e: + logger.error(f"Error getting insight priorities: {e}") + return ApiResponse(success=False, error="Failed to retrieve priorities", data=[]) + + +@router.post("/{insight_id}/dismiss", response_model=ApiResponse[InsightActionResponse]) +async def dismiss_insight( + insight_id: str, + context: RequestContext = Depends(get_current_context), + service: InsightsService = Depends(get_insights_service), +) -> ApiResponse[InsightActionResponse]: + """Dismiss an insight (mark as not relevant).""" + logger.info( + "Dismissing insight", + insight_id=insight_id, + user_id=context.user_id, + tenant_id=context.tenant_id, + ) + + try: + await service.dismiss_insight(insight_id, context.user_id) + + action_response = InsightActionResponse(insight_id=insight_id, status="dismissed") + return ApiResponse( + success=True, + data=action_response, + message="Insight dismissed successfully", + ) + + except Exception as e: + logger.error(f"Error dismissing insight: {e}") + return ApiResponse(success=False, error="Failed to dismiss insight") + + +@router.post("/{insight_id}/acknowledge", response_model=ApiResponse[InsightActionResponse]) +async def acknowledge_insight( + insight_id: str, + context: RequestContext = Depends(get_current_context), + service: InsightsService = Depends(get_insights_service), +) -> ApiResponse[InsightActionResponse]: + """Acknowledge an insight (mark as reviewed).""" + logger.info( + "Acknowledging insight", + insight_id=insight_id, + user_id=context.user_id, + tenant_id=context.tenant_id, + ) + + try: + await service.acknowledge_insight(insight_id, context.user_id) + + action_response = InsightActionResponse(insight_id=insight_id, status="acknowledged") + return ApiResponse( + success=True, + data=action_response, + message="Insight acknowledged successfully", + ) + + except Exception as e: + logger.error(f"Error acknowledging insight: {e}") + return ApiResponse(success=False, error="Failed to acknowledge insight") + + +@router.get("/summary", response_model=ApiResponse[InsightsSummaryResponse]) +async def get_insights_summary( + context: RequestContext = Depends(get_current_context), + service: InsightsService = Depends(get_insights_service), +) -> ApiResponse[InsightsSummaryResponse]: + """Get insights summary with counts by category and priority.""" + logger.info("Fetching insights summary", user_id=context.user_id, tenant_id=context.tenant_id) + + try: + summary = await service.get_insights_summary(context.user_id) + + return ApiResponse(success=True, data=summary) + + except Exception as e: + logger.error(f"Error getting insights summary: {e}") + return ApiResponse(success=False, error="Failed to retrieve insights summary") diff --git a/coaching/src/api/routes/multitenant_conversations.py b/coaching/src/api/routes/multitenant_conversations.py index eb8d2e2a..41c6a777 100644 --- a/coaching/src/api/routes/multitenant_conversations.py +++ b/coaching/src/api/routes/multitenant_conversations.py @@ -1,361 +1,362 @@ -"""Multitenant conversation API routes with business data integration. - -DEPRECATION NOTICE: -Most endpoints in this file are DEPRECATED and superseded by /ai/coaching/* endpoints. -See coaching_sessions.py for the new coaching conversation API. - -Endpoint Status: -- /business-data: USED BY FE - getBusinessMetrics() -- /initiate, /{id}/message, /{id}/complete, /{id}/pause, /{id} DELETE: DEPRECATED -- /, /tenant/all: DEPRECATED - -Migration target: /ai/coaching/* (coaching_sessions.py) -""" - -from typing import Any - -import structlog -from coaching.src.api.auth import get_current_context, require_admin -from coaching.src.api.dependencies import get_conversation_repository -from coaching.src.api.multitenant_dependencies import get_multitenant_conversation_service -from coaching.src.models.requests import ( - CompleteConversationRequest, - InitiateConversationRequest, - MessageRequest, - PauseConversationRequest, -) -from coaching.src.models.responses import ( - BusinessDataSummaryResponse, - ConversationActionResponse, - ConversationDetailResponse, - ConversationListResponse, - ConversationResponse, - MessageResponse, -) -from coaching.src.services.multitenant_conversation_service import MultitenantConversationService -from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query -from shared.models.multitenant import CoachingTopic, RequestContext, UserRole -from shared.models.schemas import ApiResponse - -logger = structlog.get_logger() -router = APIRouter() - - -@router.post("/initiate", response_model=ApiResponse[ConversationResponse]) -async def initiate_conversation( - request: InitiateConversationRequest, - context: RequestContext = Depends(get_current_context), - service: MultitenantConversationService = Depends(get_multitenant_conversation_service), -) -> ApiResponse[ConversationResponse]: - """Initiate a new coaching conversation with tenant context.""" - logger.info( - "Initiating conversation", - user_id=context.user_id, - tenant_id=context.tenant_id, - topic=request.topic.value, - ) - - # All active users can start coaching sessions - # (Role-based restrictions removed - use UserLimitsService for subscription limits) - - try: - response = await service.initiate_conversation( - topic=CoachingTopic(request.topic.value), - context_data=request.context, - language=request.language, - ) - - logger.info( - "Conversation initiated", - conversation_id=response.conversation_id, - user_id=context.user_id, - tenant_id=context.tenant_id, - session_id=response.session_data.get("session_id") if response.session_data else None, - ) - - return ApiResponse(success=True, data=response) - - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) from e - - -@router.post("/{conversation_id}/message", response_model=MessageResponse) -async def send_message( - conversation_id: str = Path(..., description="Conversation ID"), - request: MessageRequest = Body(...), - context: RequestContext = Depends(get_current_context), - service: MultitenantConversationService = Depends(get_multitenant_conversation_service), -) -> MessageResponse: - """Send a message in an existing conversation.""" - logger.info( - "Processing message", - conversation_id=conversation_id, - user_id=context.user_id, - tenant_id=context.tenant_id, - message_length=len(request.user_message), - ) - - try: - response = await service.process_message( - conversation_id=conversation_id, - user_message=request.user_message, - metadata=request.metadata, - ) - - logger.info( - "Message processed", - conversation_id=conversation_id, - user_id=context.user_id, - tenant_id=context.tenant_id, - is_complete=response.is_complete, - progress=response.progress, - ) - - return response - - except PermissionError as e: - raise HTTPException(status_code=403, detail="Access denied to this conversation") from e - - -@router.get("/business-data", response_model=ApiResponse[BusinessDataSummaryResponse]) -async def get_business_data_summary( - context: RequestContext = Depends(get_current_context), - service: MultitenantConversationService = Depends(get_multitenant_conversation_service), -) -> ApiResponse[BusinessDataSummaryResponse]: - """Get current business data summary for the tenant.""" - logger.info( - "Fetching business data summary", user_id=context.user_id, tenant_id=context.tenant_id - ) - - try: - # All active users can read business data - # (Permission checks removed - authenticated users have access) - - summary = service.get_business_data_summary() - - business_summary = BusinessDataSummaryResponse( - tenant_id=context.tenant_id, business_data=dict(summary) - ) - return ApiResponse(success=True, data=business_summary) - - except Exception as e: - logger.error(f"Error getting business data summary: {e}") - return ApiResponse(success=False, error=f"Failed to retrieve business data summary: {e}") - - -@router.get("/{conversation_id}", response_model=ConversationDetailResponse) -async def get_conversation( - conversation_id: str = Path(..., description="Conversation ID"), - context: RequestContext = Depends(get_current_context), - conversation_repo: Any = Depends(get_conversation_repository), -) -> ConversationDetailResponse: - """Get details of a specific conversation.""" - logger.info( - "Fetching conversation", - conversation_id=conversation_id, - user_id=context.user_id, - tenant_id=context.tenant_id, - ) - - try: - conversation = await conversation_repo.get(conversation_id) - - if not conversation: - raise HTTPException(status_code=404, detail=f"Conversation {conversation_id} not found") - - # Verify tenant access - if conversation.context.get("tenant_id") != context.tenant_id: - raise HTTPException(status_code=403, detail="Access denied to this conversation") - - # Check permissions (users can view their own sessions, admins can view all) - if conversation.user_id != context.user_id and context.role not in [ - UserRole.ADMIN, - UserRole.OWNER, - ]: - raise HTTPException( - status_code=403, - detail="Can only view your own conversations unless you are an admin", - ) - - return ConversationDetailResponse( - conversation_id=conversation.conversation_id, - user_id=conversation.user_id, - topic=conversation.topic, - status=conversation.status, - messages=[ - { - "role": msg.role.value, - "content": msg.content, - "timestamp": msg.timestamp.isoformat(), - "metadata": msg.metadata, - } - for msg in conversation.messages - ], - context=conversation.context.model_dump(), - progress=conversation.calculate_progress(), - created_at=conversation.created_at, - updated_at=conversation.updated_at, - completed_at=conversation.completed_at, - ) - - except PermissionError as e: - raise HTTPException(status_code=403, detail="Access denied to this conversation") from e - - -@router.post("/{conversation_id}/complete") -async def complete_conversation( - conversation_id: str = Path(..., description="Conversation ID"), - request: CompleteConversationRequest | None = None, - context: RequestContext = Depends(get_current_context), - service: MultitenantConversationService = Depends(get_multitenant_conversation_service), -) -> ConversationActionResponse: - """Mark a conversation as complete and extract business outcomes.""" - logger.info( - "Completing conversation", - conversation_id=conversation_id, - user_id=context.user_id, - tenant_id=context.tenant_id, - ) - - try: - result = await service.complete_conversation( - conversation_id=conversation_id, - _feedback=request.feedback if request else None, - _rating=request.rating if request else None, - ) - - logger.info( - "Conversation completed", - conversation_id=conversation_id, - user_id=context.user_id, - tenant_id=context.tenant_id, - business_data_updated=result.get("business_data_updated", False), - ) - - return ConversationActionResponse( - message="Conversation completed successfully", result=dict(result) - ) - - except PermissionError as e: - raise HTTPException(status_code=403, detail="Access denied to this conversation") from e - - -@router.post("/{conversation_id}/pause") -async def pause_conversation( - conversation_id: str = Path(..., description="Conversation ID"), - _request: PauseConversationRequest | None = None, - context: RequestContext = Depends(get_current_context), - _service: MultitenantConversationService = Depends(get_multitenant_conversation_service), -) -> ConversationActionResponse: - """Pause an active conversation.""" - logger.info( - "Pausing conversation", - conversation_id=conversation_id, - user_id=context.user_id, - tenant_id=context.tenant_id, - ) - - # For now, delegate to the base service - # TODO: Implement tenant-aware pause functionality - # Pause functionality not yet implemented in MultitenantConversationService - raise HTTPException(status_code=501, detail="Pause conversation feature not implemented") - - return ConversationActionResponse(message="Conversation paused successfully") - - -@router.delete("/{conversation_id}") -async def delete_conversation( - conversation_id: str = Path(..., description="Conversation ID"), - context: RequestContext = Depends(get_current_context), - _service: MultitenantConversationService = Depends(get_multitenant_conversation_service), -) -> ConversationActionResponse: - """Delete (abandon) a conversation.""" - logger.info( - "Deleting conversation", - conversation_id=conversation_id, - user_id=context.user_id, - tenant_id=context.tenant_id, - ) - - # Delete functionality not yet implemented in MultitenantConversationService - raise HTTPException(status_code=501, detail="Delete conversation feature not implemented") - - return ConversationActionResponse(message="Conversation deleted successfully") - - -@router.get("/", response_model=ConversationListResponse) -async def list_conversations( - page: int = Query(1, ge=1, description="Page number"), - page_size: int = Query(20, ge=1, le=100, description="Items per page"), - status: str | None = Query(None, description="Filter by status"), - topic: str | None = Query(None, description="Filter by topic"), - context: RequestContext = Depends(get_current_context), - service: MultitenantConversationService = Depends(get_multitenant_conversation_service), -) -> ConversationListResponse: - """List conversations for the current user within their tenant.""" - logger.info( - "Listing conversations", - user_id=context.user_id, - tenant_id=context.tenant_id, - page=page, - page_size=page_size, - status=status, - topic=topic, - ) - - coaching_topic = None - if topic: - try: - coaching_topic = CoachingTopic(topic) - except ValueError as e: - raise HTTPException(status_code=400, detail=f"Invalid topic: {topic}") from e - - response = await service.list_user_conversations( - page=page, - page_size=page_size, - status=status, - topic=coaching_topic, - ) - - return response - - -@router.get("/tenant/all", response_model=ConversationListResponse) -async def list_all_tenant_conversations( - page: int = Query(1, ge=1, description="Page number"), - page_size: int = Query(20, ge=1, le=100, description="Items per page"), - status: str | None = Query(None, description="Filter by status"), - topic: str | None = Query(None, description="Filter by topic"), - user_id: str | None = Query(None, description="Filter by user ID"), - context: RequestContext = Depends(require_admin()), - service: MultitenantConversationService = Depends(get_multitenant_conversation_service), -) -> ConversationListResponse: - """List all conversations within the tenant (admin/manager only).""" - logger.info( - "Listing all tenant conversations", - admin_user_id=context.user_id, - tenant_id=context.tenant_id, - filter_user_id=user_id, - page=page, - page_size=page_size, - status=status, - topic=topic, - ) - - # TODO: Implement tenant-wide conversation listing - # For now, return user's own conversations - coaching_topic = None - if topic: - try: - coaching_topic = CoachingTopic(topic) - except ValueError as e: - raise HTTPException(status_code=400, detail=f"Invalid topic: {topic}") from e - - response = await service.list_user_conversations( - page=page, - page_size=page_size, - status=status, - topic=coaching_topic, - ) - - return response +"""Multitenant conversation API routes with business data integration. + +DEPRECATION NOTICE: +Most endpoints in this file are DEPRECATED and superseded by /ai/coaching/* endpoints. +See coaching_sessions.py for the new coaching conversation API. + +Endpoint Status: +- /business-data: USED BY FE - getBusinessMetrics() +- /initiate, /{id}/message, /{id}/complete, /{id}/pause, /{id} DELETE: DEPRECATED +- /, /tenant/all: DEPRECATED + +Migration target: /ai/coaching/* (coaching_sessions.py) +""" + +from typing import Any + +import structlog +from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query + +from coaching.src.api.auth import get_current_context, require_admin +from coaching.src.api.dependencies import get_conversation_repository +from coaching.src.api.multitenant_dependencies import get_multitenant_conversation_service +from coaching.src.models.requests import ( + CompleteConversationRequest, + InitiateConversationRequest, + MessageRequest, + PauseConversationRequest, +) +from coaching.src.models.responses import ( + BusinessDataSummaryResponse, + ConversationActionResponse, + ConversationDetailResponse, + ConversationListResponse, + ConversationResponse, + MessageResponse, +) +from coaching.src.services.multitenant_conversation_service import MultitenantConversationService +from shared.models.multitenant import CoachingTopic, RequestContext, UserRole +from shared.models.schemas import ApiResponse + +logger = structlog.get_logger() +router = APIRouter() + + +@router.post("/initiate", response_model=ApiResponse[ConversationResponse]) +async def initiate_conversation( + request: InitiateConversationRequest, + context: RequestContext = Depends(get_current_context), + service: MultitenantConversationService = Depends(get_multitenant_conversation_service), +) -> ApiResponse[ConversationResponse]: + """Initiate a new coaching conversation with tenant context.""" + logger.info( + "Initiating conversation", + user_id=context.user_id, + tenant_id=context.tenant_id, + topic=request.topic.value, + ) + + # All active users can start coaching sessions + # (Role-based restrictions removed - use UserLimitsService for subscription limits) + + try: + response = await service.initiate_conversation( + topic=CoachingTopic(request.topic.value), + context_data=request.context, + language=request.language, + ) + + logger.info( + "Conversation initiated", + conversation_id=response.conversation_id, + user_id=context.user_id, + tenant_id=context.tenant_id, + session_id=response.session_data.get("session_id") if response.session_data else None, + ) + + return ApiResponse(success=True, data=response) + + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + + +@router.post("/{conversation_id}/message", response_model=MessageResponse) +async def send_message( + conversation_id: str = Path(..., description="Conversation ID"), + request: MessageRequest = Body(...), + context: RequestContext = Depends(get_current_context), + service: MultitenantConversationService = Depends(get_multitenant_conversation_service), +) -> MessageResponse: + """Send a message in an existing conversation.""" + logger.info( + "Processing message", + conversation_id=conversation_id, + user_id=context.user_id, + tenant_id=context.tenant_id, + message_length=len(request.user_message), + ) + + try: + response = await service.process_message( + conversation_id=conversation_id, + user_message=request.user_message, + metadata=request.metadata, + ) + + logger.info( + "Message processed", + conversation_id=conversation_id, + user_id=context.user_id, + tenant_id=context.tenant_id, + is_complete=response.is_complete, + progress=response.progress, + ) + + return response + + except PermissionError as e: + raise HTTPException(status_code=403, detail="Access denied to this conversation") from e + + +@router.get("/business-data", response_model=ApiResponse[BusinessDataSummaryResponse]) +async def get_business_data_summary( + context: RequestContext = Depends(get_current_context), + service: MultitenantConversationService = Depends(get_multitenant_conversation_service), +) -> ApiResponse[BusinessDataSummaryResponse]: + """Get current business data summary for the tenant.""" + logger.info( + "Fetching business data summary", user_id=context.user_id, tenant_id=context.tenant_id + ) + + try: + # All active users can read business data + # (Permission checks removed - authenticated users have access) + + summary = service.get_business_data_summary() + + business_summary = BusinessDataSummaryResponse( + tenant_id=context.tenant_id, business_data=dict(summary) + ) + return ApiResponse(success=True, data=business_summary) + + except Exception as e: + logger.error(f"Error getting business data summary: {e}") + return ApiResponse(success=False, error=f"Failed to retrieve business data summary: {e}") + + +@router.get("/{conversation_id}", response_model=ConversationDetailResponse) +async def get_conversation( + conversation_id: str = Path(..., description="Conversation ID"), + context: RequestContext = Depends(get_current_context), + conversation_repo: Any = Depends(get_conversation_repository), +) -> ConversationDetailResponse: + """Get details of a specific conversation.""" + logger.info( + "Fetching conversation", + conversation_id=conversation_id, + user_id=context.user_id, + tenant_id=context.tenant_id, + ) + + try: + conversation = await conversation_repo.get(conversation_id) + + if not conversation: + raise HTTPException(status_code=404, detail=f"Conversation {conversation_id} not found") + + # Verify tenant access + if conversation.context.get("tenant_id") != context.tenant_id: + raise HTTPException(status_code=403, detail="Access denied to this conversation") + + # Check permissions (users can view their own sessions, admins can view all) + if conversation.user_id != context.user_id and context.role not in [ + UserRole.ADMIN, + UserRole.OWNER, + ]: + raise HTTPException( + status_code=403, + detail="Can only view your own conversations unless you are an admin", + ) + + return ConversationDetailResponse( + conversation_id=conversation.conversation_id, + user_id=conversation.user_id, + topic=conversation.topic, + status=conversation.status, + messages=[ + { + "role": msg.role.value, + "content": msg.content, + "timestamp": msg.timestamp.isoformat(), + "metadata": msg.metadata, + } + for msg in conversation.messages + ], + context=conversation.context.model_dump(), + progress=conversation.calculate_progress(), + created_at=conversation.created_at, + updated_at=conversation.updated_at, + completed_at=conversation.completed_at, + ) + + except PermissionError as e: + raise HTTPException(status_code=403, detail="Access denied to this conversation") from e + + +@router.post("/{conversation_id}/complete") +async def complete_conversation( + conversation_id: str = Path(..., description="Conversation ID"), + request: CompleteConversationRequest | None = None, + context: RequestContext = Depends(get_current_context), + service: MultitenantConversationService = Depends(get_multitenant_conversation_service), +) -> ConversationActionResponse: + """Mark a conversation as complete and extract business outcomes.""" + logger.info( + "Completing conversation", + conversation_id=conversation_id, + user_id=context.user_id, + tenant_id=context.tenant_id, + ) + + try: + result = await service.complete_conversation( + conversation_id=conversation_id, + _feedback=request.feedback if request else None, + _rating=request.rating if request else None, + ) + + logger.info( + "Conversation completed", + conversation_id=conversation_id, + user_id=context.user_id, + tenant_id=context.tenant_id, + business_data_updated=result.get("business_data_updated", False), + ) + + return ConversationActionResponse( + message="Conversation completed successfully", result=dict(result) + ) + + except PermissionError as e: + raise HTTPException(status_code=403, detail="Access denied to this conversation") from e + + +@router.post("/{conversation_id}/pause") +async def pause_conversation( + conversation_id: str = Path(..., description="Conversation ID"), + _request: PauseConversationRequest | None = None, + context: RequestContext = Depends(get_current_context), + _service: MultitenantConversationService = Depends(get_multitenant_conversation_service), +) -> ConversationActionResponse: + """Pause an active conversation.""" + logger.info( + "Pausing conversation", + conversation_id=conversation_id, + user_id=context.user_id, + tenant_id=context.tenant_id, + ) + + # For now, delegate to the base service + # TODO: Implement tenant-aware pause functionality + # Pause functionality not yet implemented in MultitenantConversationService + raise HTTPException(status_code=501, detail="Pause conversation feature not implemented") + + return ConversationActionResponse(message="Conversation paused successfully") + + +@router.delete("/{conversation_id}") +async def delete_conversation( + conversation_id: str = Path(..., description="Conversation ID"), + context: RequestContext = Depends(get_current_context), + _service: MultitenantConversationService = Depends(get_multitenant_conversation_service), +) -> ConversationActionResponse: + """Delete (abandon) a conversation.""" + logger.info( + "Deleting conversation", + conversation_id=conversation_id, + user_id=context.user_id, + tenant_id=context.tenant_id, + ) + + # Delete functionality not yet implemented in MultitenantConversationService + raise HTTPException(status_code=501, detail="Delete conversation feature not implemented") + + return ConversationActionResponse(message="Conversation deleted successfully") + + +@router.get("/", response_model=ConversationListResponse) +async def list_conversations( + page: int = Query(1, ge=1, description="Page number"), + page_size: int = Query(20, ge=1, le=100, description="Items per page"), + status: str | None = Query(None, description="Filter by status"), + topic: str | None = Query(None, description="Filter by topic"), + context: RequestContext = Depends(get_current_context), + service: MultitenantConversationService = Depends(get_multitenant_conversation_service), +) -> ConversationListResponse: + """List conversations for the current user within their tenant.""" + logger.info( + "Listing conversations", + user_id=context.user_id, + tenant_id=context.tenant_id, + page=page, + page_size=page_size, + status=status, + topic=topic, + ) + + coaching_topic = None + if topic: + try: + coaching_topic = CoachingTopic(topic) + except ValueError as e: + raise HTTPException(status_code=400, detail=f"Invalid topic: {topic}") from e + + response = await service.list_user_conversations( + page=page, + page_size=page_size, + status=status, + topic=coaching_topic, + ) + + return response + + +@router.get("/tenant/all", response_model=ConversationListResponse) +async def list_all_tenant_conversations( + page: int = Query(1, ge=1, description="Page number"), + page_size: int = Query(20, ge=1, le=100, description="Items per page"), + status: str | None = Query(None, description="Filter by status"), + topic: str | None = Query(None, description="Filter by topic"), + user_id: str | None = Query(None, description="Filter by user ID"), + context: RequestContext = Depends(require_admin()), + service: MultitenantConversationService = Depends(get_multitenant_conversation_service), +) -> ConversationListResponse: + """List all conversations within the tenant (admin/manager only).""" + logger.info( + "Listing all tenant conversations", + admin_user_id=context.user_id, + tenant_id=context.tenant_id, + filter_user_id=user_id, + page=page, + page_size=page_size, + status=status, + topic=topic, + ) + + # TODO: Implement tenant-wide conversation listing + # For now, return user's own conversations + coaching_topic = None + if topic: + try: + coaching_topic = CoachingTopic(topic) + except ValueError as e: + raise HTTPException(status_code=400, detail=f"Invalid topic: {topic}") from e + + response = await service.list_user_conversations( + page=page, + page_size=page_size, + status=status, + topic=coaching_topic, + ) + + return response diff --git a/coaching/src/application/ai_engine/response_serializer.py b/coaching/src/application/ai_engine/response_serializer.py index 77a18bfc..29fda371 100644 --- a/coaching/src/application/ai_engine/response_serializer.py +++ b/coaching/src/application/ai_engine/response_serializer.py @@ -9,9 +9,10 @@ from typing import Any, TypeVar import structlog -from coaching.src.domain.entities.llm_topic import LLMTopic from pydantic import BaseModel, ValidationError +from coaching.src.domain.entities.llm_topic import LLMTopic + logger = structlog.get_logger() T = TypeVar("T", bound=BaseModel) diff --git a/coaching/src/application/analysis/alignment_service.py b/coaching/src/application/analysis/alignment_service.py index 6cf712d6..363fa4b7 100644 --- a/coaching/src/application/analysis/alignment_service.py +++ b/coaching/src/application/analysis/alignment_service.py @@ -1,177 +1,178 @@ -"""Alignment analysis service. - -Analyzes how well user's actions/plans align with their purpose, values, and goals. -Provides scoring, explanation, and improvement suggestions. -""" - -import json -from typing import Any - -import structlog -from coaching.src.application.analysis.base_analysis_service import BaseAnalysisService -from coaching.src.core.constants import AnalysisType - -logger = structlog.get_logger() - - -class AlignmentAnalysisService(BaseAnalysisService): - """ - Service for alignment analysis. - - Analyzes alignment between: - - Actions and purpose/values - - Plans and goals - - Current state and desired state - - Output: - - Alignment score (0-100) - - Explanation of score - - Specific misalignments identified - - Recommendations for improvement - """ - - def get_analysis_type(self) -> AnalysisType: - """Return ALIGNMENT analysis type.""" - return AnalysisType.ALIGNMENT - - def build_prompt(self, context: dict[str, Any]) -> str: - """ - Build alignment analysis prompt. - - Required context: - - user_id: User identifier - - purpose: User's purpose statement (optional) - - values: List of core values (optional) - - goals: User's goals (optional) - - current_actions: Current activities/plans to analyze - - Returns: - Formatted prompt for alignment analysis - """ - purpose = context.get("purpose", "Not defined") - values = context.get("values", []) - goals = context.get("goals", []) - current_actions = context.get("current_actions", "") - - values_str = ", ".join(values) if values else "Not defined" - goals_str = "\n".join([f"- {g}" for g in goals]) if goals else "Not defined" - - prompt = f"""You are an expert business coach analyzing alignment between a user's purpose, values, goals, and their current actions/plans. - -**User's Purpose:** -{purpose} - -**User's Core Values:** -{values_str} - -**User's Goals:** -{goals_str} - -**Current Actions/Plans to Analyze:** -{current_actions} - -Please analyze the alignment and provide your response in the following JSON format: - -{{ - "alignment_score": , - "overall_assessment": "", - "strengths": [ - "" - ], - "misalignments": [ - {{ - "area": "", - "explanation": "", - "impact": "" - }} - ], - "recommendations": [ - {{ - "action": "", - "rationale": "", - "priority": "" - }} - ] -}} - -Provide a thorough, actionable analysis.""" - - return prompt - - def parse_response(self, llm_response: str) -> dict[str, Any]: - """ - Parse alignment analysis response. - - Args: - llm_response: Raw LLM JSON response - - Returns: - Structured alignment analysis result - - Expected structure: - - alignment_score: int (0-100) - - overall_assessment: str - - strengths: list[str] - - misalignments: list[dict] - - recommendations: list[dict] - """ - try: - # Try to extract JSON from response (may have markdown code blocks) - response_text = llm_response.strip() - - # Remove markdown code blocks if present - if response_text.startswith("```json"): - response_text = response_text[7:] - if response_text.startswith("```"): - response_text = response_text[3:] - if response_text.endswith("```"): - response_text = response_text[:-3] - - result: dict[str, Any] = json.loads(response_text.strip()) - - # Validate required fields - required_fields = [ - "alignment_score", - "overall_assessment", - "strengths", - "misalignments", - "recommendations", - ] - for field in required_fields: - if field not in result: - raise ValueError(f"Missing required field: {field}") - - # Validate alignment score range - if not 0 <= result["alignment_score"] <= 100: - logger.warning( - "Alignment score out of range, capping", - score=result["alignment_score"], - ) - result["alignment_score"] = max(0, min(100, result["alignment_score"])) - - logger.debug( - "Alignment response parsed", - score=result["alignment_score"], - misalignments_count=len(result["misalignments"]), - recommendations_count=len(result["recommendations"]), - ) - - return result - - except json.JSONDecodeError as e: - logger.error("Failed to parse alignment JSON response", error=str(e)) - # Return fallback structure - return { - "alignment_score": 0, - "overall_assessment": "Error parsing response", - "strengths": [], - "misalignments": [], - "recommendations": [], - "parse_error": str(e), - } - except Exception as e: - logger.error("Failed to parse alignment response", error=str(e)) - raise - - -__all__ = ["AlignmentAnalysisService"] +"""Alignment analysis service. + +Analyzes how well user's actions/plans align with their purpose, values, and goals. +Provides scoring, explanation, and improvement suggestions. +""" + +import json +from typing import Any + +import structlog + +from coaching.src.application.analysis.base_analysis_service import BaseAnalysisService +from coaching.src.core.constants import AnalysisType + +logger = structlog.get_logger() + + +class AlignmentAnalysisService(BaseAnalysisService): + """ + Service for alignment analysis. + + Analyzes alignment between: + - Actions and purpose/values + - Plans and goals + - Current state and desired state + + Output: + - Alignment score (0-100) + - Explanation of score + - Specific misalignments identified + - Recommendations for improvement + """ + + def get_analysis_type(self) -> AnalysisType: + """Return ALIGNMENT analysis type.""" + return AnalysisType.ALIGNMENT + + def build_prompt(self, context: dict[str, Any]) -> str: + """ + Build alignment analysis prompt. + + Required context: + - user_id: User identifier + - purpose: User's purpose statement (optional) + - values: List of core values (optional) + - goals: User's goals (optional) + - current_actions: Current activities/plans to analyze + + Returns: + Formatted prompt for alignment analysis + """ + purpose = context.get("purpose", "Not defined") + values = context.get("values", []) + goals = context.get("goals", []) + current_actions = context.get("current_actions", "") + + values_str = ", ".join(values) if values else "Not defined" + goals_str = "\n".join([f"- {g}" for g in goals]) if goals else "Not defined" + + prompt = f"""You are an expert business coach analyzing alignment between a user's purpose, values, goals, and their current actions/plans. + +**User's Purpose:** +{purpose} + +**User's Core Values:** +{values_str} + +**User's Goals:** +{goals_str} + +**Current Actions/Plans to Analyze:** +{current_actions} + +Please analyze the alignment and provide your response in the following JSON format: + +{{ + "alignment_score": , + "overall_assessment": "", + "strengths": [ + "" + ], + "misalignments": [ + {{ + "area": "", + "explanation": "", + "impact": "" + }} + ], + "recommendations": [ + {{ + "action": "", + "rationale": "", + "priority": "" + }} + ] +}} + +Provide a thorough, actionable analysis.""" + + return prompt + + def parse_response(self, llm_response: str) -> dict[str, Any]: + """ + Parse alignment analysis response. + + Args: + llm_response: Raw LLM JSON response + + Returns: + Structured alignment analysis result + + Expected structure: + - alignment_score: int (0-100) + - overall_assessment: str + - strengths: list[str] + - misalignments: list[dict] + - recommendations: list[dict] + """ + try: + # Try to extract JSON from response (may have markdown code blocks) + response_text = llm_response.strip() + + # Remove markdown code blocks if present + if response_text.startswith("```json"): + response_text = response_text[7:] + if response_text.startswith("```"): + response_text = response_text[3:] + if response_text.endswith("```"): + response_text = response_text[:-3] + + result: dict[str, Any] = json.loads(response_text.strip()) + + # Validate required fields + required_fields = [ + "alignment_score", + "overall_assessment", + "strengths", + "misalignments", + "recommendations", + ] + for field in required_fields: + if field not in result: + raise ValueError(f"Missing required field: {field}") + + # Validate alignment score range + if not 0 <= result["alignment_score"] <= 100: + logger.warning( + "Alignment score out of range, capping", + score=result["alignment_score"], + ) + result["alignment_score"] = max(0, min(100, result["alignment_score"])) + + logger.debug( + "Alignment response parsed", + score=result["alignment_score"], + misalignments_count=len(result["misalignments"]), + recommendations_count=len(result["recommendations"]), + ) + + return result + + except json.JSONDecodeError as e: + logger.error("Failed to parse alignment JSON response", error=str(e)) + # Return fallback structure + return { + "alignment_score": 0, + "overall_assessment": "Error parsing response", + "strengths": [], + "misalignments": [], + "recommendations": [], + "parse_error": str(e), + } + except Exception as e: + logger.error("Failed to parse alignment response", error=str(e)) + raise + + +__all__ = ["AlignmentAnalysisService"] diff --git a/coaching/src/application/analysis/base_analysis_service.py b/coaching/src/application/analysis/base_analysis_service.py index e71e4d9a..afe1c9f8 100644 --- a/coaching/src/application/analysis/base_analysis_service.py +++ b/coaching/src/application/analysis/base_analysis_service.py @@ -1,198 +1,199 @@ -"""Base analysis service using Template Method pattern. - -This module provides the abstract base for all analysis services, -implementing common workflow steps while allowing customization. -""" - -from abc import ABC, abstractmethod -from typing import Any - -import structlog -from coaching.src.application.llm.llm_service import LLMApplicationService -from coaching.src.core.constants import AnalysisType - -logger = structlog.get_logger() - - -class BaseAnalysisService(ABC): - """ - Abstract base class for analysis services. - - This class implements the Template Method pattern, defining the - common workflow for all analysis types while allowing subclasses - to customize specific steps. - - Design Pattern: Template Method - - analyze() is the template method (final workflow) - - build_prompt(), parse_response() are abstract (customized per type) - - prepare_context() can be overridden if needed - - Design Principles: - - DRY: Common workflow in one place - - Open/Closed: Open for extension (new analysis types), closed for modification - - Single Responsibility: Each analysis type handles its specific logic - - Dependency Injection: Depends on LLMApplicationService - """ - - def __init__(self, llm_service: LLMApplicationService): - """ - Initialize base analysis service. - - Args: - llm_service: LLM application service for generation - """ - self.llm_service = llm_service - logger.info( - f"{self.__class__.__name__} initialized", - analysis_type=self.get_analysis_type().value, - ) - - @abstractmethod - def get_analysis_type(self) -> AnalysisType: - """ - Get the analysis type for this service. - - Returns: - AnalysisType enum value - - Implemented by subclasses to identify analysis type. - """ - pass - - @abstractmethod - def build_prompt(self, context: dict[str, Any]) -> str: - """ - Build analysis-specific prompt. - - Args: - context: Analysis context with required data - - Returns: - Formatted prompt string - - Implemented by subclasses to build type-specific prompts. - Must include all necessary context and instructions. - """ - pass - - @abstractmethod - def parse_response(self, llm_response: str) -> dict[str, Any]: - """ - Parse LLM response into structured result. - - Args: - llm_response: Raw LLM response text - - Returns: - Structured analysis result - - Implemented by subclasses to parse type-specific responses. - Should handle parsing errors gracefully. - """ - pass - - def prepare_context(self, raw_context: dict[str, Any]) -> dict[str, Any]: - """ - Prepare and validate analysis context. - - Args: - raw_context: Raw context from caller - - Returns: - Prepared context ready for prompt building - - Can be overridden by subclasses for custom preparation. - Default implementation validates required fields. - """ - # Default: pass through with basic validation - if not raw_context: - raise ValueError("Analysis context cannot be empty") - - return raw_context - - async def analyze(self, context: dict[str, Any]) -> dict[str, Any]: - """ - Execute complete analysis workflow (Template Method). - - This is the main entry point for all analysis types. - It orchestrates the workflow using abstract methods. - - Workflow: - 1. Prepare context (validate, enrich) - 2. Build prompt (type-specific) - 3. Generate LLM response - 4. Parse response (type-specific) - 5. Return structured result - - Args: - context: Analysis context with required data - - Returns: - Structured analysis result - - Raises: - ValueError: If context is invalid - Exception: If LLM generation or parsing fails - """ - analysis_type = self.get_analysis_type() - - try: - logger.info( - "Analysis started", - analysis_type=analysis_type.value, - ) - - # Step 1: Prepare context - prepared_context = self.prepare_context(context) - - # Step 2: Build prompt - prompt = self.build_prompt(prepared_context) - - logger.debug( - "Prompt built", - analysis_type=analysis_type.value, - prompt_length=len(prompt), - ) - - # Step 3: Generate LLM response - llm_response = await self.llm_service.generate_analysis( - analysis_prompt=prompt, - context=prepared_context, - temperature=0.3, # Lower temperature for deterministic analysis - ) - - logger.debug( - "LLM response received", - analysis_type=analysis_type.value, - tokens=llm_response.usage.get("total_tokens", 0), - ) - - # Step 4: Parse response - result = self.parse_response(llm_response.content) - - # Step 5: Add metadata - result["_metadata"] = { - "analysis_type": analysis_type.value, - "model": llm_response.model, - "tokens_used": llm_response.usage, - "provider": llm_response.provider, - } - - logger.info( - "Analysis completed", - analysis_type=analysis_type.value, - tokens=llm_response.usage.get("total_tokens", 0), - ) - - return result - - except Exception as e: - logger.error( - "Analysis failed", - analysis_type=analysis_type.value, - error=str(e), - ) - raise - - -__all__ = ["BaseAnalysisService"] +"""Base analysis service using Template Method pattern. + +This module provides the abstract base for all analysis services, +implementing common workflow steps while allowing customization. +""" + +from abc import ABC, abstractmethod +from typing import Any + +import structlog + +from coaching.src.application.llm.llm_service import LLMApplicationService +from coaching.src.core.constants import AnalysisType + +logger = structlog.get_logger() + + +class BaseAnalysisService(ABC): + """ + Abstract base class for analysis services. + + This class implements the Template Method pattern, defining the + common workflow for all analysis types while allowing subclasses + to customize specific steps. + + Design Pattern: Template Method + - analyze() is the template method (final workflow) + - build_prompt(), parse_response() are abstract (customized per type) + - prepare_context() can be overridden if needed + + Design Principles: + - DRY: Common workflow in one place + - Open/Closed: Open for extension (new analysis types), closed for modification + - Single Responsibility: Each analysis type handles its specific logic + - Dependency Injection: Depends on LLMApplicationService + """ + + def __init__(self, llm_service: LLMApplicationService): + """ + Initialize base analysis service. + + Args: + llm_service: LLM application service for generation + """ + self.llm_service = llm_service + logger.info( + f"{self.__class__.__name__} initialized", + analysis_type=self.get_analysis_type().value, + ) + + @abstractmethod + def get_analysis_type(self) -> AnalysisType: + """ + Get the analysis type for this service. + + Returns: + AnalysisType enum value + + Implemented by subclasses to identify analysis type. + """ + pass + + @abstractmethod + def build_prompt(self, context: dict[str, Any]) -> str: + """ + Build analysis-specific prompt. + + Args: + context: Analysis context with required data + + Returns: + Formatted prompt string + + Implemented by subclasses to build type-specific prompts. + Must include all necessary context and instructions. + """ + pass + + @abstractmethod + def parse_response(self, llm_response: str) -> dict[str, Any]: + """ + Parse LLM response into structured result. + + Args: + llm_response: Raw LLM response text + + Returns: + Structured analysis result + + Implemented by subclasses to parse type-specific responses. + Should handle parsing errors gracefully. + """ + pass + + def prepare_context(self, raw_context: dict[str, Any]) -> dict[str, Any]: + """ + Prepare and validate analysis context. + + Args: + raw_context: Raw context from caller + + Returns: + Prepared context ready for prompt building + + Can be overridden by subclasses for custom preparation. + Default implementation validates required fields. + """ + # Default: pass through with basic validation + if not raw_context: + raise ValueError("Analysis context cannot be empty") + + return raw_context + + async def analyze(self, context: dict[str, Any]) -> dict[str, Any]: + """ + Execute complete analysis workflow (Template Method). + + This is the main entry point for all analysis types. + It orchestrates the workflow using abstract methods. + + Workflow: + 1. Prepare context (validate, enrich) + 2. Build prompt (type-specific) + 3. Generate LLM response + 4. Parse response (type-specific) + 5. Return structured result + + Args: + context: Analysis context with required data + + Returns: + Structured analysis result + + Raises: + ValueError: If context is invalid + Exception: If LLM generation or parsing fails + """ + analysis_type = self.get_analysis_type() + + try: + logger.info( + "Analysis started", + analysis_type=analysis_type.value, + ) + + # Step 1: Prepare context + prepared_context = self.prepare_context(context) + + # Step 2: Build prompt + prompt = self.build_prompt(prepared_context) + + logger.debug( + "Prompt built", + analysis_type=analysis_type.value, + prompt_length=len(prompt), + ) + + # Step 3: Generate LLM response + llm_response = await self.llm_service.generate_analysis( + analysis_prompt=prompt, + context=prepared_context, + temperature=0.3, # Lower temperature for deterministic analysis + ) + + logger.debug( + "LLM response received", + analysis_type=analysis_type.value, + tokens=llm_response.usage.get("total_tokens", 0), + ) + + # Step 4: Parse response + result = self.parse_response(llm_response.content) + + # Step 5: Add metadata + result["_metadata"] = { + "analysis_type": analysis_type.value, + "model": llm_response.model, + "tokens_used": llm_response.usage, + "provider": llm_response.provider, + } + + logger.info( + "Analysis completed", + analysis_type=analysis_type.value, + tokens=llm_response.usage.get("total_tokens", 0), + ) + + return result + + except Exception as e: + logger.error( + "Analysis failed", + analysis_type=analysis_type.value, + error=str(e), + ) + raise + + +__all__ = ["BaseAnalysisService"] diff --git a/coaching/src/application/analysis/kpi_service.py b/coaching/src/application/analysis/kpi_service.py index c4ff6e8f..3f7088e3 100644 --- a/coaching/src/application/analysis/kpi_service.py +++ b/coaching/src/application/analysis/kpi_service.py @@ -7,6 +7,7 @@ from typing import Any import structlog + from coaching.src.application.analysis.base_analysis_service import BaseAnalysisService from coaching.src.core.constants import AnalysisType diff --git a/coaching/src/application/analysis/measure_service.py b/coaching/src/application/analysis/measure_service.py index 6c306f51..167d3461 100644 --- a/coaching/src/application/analysis/measure_service.py +++ b/coaching/src/application/analysis/measure_service.py @@ -7,6 +7,7 @@ from typing import Any import structlog + from coaching.src.application.analysis.base_analysis_service import BaseAnalysisService from coaching.src.core.constants import AnalysisType diff --git a/coaching/src/application/analysis/operations_ai_service.py b/coaching/src/application/analysis/operations_ai_service.py index 200ab9c2..454a5a7f 100644 --- a/coaching/src/application/analysis/operations_ai_service.py +++ b/coaching/src/application/analysis/operations_ai_service.py @@ -1,394 +1,395 @@ -"""Operations AI Service for strategic operations management (Issues #63 & #64).""" - -import json -from datetime import datetime, timedelta -from typing import Any - -import structlog -from coaching.src.application.llm.llm_service import LLMApplicationService - -logger = structlog.get_logger() - - -class OperationsAIService: - """Service for AI-powered operations management.""" - - def __init__(self, llm_service: LLMApplicationService): - self.llm_service = llm_service - logger.info("OperationsAIService initialized") - - async def analyze_strategic_alignment( - self, - actions: list[dict[str, Any]], - goals: list[dict[str, Any]], - business_foundation: dict[str, Any], - ) -> dict[str, Any]: - """Analyze how well actions align with business goals and foundation.""" - logger.info( - "Analyzing strategic alignment", action_count=len(actions), goal_count=len(goals) - ) - - if not actions: - raise ValueError("At least one action is required") - if not goals: - raise ValueError("At least one goal is required") - if not business_foundation.get("vision") or not business_foundation.get("purpose"): - raise ValueError("Business foundation must include vision and purpose") - - actions_summary = self._format_actions_for_prompt(actions) - goals_summary = self._format_goals_for_prompt(goals) - foundation_summary = self._format_foundation_for_prompt(business_foundation) - - prompt = f"""You are an expert strategic business analyst. Analyze how well the following actions align with business goals and foundation. - -**Business Foundation:** -{foundation_summary} - -**Business Goals:** -{goals_summary} - -**Actions to Analyze:** -{actions_summary} - -Format your response as JSON with alignmentAnalysis (array), overallAlignment (number), and insights (array).""" - - llm_response = await self.llm_service.generate_analysis( - analysis_prompt=prompt, - context={ - "actions": actions, - "goals": goals, - "business_foundation": business_foundation, - }, - temperature=0.6, - ) - - try: - response_text = llm_response.content.strip() - if response_text.startswith("```json"): - response_text = response_text[7:] - if response_text.startswith("```"): - response_text = response_text[3:] - if response_text.endswith("```"): - response_text = response_text[:-3] - analysis = json.loads(response_text.strip()) - logger.info( - "Strategic alignment analysis completed", - overall_score=analysis.get("overallAlignment"), - ) - return dict(analysis) - except json.JSONDecodeError: - return { - "alignmentAnalysis": [ - { - "actionId": a.get("id"), - "alignmentScore": 50, - "strategicConnections": [], - "recommendations": ["Review alignment"], - } - for a in actions - ], - "overallAlignment": 50, - "insights": ["Unable to complete detailed analysis"], - } - - async def suggest_prioritization( - self, - actions: list[dict[str, Any]], - business_context: dict[str, Any], - ) -> list[dict[str, Any]]: - """Generate AI-powered prioritization suggestions for actions.""" - logger.info("Generating prioritization suggestions", action_count=len(actions)) - - if not actions: - raise ValueError("At least one action is required") - - actions_summary = self._format_prioritization_actions(actions) - context_summary = self._format_business_context(business_context) - - prompt = f"""You are an expert project prioritization analyst. Suggest optimal priorities for these actions. - -**Business Context:** -{context_summary} - -**Actions:** -{actions_summary} - -Format as JSON array with actionId, suggestedPriority, currentPriority, reasoning, confidence, urgencyFactors, impactFactors, recommendedAction, estimatedBusinessValue.""" - - llm_response = await self.llm_service.generate_analysis( - analysis_prompt=prompt, - context={"actions": actions, "business_context": business_context}, - temperature=0.5, - ) - - try: - response_text = llm_response.content.strip() - if response_text.startswith("```json"): - response_text = response_text[7:] - if response_text.startswith("```"): - response_text = response_text[3:] - if response_text.endswith("```"): - response_text = response_text[:-3] - suggestions = json.loads(response_text.strip()) - logger.info("Prioritization suggestions generated", suggestions_count=len(suggestions)) - return list(suggestions) - except json.JSONDecodeError: - return [ - { - "actionId": a.get("id"), - "suggestedPriority": a.get("currentPriority", "medium"), - "currentPriority": a.get("currentPriority", "medium"), - "reasoning": "Unable to analyze", - "confidence": 0.5, - "urgencyFactors": ["Requires review"], - "impactFactors": ["Assessment needed"], - "recommendedAction": "maintain", - "estimatedBusinessValue": None, - } - for a in actions - ] - - async def optimize_scheduling( - self, - actions: list[dict[str, Any]], - constraints: dict[str, Any], - ) -> list[dict[str, Any]]: - """Generate optimized scheduling suggestions for actions.""" - logger.info("Generating scheduling suggestions", action_count=len(actions)) - - if not actions: - raise ValueError("At least one action is required") - if not constraints.get("teamCapacity"): - raise ValueError("Team capacity is required") - - actions_summary = self._format_scheduling_actions(actions) - constraints_summary = self._format_constraints(constraints) - - prompt = f"""You are an expert project scheduling optimizer. Suggest optimal schedules. - -**Constraints:** -{constraints_summary} - -**Actions:** -{actions_summary} - -Format as JSON array with actionId, suggestedStartDate, suggestedDueDate, reasoning, confidence, dependencies, resourceConsiderations, risks, alternativeSchedules.""" - - llm_response = await self.llm_service.generate_analysis( - analysis_prompt=prompt, - context={"actions": actions, "constraints": constraints}, - temperature=0.4, - ) - - try: - response_text = llm_response.content.strip() - if response_text.startswith("```json"): - response_text = response_text[7:] - if response_text.startswith("```"): - response_text = response_text[3:] - if response_text.endswith("```"): - response_text = response_text[:-3] - schedules = json.loads(response_text.strip()) - logger.info("Scheduling suggestions generated", schedules_count=len(schedules)) - return list(schedules) - except json.JSONDecodeError: - today = datetime.now().date() - return [ - { - "actionId": a.get("id"), - "suggestedStartDate": today.isoformat(), - "suggestedDueDate": (today + timedelta(days=7)).isoformat(), - "reasoning": "Default schedule", - "confidence": 0.5, - "dependencies": [], - "resourceConsiderations": ["Requires review"], - "risks": ["Not optimized"], - "alternativeSchedules": [], - } - for a in actions - ] - - async def suggest_root_cause_methods( - self, - issue: dict[str, Any], - context: dict[str, Any], - ) -> list[dict[str, Any]]: - """AI-powered root cause analysis method selection (Issue #64).""" - logger.info("Suggesting root cause analysis methods") - - if not issue.get("issueTitle") or not issue.get("issueDescription"): - raise ValueError("Issue title and description are required") - - issue_summary = self._format_issue_for_root_cause(issue, context) - - prompt = f"""You are an expert problem-solving analyst. Suggest the most appropriate root cause analysis methods. - -**Issue:** -{issue_summary} - -Suggest 1-3 methods (five_whys, fishbone, swot, pareto) with method, confidence, suggestions, and reasoning. Format as JSON array.""" - - llm_response = await self.llm_service.generate_analysis( - analysis_prompt=prompt, - context={"issue": issue, "context": context}, - temperature=0.6, - ) - - try: - response_text = llm_response.content.strip() - if response_text.startswith("```json"): - response_text = response_text[7:] - if response_text.startswith("```"): - response_text = response_text[3:] - if response_text.endswith("```"): - response_text = response_text[:-3] - suggestions = json.loads(response_text.strip()) - logger.info( - "Root cause method suggestions generated", suggestions_count=len(suggestions) - ) - return list(suggestions) - except json.JSONDecodeError: - return [ - { - "method": "five_whys", - "confidence": 0.7, - "suggestions": { - "fiveWhys": { - "suggestedQuestions": [ - "Why did this occur?", - "Why wasn't it prevented?", - ], - "potentialRootCauses": ["Process gaps", "Communication issues"], - } - }, - "reasoning": "Five Whys is a good general-purpose method", - } - ] - - async def generate_action_plan( - self, - issue: dict[str, Any], - constraints: dict[str, Any], - context: dict[str, Any], - ) -> list[dict[str, Any]]: - """Generate actionable plan with AI insights (Issue #64).""" - logger.info("Generating action plan suggestions") - - if not issue.get("title") or not issue.get("description"): - raise ValueError("Issue title and description are required") - - issue_summary = self._format_issue_for_action_plan(issue) - constraints_summary = self._format_action_constraints(constraints) - context_summary = self._format_action_context(context) - - prompt = f"""You are an expert project manager. Generate a comprehensive action plan. - -**Issue:** -{issue_summary} - -**Constraints:** -{constraints_summary} - -**Context:** -{context_summary} - -Generate 3-5 actions with title, description, priority, estimatedDuration, estimatedCost, assignmentSuggestion, dependencies, confidence, reasoning, expectedOutcome, risks. Format as JSON array.""" - - llm_response = await self.llm_service.generate_analysis( - analysis_prompt=prompt, - context={"issue": issue, "constraints": constraints, "context": context}, - temperature=0.5, - ) - - try: - response_text = llm_response.content.strip() - if response_text.startswith("```json"): - response_text = response_text[7:] - if response_text.startswith("```"): - response_text = response_text[3:] - if response_text.endswith("```"): - response_text = response_text[:-3] - actions = json.loads(response_text.strip()) - logger.info("Action plan generated", actions_count=len(actions)) - return list(actions) - except json.JSONDecodeError: - return [ - { - "title": "Investigate root cause", - "description": "Conduct investigation", - "priority": "high", - "estimatedDuration": 16, - "estimatedCost": None, - "assignmentSuggestion": "Team lead", - "dependencies": [], - "confidence": 0.8, - "reasoning": "Understanding root cause is essential", - "expectedOutcome": "Clear identification", - "risks": ["May take longer"], - }, - { - "title": "Implement solution", - "description": "Design and implement solution", - "priority": "high", - "estimatedDuration": 40, - "estimatedCost": None, - "assignmentSuggestion": "Development team", - "dependencies": ["Investigate root cause"], - "confidence": 0.75, - "reasoning": "Direct action to resolve", - "expectedOutcome": "Issue resolved", - "risks": ["May need iterations"], - }, - ] - - def _format_actions_for_prompt(self, actions: list[dict[str, Any]]) -> str: - lines = [ - f"{i}. **{a.get('title')}** (ID: {a.get('id')})\n Description: {a.get('description', 'N/A')}\n Priority: {a.get('priority', 'N/A')}\n Status: {a.get('status', 'N/A')}" - for i, a in enumerate(actions, 1) - ] - return "\n\n".join(lines) - - def _format_goals_for_prompt(self, goals: list[dict[str, Any]]) -> str: - lines = [ - f"{i}. **{g.get('intent')}** (ID: {g.get('id')})\n Strategies: {', '.join(g.get('strategies', [])) or 'None'}" - for i, g in enumerate(goals, 1) - ] - return "\n\n".join(lines) - - def _format_foundation_for_prompt(self, foundation: dict[str, Any]) -> str: - return f"- Vision: {foundation.get('vision', 'Not defined')}\n- Purpose: {foundation.get('purpose', 'Not defined')}\n- Core Values: {', '.join(foundation.get('coreValues', []))}" - - def _format_prioritization_actions(self, actions: list[dict[str, Any]]) -> str: - lines = [ - f"{i}. **{a.get('title')}** (ID: {a.get('id')})\n Current Priority: {a.get('currentPriority', 'N/A')}\n Due Date: {a.get('dueDate', 'Not set')}\n Status: {a.get('status', 'N/A')}" - for i, a in enumerate(actions, 1) - ] - return "\n\n".join(lines) - - def _format_business_context(self, context: dict[str, Any]) -> str: - return f"- Current Goals: {', '.join(context.get('currentGoals', [])) or 'Not specified'}\n- Constraints: {', '.join(context.get('constraints', [])) or 'None'}\n- Urgent Deadlines: {', '.join(context.get('urgentDeadlines', [])) or 'None'}" - - def _format_scheduling_actions(self, actions: list[dict[str, Any]]) -> str: - lines = [ - f"{i}. **{a.get('title')}** (ID: {a.get('id')})\n Duration: {a.get('estimatedDuration', 'N/A')} hours\n Priority: {a.get('priority', 'N/A')}" - for i, a in enumerate(actions, 1) - ] - return "\n\n".join(lines) - - def _format_constraints(self, constraints: dict[str, Any]) -> str: - return f"- Team Capacity: {constraints.get('teamCapacity', 'N/A')} hours\n- Critical Deadlines: {len(constraints.get('criticalDeadlines', []))} deadlines" - - def _format_issue_for_root_cause(self, issue: dict[str, Any], _context: dict[str, Any]) -> str: - return f"**Title:** {issue.get('issueTitle', 'Untitled')}\n**Description:** {issue.get('issueDescription', 'No description')}\n**Impact:** {issue.get('businessImpact', 'Unknown')}" - - def _format_issue_for_action_plan(self, issue: dict[str, Any]) -> str: - return f"**Title:** {issue.get('title', 'Untitled')}\n**Description:** {issue.get('description', 'No description')}\n**Impact:** {issue.get('impact', 'Unknown')}" - - def _format_action_constraints(self, constraints: dict[str, Any]) -> str: - return f"- Timeline: {constraints.get('timeline', 'Not specified')}\n- Budget: ${constraints.get('budget', 0):,}" - - def _format_action_context(self, context: dict[str, Any]) -> str: - return f"- Related Goals: {', '.join(context.get('relatedGoals', [])) or 'None'}\n- Current Actions: {', '.join(context.get('currentActions', [])) or 'None'}" - - -__all__ = ["OperationsAIService"] +"""Operations AI Service for strategic operations management (Issues #63 & #64).""" + +import json +from datetime import datetime, timedelta +from typing import Any + +import structlog + +from coaching.src.application.llm.llm_service import LLMApplicationService + +logger = structlog.get_logger() + + +class OperationsAIService: + """Service for AI-powered operations management.""" + + def __init__(self, llm_service: LLMApplicationService): + self.llm_service = llm_service + logger.info("OperationsAIService initialized") + + async def analyze_strategic_alignment( + self, + actions: list[dict[str, Any]], + goals: list[dict[str, Any]], + business_foundation: dict[str, Any], + ) -> dict[str, Any]: + """Analyze how well actions align with business goals and foundation.""" + logger.info( + "Analyzing strategic alignment", action_count=len(actions), goal_count=len(goals) + ) + + if not actions: + raise ValueError("At least one action is required") + if not goals: + raise ValueError("At least one goal is required") + if not business_foundation.get("vision") or not business_foundation.get("purpose"): + raise ValueError("Business foundation must include vision and purpose") + + actions_summary = self._format_actions_for_prompt(actions) + goals_summary = self._format_goals_for_prompt(goals) + foundation_summary = self._format_foundation_for_prompt(business_foundation) + + prompt = f"""You are an expert strategic business analyst. Analyze how well the following actions align with business goals and foundation. + +**Business Foundation:** +{foundation_summary} + +**Business Goals:** +{goals_summary} + +**Actions to Analyze:** +{actions_summary} + +Format your response as JSON with alignmentAnalysis (array), overallAlignment (number), and insights (array).""" + + llm_response = await self.llm_service.generate_analysis( + analysis_prompt=prompt, + context={ + "actions": actions, + "goals": goals, + "business_foundation": business_foundation, + }, + temperature=0.6, + ) + + try: + response_text = llm_response.content.strip() + if response_text.startswith("```json"): + response_text = response_text[7:] + if response_text.startswith("```"): + response_text = response_text[3:] + if response_text.endswith("```"): + response_text = response_text[:-3] + analysis = json.loads(response_text.strip()) + logger.info( + "Strategic alignment analysis completed", + overall_score=analysis.get("overallAlignment"), + ) + return dict(analysis) + except json.JSONDecodeError: + return { + "alignmentAnalysis": [ + { + "actionId": a.get("id"), + "alignmentScore": 50, + "strategicConnections": [], + "recommendations": ["Review alignment"], + } + for a in actions + ], + "overallAlignment": 50, + "insights": ["Unable to complete detailed analysis"], + } + + async def suggest_prioritization( + self, + actions: list[dict[str, Any]], + business_context: dict[str, Any], + ) -> list[dict[str, Any]]: + """Generate AI-powered prioritization suggestions for actions.""" + logger.info("Generating prioritization suggestions", action_count=len(actions)) + + if not actions: + raise ValueError("At least one action is required") + + actions_summary = self._format_prioritization_actions(actions) + context_summary = self._format_business_context(business_context) + + prompt = f"""You are an expert project prioritization analyst. Suggest optimal priorities for these actions. + +**Business Context:** +{context_summary} + +**Actions:** +{actions_summary} + +Format as JSON array with actionId, suggestedPriority, currentPriority, reasoning, confidence, urgencyFactors, impactFactors, recommendedAction, estimatedBusinessValue.""" + + llm_response = await self.llm_service.generate_analysis( + analysis_prompt=prompt, + context={"actions": actions, "business_context": business_context}, + temperature=0.5, + ) + + try: + response_text = llm_response.content.strip() + if response_text.startswith("```json"): + response_text = response_text[7:] + if response_text.startswith("```"): + response_text = response_text[3:] + if response_text.endswith("```"): + response_text = response_text[:-3] + suggestions = json.loads(response_text.strip()) + logger.info("Prioritization suggestions generated", suggestions_count=len(suggestions)) + return list(suggestions) + except json.JSONDecodeError: + return [ + { + "actionId": a.get("id"), + "suggestedPriority": a.get("currentPriority", "medium"), + "currentPriority": a.get("currentPriority", "medium"), + "reasoning": "Unable to analyze", + "confidence": 0.5, + "urgencyFactors": ["Requires review"], + "impactFactors": ["Assessment needed"], + "recommendedAction": "maintain", + "estimatedBusinessValue": None, + } + for a in actions + ] + + async def optimize_scheduling( + self, + actions: list[dict[str, Any]], + constraints: dict[str, Any], + ) -> list[dict[str, Any]]: + """Generate optimized scheduling suggestions for actions.""" + logger.info("Generating scheduling suggestions", action_count=len(actions)) + + if not actions: + raise ValueError("At least one action is required") + if not constraints.get("teamCapacity"): + raise ValueError("Team capacity is required") + + actions_summary = self._format_scheduling_actions(actions) + constraints_summary = self._format_constraints(constraints) + + prompt = f"""You are an expert project scheduling optimizer. Suggest optimal schedules. + +**Constraints:** +{constraints_summary} + +**Actions:** +{actions_summary} + +Format as JSON array with actionId, suggestedStartDate, suggestedDueDate, reasoning, confidence, dependencies, resourceConsiderations, risks, alternativeSchedules.""" + + llm_response = await self.llm_service.generate_analysis( + analysis_prompt=prompt, + context={"actions": actions, "constraints": constraints}, + temperature=0.4, + ) + + try: + response_text = llm_response.content.strip() + if response_text.startswith("```json"): + response_text = response_text[7:] + if response_text.startswith("```"): + response_text = response_text[3:] + if response_text.endswith("```"): + response_text = response_text[:-3] + schedules = json.loads(response_text.strip()) + logger.info("Scheduling suggestions generated", schedules_count=len(schedules)) + return list(schedules) + except json.JSONDecodeError: + today = datetime.now().date() + return [ + { + "actionId": a.get("id"), + "suggestedStartDate": today.isoformat(), + "suggestedDueDate": (today + timedelta(days=7)).isoformat(), + "reasoning": "Default schedule", + "confidence": 0.5, + "dependencies": [], + "resourceConsiderations": ["Requires review"], + "risks": ["Not optimized"], + "alternativeSchedules": [], + } + for a in actions + ] + + async def suggest_root_cause_methods( + self, + issue: dict[str, Any], + context: dict[str, Any], + ) -> list[dict[str, Any]]: + """AI-powered root cause analysis method selection (Issue #64).""" + logger.info("Suggesting root cause analysis methods") + + if not issue.get("issueTitle") or not issue.get("issueDescription"): + raise ValueError("Issue title and description are required") + + issue_summary = self._format_issue_for_root_cause(issue, context) + + prompt = f"""You are an expert problem-solving analyst. Suggest the most appropriate root cause analysis methods. + +**Issue:** +{issue_summary} + +Suggest 1-3 methods (five_whys, fishbone, swot, pareto) with method, confidence, suggestions, and reasoning. Format as JSON array.""" + + llm_response = await self.llm_service.generate_analysis( + analysis_prompt=prompt, + context={"issue": issue, "context": context}, + temperature=0.6, + ) + + try: + response_text = llm_response.content.strip() + if response_text.startswith("```json"): + response_text = response_text[7:] + if response_text.startswith("```"): + response_text = response_text[3:] + if response_text.endswith("```"): + response_text = response_text[:-3] + suggestions = json.loads(response_text.strip()) + logger.info( + "Root cause method suggestions generated", suggestions_count=len(suggestions) + ) + return list(suggestions) + except json.JSONDecodeError: + return [ + { + "method": "five_whys", + "confidence": 0.7, + "suggestions": { + "fiveWhys": { + "suggestedQuestions": [ + "Why did this occur?", + "Why wasn't it prevented?", + ], + "potentialRootCauses": ["Process gaps", "Communication issues"], + } + }, + "reasoning": "Five Whys is a good general-purpose method", + } + ] + + async def generate_action_plan( + self, + issue: dict[str, Any], + constraints: dict[str, Any], + context: dict[str, Any], + ) -> list[dict[str, Any]]: + """Generate actionable plan with AI insights (Issue #64).""" + logger.info("Generating action plan suggestions") + + if not issue.get("title") or not issue.get("description"): + raise ValueError("Issue title and description are required") + + issue_summary = self._format_issue_for_action_plan(issue) + constraints_summary = self._format_action_constraints(constraints) + context_summary = self._format_action_context(context) + + prompt = f"""You are an expert project manager. Generate a comprehensive action plan. + +**Issue:** +{issue_summary} + +**Constraints:** +{constraints_summary} + +**Context:** +{context_summary} + +Generate 3-5 actions with title, description, priority, estimatedDuration, estimatedCost, assignmentSuggestion, dependencies, confidence, reasoning, expectedOutcome, risks. Format as JSON array.""" + + llm_response = await self.llm_service.generate_analysis( + analysis_prompt=prompt, + context={"issue": issue, "constraints": constraints, "context": context}, + temperature=0.5, + ) + + try: + response_text = llm_response.content.strip() + if response_text.startswith("```json"): + response_text = response_text[7:] + if response_text.startswith("```"): + response_text = response_text[3:] + if response_text.endswith("```"): + response_text = response_text[:-3] + actions = json.loads(response_text.strip()) + logger.info("Action plan generated", actions_count=len(actions)) + return list(actions) + except json.JSONDecodeError: + return [ + { + "title": "Investigate root cause", + "description": "Conduct investigation", + "priority": "high", + "estimatedDuration": 16, + "estimatedCost": None, + "assignmentSuggestion": "Team lead", + "dependencies": [], + "confidence": 0.8, + "reasoning": "Understanding root cause is essential", + "expectedOutcome": "Clear identification", + "risks": ["May take longer"], + }, + { + "title": "Implement solution", + "description": "Design and implement solution", + "priority": "high", + "estimatedDuration": 40, + "estimatedCost": None, + "assignmentSuggestion": "Development team", + "dependencies": ["Investigate root cause"], + "confidence": 0.75, + "reasoning": "Direct action to resolve", + "expectedOutcome": "Issue resolved", + "risks": ["May need iterations"], + }, + ] + + def _format_actions_for_prompt(self, actions: list[dict[str, Any]]) -> str: + lines = [ + f"{i}. **{a.get('title')}** (ID: {a.get('id')})\n Description: {a.get('description', 'N/A')}\n Priority: {a.get('priority', 'N/A')}\n Status: {a.get('status', 'N/A')}" + for i, a in enumerate(actions, 1) + ] + return "\n\n".join(lines) + + def _format_goals_for_prompt(self, goals: list[dict[str, Any]]) -> str: + lines = [ + f"{i}. **{g.get('intent')}** (ID: {g.get('id')})\n Strategies: {', '.join(g.get('strategies', [])) or 'None'}" + for i, g in enumerate(goals, 1) + ] + return "\n\n".join(lines) + + def _format_foundation_for_prompt(self, foundation: dict[str, Any]) -> str: + return f"- Vision: {foundation.get('vision', 'Not defined')}\n- Purpose: {foundation.get('purpose', 'Not defined')}\n- Core Values: {', '.join(foundation.get('coreValues', []))}" + + def _format_prioritization_actions(self, actions: list[dict[str, Any]]) -> str: + lines = [ + f"{i}. **{a.get('title')}** (ID: {a.get('id')})\n Current Priority: {a.get('currentPriority', 'N/A')}\n Due Date: {a.get('dueDate', 'Not set')}\n Status: {a.get('status', 'N/A')}" + for i, a in enumerate(actions, 1) + ] + return "\n\n".join(lines) + + def _format_business_context(self, context: dict[str, Any]) -> str: + return f"- Current Goals: {', '.join(context.get('currentGoals', [])) or 'Not specified'}\n- Constraints: {', '.join(context.get('constraints', [])) or 'None'}\n- Urgent Deadlines: {', '.join(context.get('urgentDeadlines', [])) or 'None'}" + + def _format_scheduling_actions(self, actions: list[dict[str, Any]]) -> str: + lines = [ + f"{i}. **{a.get('title')}** (ID: {a.get('id')})\n Duration: {a.get('estimatedDuration', 'N/A')} hours\n Priority: {a.get('priority', 'N/A')}" + for i, a in enumerate(actions, 1) + ] + return "\n\n".join(lines) + + def _format_constraints(self, constraints: dict[str, Any]) -> str: + return f"- Team Capacity: {constraints.get('teamCapacity', 'N/A')} hours\n- Critical Deadlines: {len(constraints.get('criticalDeadlines', []))} deadlines" + + def _format_issue_for_root_cause(self, issue: dict[str, Any], _context: dict[str, Any]) -> str: + return f"**Title:** {issue.get('issueTitle', 'Untitled')}\n**Description:** {issue.get('issueDescription', 'No description')}\n**Impact:** {issue.get('businessImpact', 'Unknown')}" + + def _format_issue_for_action_plan(self, issue: dict[str, Any]) -> str: + return f"**Title:** {issue.get('title', 'Untitled')}\n**Description:** {issue.get('description', 'No description')}\n**Impact:** {issue.get('impact', 'Unknown')}" + + def _format_action_constraints(self, constraints: dict[str, Any]) -> str: + return f"- Timeline: {constraints.get('timeline', 'Not specified')}\n- Budget: ${constraints.get('budget', 0):,}" + + def _format_action_context(self, context: dict[str, Any]) -> str: + return f"- Related Goals: {', '.join(context.get('relatedGoals', [])) or 'None'}\n- Current Actions: {', '.join(context.get('currentActions', [])) or 'None'}" + + +__all__ = ["OperationsAIService"] diff --git a/coaching/src/application/analysis/strategy_service.py b/coaching/src/application/analysis/strategy_service.py index 60efa26d..b70ebb26 100644 --- a/coaching/src/application/analysis/strategy_service.py +++ b/coaching/src/application/analysis/strategy_service.py @@ -1,154 +1,155 @@ -"""Strategy analysis service. - -Generates strategic recommendations based on user's goals, current situation, and context. -""" - -import json -from typing import Any - -import structlog -from coaching.src.application.analysis.base_analysis_service import BaseAnalysisService -from coaching.src.core.constants import AnalysisType - -logger = structlog.get_logger() - - -class StrategyAnalysisService(BaseAnalysisService): - """ - Service for strategy recommendations. - - Analyzes user's situation and provides: - - Strategic recommendations - - Implementation approaches - - Risk assessment - - Success metrics - """ - - def get_analysis_type(self) -> AnalysisType: - """Return STRATEGY analysis type.""" - return AnalysisType.STRATEGY - - def build_prompt(self, context: dict[str, Any]) -> str: - """ - Build strategy analysis prompt. - - Required context: - - goal: Primary goal to strategize for - - current_situation: Current state description - - constraints: Known constraints (optional) - - resources: Available resources (optional) - - Returns: - Formatted prompt for strategy analysis - """ - goal = context.get("goal", "") - current_situation = context.get("current_situation", "") - constraints = context.get("constraints", []) - resources = context.get("resources", []) - - constraints_str = ( - "\n".join([f"- {c}" for c in constraints]) if constraints else "None specified" - ) - resources_str = "\n".join([f"- {r}" for r in resources]) if resources else "None specified" - - prompt = f"""You are an expert strategic business coach helping a user develop strategies to achieve their goals. - -**Primary Goal:** -{goal} - -**Current Situation:** -{current_situation} - -**Constraints:** -{constraints_str} - -**Available Resources:** -{resources_str} - -Please provide strategic recommendations in the following JSON format: - -{{ - "strategic_approach": "", - "strategies": [ - {{ - "name": "", - "description": "", - "rationale": "", - "implementation_steps": [ - "" - ], - "timeline": "", - "priority": "" - }} - ], - "risks": [ - {{ - "risk": "", - "mitigation": "", - "severity": "" - }} - ], - "success_metrics": [ - "" - ], - "quick_wins": [ - "" - ] -}} - -Provide 3-5 actionable strategies.""" - - return prompt - - def parse_response(self, llm_response: str) -> dict[str, Any]: - """ - Parse strategy analysis response. - - Args: - llm_response: Raw LLM JSON response - - Returns: - Structured strategy analysis result - """ - try: - # Clean response - response_text = llm_response.strip() - if response_text.startswith("```json"): - response_text = response_text[7:] - if response_text.startswith("```"): - response_text = response_text[3:] - if response_text.endswith("```"): - response_text = response_text[:-3] - - result: dict[str, Any] = json.loads(response_text.strip()) - - # Validate required fields - required_fields = ["strategic_approach", "strategies", "risks", "success_metrics"] - for field in required_fields: - if field not in result: - raise ValueError(f"Missing required field: {field}") - - logger.debug( - "Strategy response parsed", - strategies_count=len(result["strategies"]), - risks_count=len(result["risks"]), - ) - - return result - - except json.JSONDecodeError as e: - logger.error("Failed to parse strategy JSON response", error=str(e)) - return { - "strategic_approach": "Error parsing response", - "strategies": [], - "risks": [], - "success_metrics": [], - "quick_wins": [], - "parse_error": str(e), - } - except Exception as e: - logger.error("Failed to parse strategy response", error=str(e)) - raise - - -__all__ = ["StrategyAnalysisService"] +"""Strategy analysis service. + +Generates strategic recommendations based on user's goals, current situation, and context. +""" + +import json +from typing import Any + +import structlog + +from coaching.src.application.analysis.base_analysis_service import BaseAnalysisService +from coaching.src.core.constants import AnalysisType + +logger = structlog.get_logger() + + +class StrategyAnalysisService(BaseAnalysisService): + """ + Service for strategy recommendations. + + Analyzes user's situation and provides: + - Strategic recommendations + - Implementation approaches + - Risk assessment + - Success metrics + """ + + def get_analysis_type(self) -> AnalysisType: + """Return STRATEGY analysis type.""" + return AnalysisType.STRATEGY + + def build_prompt(self, context: dict[str, Any]) -> str: + """ + Build strategy analysis prompt. + + Required context: + - goal: Primary goal to strategize for + - current_situation: Current state description + - constraints: Known constraints (optional) + - resources: Available resources (optional) + + Returns: + Formatted prompt for strategy analysis + """ + goal = context.get("goal", "") + current_situation = context.get("current_situation", "") + constraints = context.get("constraints", []) + resources = context.get("resources", []) + + constraints_str = ( + "\n".join([f"- {c}" for c in constraints]) if constraints else "None specified" + ) + resources_str = "\n".join([f"- {r}" for r in resources]) if resources else "None specified" + + prompt = f"""You are an expert strategic business coach helping a user develop strategies to achieve their goals. + +**Primary Goal:** +{goal} + +**Current Situation:** +{current_situation} + +**Constraints:** +{constraints_str} + +**Available Resources:** +{resources_str} + +Please provide strategic recommendations in the following JSON format: + +{{ + "strategic_approach": "", + "strategies": [ + {{ + "name": "", + "description": "", + "rationale": "", + "implementation_steps": [ + "" + ], + "timeline": "", + "priority": "" + }} + ], + "risks": [ + {{ + "risk": "", + "mitigation": "", + "severity": "" + }} + ], + "success_metrics": [ + "" + ], + "quick_wins": [ + "" + ] +}} + +Provide 3-5 actionable strategies.""" + + return prompt + + def parse_response(self, llm_response: str) -> dict[str, Any]: + """ + Parse strategy analysis response. + + Args: + llm_response: Raw LLM JSON response + + Returns: + Structured strategy analysis result + """ + try: + # Clean response + response_text = llm_response.strip() + if response_text.startswith("```json"): + response_text = response_text[7:] + if response_text.startswith("```"): + response_text = response_text[3:] + if response_text.endswith("```"): + response_text = response_text[:-3] + + result: dict[str, Any] = json.loads(response_text.strip()) + + # Validate required fields + required_fields = ["strategic_approach", "strategies", "risks", "success_metrics"] + for field in required_fields: + if field not in result: + raise ValueError(f"Missing required field: {field}") + + logger.debug( + "Strategy response parsed", + strategies_count=len(result["strategies"]), + risks_count=len(result["risks"]), + ) + + return result + + except json.JSONDecodeError as e: + logger.error("Failed to parse strategy JSON response", error=str(e)) + return { + "strategic_approach": "Error parsing response", + "strategies": [], + "risks": [], + "success_metrics": [], + "quick_wins": [], + "parse_error": str(e), + } + except Exception as e: + logger.error("Failed to parse strategy response", error=str(e)) + raise + + +__all__ = ["StrategyAnalysisService"] diff --git a/coaching/src/application/analysis/strategy_suggestion_service.py b/coaching/src/application/analysis/strategy_suggestion_service.py index 87f4e013..7314f22a 100644 --- a/coaching/src/application/analysis/strategy_suggestion_service.py +++ b/coaching/src/application/analysis/strategy_suggestion_service.py @@ -1,246 +1,247 @@ -"""Strategy suggestion service for AI-powered strategic recommendations. - -Generates actionable strategy recommendations based on goal intent, business context, -and resource constraints using LLM analysis. -""" - -import json -from typing import Any - -import structlog -from coaching.src.application.analysis.base_analysis_service import BaseAnalysisService -from coaching.src.core.constants import AnalysisType - -logger = structlog.get_logger() - - -class StrategySuggestionService(BaseAnalysisService): - """ - Service for generating strategy suggestions. - - Analyzes: - - Goal intent and desired outcomes - - Business context (vision, values, market) - - Existing strategies - - Resource constraints - - Output: - - List of strategic recommendations - - Each with title, description, rationale, difficulty, impact - - Confidence score and reasoning - """ - - def get_analysis_type(self) -> AnalysisType: - """Return STRATEGY analysis type.""" - return AnalysisType.STRATEGY - - def build_prompt(self, context: dict[str, Any]) -> str: - """ - Build strategy suggestion prompt. - - Required context: - - goal_intent: The goal requiring strategies - - business_context: Dict with vision, purpose, values, etc. - - existing_strategies: List of current strategies (optional) - - constraints: Dict with budget, timeline, resources (optional) - - Returns: - Formatted prompt for strategy generation - """ - goal_intent = context.get("goal_intent", "") - business_ctx = context.get("business_context", {}) - existing_strategies = context.get("existing_strategies", []) - constraints = context.get("constraints", {}) - - # Extract business context fields - vision = business_ctx.get("vision", "Not defined") - purpose = business_ctx.get("purpose", "Not defined") - core_values = business_ctx.get("coreValues", []) - target_market = business_ctx.get("targetMarket", "Not defined") - value_prop = business_ctx.get("valueProposition", "Not defined") - industry = business_ctx.get("industry", "Not specified") - business_type = business_ctx.get("businessType", "Not specified") - - values_str = ", ".join(core_values) if core_values else "Not defined" - existing_str = ( - "\n".join([f"- {s}" for s in existing_strategies]) - if existing_strategies - else "None currently in place" - ) - - # Build constraints section - constraints_section = "" - if constraints: - budget = constraints.get("budget") - timeline = constraints.get("timeline") - resources = constraints.get("resources", []) - - constraints_section = f""" -**Resource Constraints:** -- Budget: ${budget:,} if budget else 'Flexible' -- Timeline: {timeline or "Flexible"} -- Available Resources: {", ".join(resources) if resources else "To be determined"} -""" - - prompt = f"""You are an expert business strategist helping develop actionable strategies for a business goal. - -**Goal:** -{goal_intent} - -**Business Context:** -- Vision: {vision} -- Purpose: {purpose} -- Core Values: {values_str} -- Target Market: {target_market} -- Value Proposition: {value_prop} -- Industry: {industry} -- Business Type: {business_type} - -**Existing Strategies:** -{existing_str} -{constraints_section} - -Please generate 3-5 strategic recommendations to achieve this goal. For each strategy, provide: -1. **title**: Clear, actionable title (4-8 words) -2. **description**: Detailed description of the strategy (2-3 sentences) -3. **rationale**: Why this strategy makes sense given the context (2-3 sentences) -4. **difficulty**: Implementation difficulty (low/medium/high) -5. **timeframe**: Expected implementation timeframe (e.g., "2-3 months", "6 weeks") -6. **expectedImpact**: Expected business impact (low/medium/high) -7. **prerequisites**: List of prerequisites or dependencies (array) -8. **estimatedCost**: Estimated cost in dollars (number or null if not applicable) -9. **requiredResources**: List of required resources/people (array) - -Respond in this exact JSON format: - -{{ - "suggestions": [ - {{ - "title": "Strategy Title Here", - "description": "Detailed description of the strategy", - "rationale": "Why this strategy will help achieve the goal", - "difficulty": "medium", - "timeframe": "2-3 months", - "expectedImpact": "high", - "prerequisites": ["Prerequisite 1", "Prerequisite 2"], - "estimatedCost": 15000, - "requiredResources": ["Resource 1", "Resource 2"] - }} - ], - "confidence": 0.85, - "reasoning": "Overall reasoning for why these strategies were recommended given the business context" -}} - -Important guidelines: -- Strategies should be specific, actionable, and realistic -- Consider the business context, values, and constraints -- Build upon or complement existing strategies where appropriate -- Ensure recommendations align with the vision and purpose -- If constraints are provided, respect them in your recommendations -- Provide honest confidence score (0.0-1.0) based on information quality""" - - return prompt - - def parse_response(self, llm_response: str) -> dict[str, Any]: - """ - Parse strategy suggestion response. - - Args: - llm_response: Raw LLM JSON response - - Returns: - Structured strategy suggestions result - - Expected structure: - - suggestions: list[dict] with strategy details - - confidence: float (0.0-1.0) - - reasoning: str - """ - try: - # Extract JSON from response (handle markdown code blocks) - response_text = llm_response.strip() - - # Remove markdown code blocks if present - if response_text.startswith("```json"): - response_text = response_text[7:] - if response_text.startswith("```"): - response_text = response_text[3:] - if response_text.endswith("```"): - response_text = response_text[:-3] - - result: dict[str, Any] = json.loads(response_text.strip()) - - # Validate required fields - required_fields = ["suggestions", "confidence", "reasoning"] - for field in required_fields: - if field not in result: - raise ValueError(f"Missing required field: {field}") - - # Validate suggestions structure - if not isinstance(result["suggestions"], list): - raise ValueError("suggestions must be a list") - - if not result["suggestions"]: - raise ValueError("At least one suggestion is required") - - # Validate each suggestion - required_suggestion_fields = [ - "title", - "description", - "rationale", - "difficulty", - "timeframe", - "expectedImpact", - ] - - for idx, suggestion in enumerate(result["suggestions"]): - for field in required_suggestion_fields: - if field not in suggestion: - raise ValueError(f"Suggestion {idx} missing required field: {field}") - - # Ensure optional fields have defaults - suggestion.setdefault("prerequisites", []) - suggestion.setdefault("estimatedCost", None) - suggestion.setdefault("requiredResources", []) - - # Validate difficulty values - if suggestion["difficulty"] not in ["low", "medium", "high"]: - logger.warning( - "Invalid difficulty value, defaulting to medium", - difficulty=suggestion["difficulty"], - ) - suggestion["difficulty"] = "medium" - - # Validate expectedImpact values - if suggestion["expectedImpact"] not in ["low", "medium", "high"]: - logger.warning( - "Invalid expectedImpact value, defaulting to medium", - impact=suggestion["expectedImpact"], - ) - suggestion["expectedImpact"] = "medium" - - # Validate confidence range - if not 0.0 <= result["confidence"] <= 1.0: - logger.warning( - "Confidence score out of range, capping", - confidence=result["confidence"], - ) - result["confidence"] = max(0.0, min(1.0, result["confidence"])) - - logger.info( - "Strategy suggestions parsed successfully", - suggestion_count=len(result["suggestions"]), - confidence=result["confidence"], - ) - - return result - - except json.JSONDecodeError as e: - logger.error("Failed to parse strategy suggestions JSON", error=str(e)) - raise ValueError(f"Invalid JSON response from LLM: {e}") from e - except (KeyError, ValueError) as e: - logger.error("Invalid strategy suggestions structure", error=str(e)) - raise ValueError(f"Invalid strategy suggestions structure: {e}") from e - - -__all__ = ["StrategySuggestionService"] +"""Strategy suggestion service for AI-powered strategic recommendations. + +Generates actionable strategy recommendations based on goal intent, business context, +and resource constraints using LLM analysis. +""" + +import json +from typing import Any + +import structlog + +from coaching.src.application.analysis.base_analysis_service import BaseAnalysisService +from coaching.src.core.constants import AnalysisType + +logger = structlog.get_logger() + + +class StrategySuggestionService(BaseAnalysisService): + """ + Service for generating strategy suggestions. + + Analyzes: + - Goal intent and desired outcomes + - Business context (vision, values, market) + - Existing strategies + - Resource constraints + + Output: + - List of strategic recommendations + - Each with title, description, rationale, difficulty, impact + - Confidence score and reasoning + """ + + def get_analysis_type(self) -> AnalysisType: + """Return STRATEGY analysis type.""" + return AnalysisType.STRATEGY + + def build_prompt(self, context: dict[str, Any]) -> str: + """ + Build strategy suggestion prompt. + + Required context: + - goal_intent: The goal requiring strategies + - business_context: Dict with vision, purpose, values, etc. + - existing_strategies: List of current strategies (optional) + - constraints: Dict with budget, timeline, resources (optional) + + Returns: + Formatted prompt for strategy generation + """ + goal_intent = context.get("goal_intent", "") + business_ctx = context.get("business_context", {}) + existing_strategies = context.get("existing_strategies", []) + constraints = context.get("constraints", {}) + + # Extract business context fields + vision = business_ctx.get("vision", "Not defined") + purpose = business_ctx.get("purpose", "Not defined") + core_values = business_ctx.get("coreValues", []) + target_market = business_ctx.get("targetMarket", "Not defined") + value_prop = business_ctx.get("valueProposition", "Not defined") + industry = business_ctx.get("industry", "Not specified") + business_type = business_ctx.get("businessType", "Not specified") + + values_str = ", ".join(core_values) if core_values else "Not defined" + existing_str = ( + "\n".join([f"- {s}" for s in existing_strategies]) + if existing_strategies + else "None currently in place" + ) + + # Build constraints section + constraints_section = "" + if constraints: + budget = constraints.get("budget") + timeline = constraints.get("timeline") + resources = constraints.get("resources", []) + + constraints_section = f""" +**Resource Constraints:** +- Budget: ${budget:,} if budget else 'Flexible' +- Timeline: {timeline or "Flexible"} +- Available Resources: {", ".join(resources) if resources else "To be determined"} +""" + + prompt = f"""You are an expert business strategist helping develop actionable strategies for a business goal. + +**Goal:** +{goal_intent} + +**Business Context:** +- Vision: {vision} +- Purpose: {purpose} +- Core Values: {values_str} +- Target Market: {target_market} +- Value Proposition: {value_prop} +- Industry: {industry} +- Business Type: {business_type} + +**Existing Strategies:** +{existing_str} +{constraints_section} + +Please generate 3-5 strategic recommendations to achieve this goal. For each strategy, provide: +1. **title**: Clear, actionable title (4-8 words) +2. **description**: Detailed description of the strategy (2-3 sentences) +3. **rationale**: Why this strategy makes sense given the context (2-3 sentences) +4. **difficulty**: Implementation difficulty (low/medium/high) +5. **timeframe**: Expected implementation timeframe (e.g., "2-3 months", "6 weeks") +6. **expectedImpact**: Expected business impact (low/medium/high) +7. **prerequisites**: List of prerequisites or dependencies (array) +8. **estimatedCost**: Estimated cost in dollars (number or null if not applicable) +9. **requiredResources**: List of required resources/people (array) + +Respond in this exact JSON format: + +{{ + "suggestions": [ + {{ + "title": "Strategy Title Here", + "description": "Detailed description of the strategy", + "rationale": "Why this strategy will help achieve the goal", + "difficulty": "medium", + "timeframe": "2-3 months", + "expectedImpact": "high", + "prerequisites": ["Prerequisite 1", "Prerequisite 2"], + "estimatedCost": 15000, + "requiredResources": ["Resource 1", "Resource 2"] + }} + ], + "confidence": 0.85, + "reasoning": "Overall reasoning for why these strategies were recommended given the business context" +}} + +Important guidelines: +- Strategies should be specific, actionable, and realistic +- Consider the business context, values, and constraints +- Build upon or complement existing strategies where appropriate +- Ensure recommendations align with the vision and purpose +- If constraints are provided, respect them in your recommendations +- Provide honest confidence score (0.0-1.0) based on information quality""" + + return prompt + + def parse_response(self, llm_response: str) -> dict[str, Any]: + """ + Parse strategy suggestion response. + + Args: + llm_response: Raw LLM JSON response + + Returns: + Structured strategy suggestions result + + Expected structure: + - suggestions: list[dict] with strategy details + - confidence: float (0.0-1.0) + - reasoning: str + """ + try: + # Extract JSON from response (handle markdown code blocks) + response_text = llm_response.strip() + + # Remove markdown code blocks if present + if response_text.startswith("```json"): + response_text = response_text[7:] + if response_text.startswith("```"): + response_text = response_text[3:] + if response_text.endswith("```"): + response_text = response_text[:-3] + + result: dict[str, Any] = json.loads(response_text.strip()) + + # Validate required fields + required_fields = ["suggestions", "confidence", "reasoning"] + for field in required_fields: + if field not in result: + raise ValueError(f"Missing required field: {field}") + + # Validate suggestions structure + if not isinstance(result["suggestions"], list): + raise ValueError("suggestions must be a list") + + if not result["suggestions"]: + raise ValueError("At least one suggestion is required") + + # Validate each suggestion + required_suggestion_fields = [ + "title", + "description", + "rationale", + "difficulty", + "timeframe", + "expectedImpact", + ] + + for idx, suggestion in enumerate(result["suggestions"]): + for field in required_suggestion_fields: + if field not in suggestion: + raise ValueError(f"Suggestion {idx} missing required field: {field}") + + # Ensure optional fields have defaults + suggestion.setdefault("prerequisites", []) + suggestion.setdefault("estimatedCost", None) + suggestion.setdefault("requiredResources", []) + + # Validate difficulty values + if suggestion["difficulty"] not in ["low", "medium", "high"]: + logger.warning( + "Invalid difficulty value, defaulting to medium", + difficulty=suggestion["difficulty"], + ) + suggestion["difficulty"] = "medium" + + # Validate expectedImpact values + if suggestion["expectedImpact"] not in ["low", "medium", "high"]: + logger.warning( + "Invalid expectedImpact value, defaulting to medium", + impact=suggestion["expectedImpact"], + ) + suggestion["expectedImpact"] = "medium" + + # Validate confidence range + if not 0.0 <= result["confidence"] <= 1.0: + logger.warning( + "Confidence score out of range, capping", + confidence=result["confidence"], + ) + result["confidence"] = max(0.0, min(1.0, result["confidence"])) + + logger.info( + "Strategy suggestions parsed successfully", + suggestion_count=len(result["suggestions"]), + confidence=result["confidence"], + ) + + return result + + except json.JSONDecodeError as e: + logger.error("Failed to parse strategy suggestions JSON", error=str(e)) + raise ValueError(f"Invalid JSON response from LLM: {e}") from e + except (KeyError, ValueError) as e: + logger.error("Invalid strategy suggestions structure", error=str(e)) + raise ValueError(f"Invalid strategy suggestions structure: {e}") from e + + +__all__ = ["StrategySuggestionService"] diff --git a/coaching/src/application/conversation/conversation_service.py b/coaching/src/application/conversation/conversation_service.py index a832a36d..912f1e61 100644 --- a/coaching/src/application/conversation/conversation_service.py +++ b/coaching/src/application/conversation/conversation_service.py @@ -1,361 +1,362 @@ -"""Conversation application service. - -This service orchestrates conversation-related use cases, coordinating -domain entities, repositories, and infrastructure services. -""" - -from datetime import UTC, datetime -from typing import Any - -import structlog -from coaching.src.core.constants import CoachingTopic, ConversationStatus, MessageRole -from coaching.src.core.types import ConversationId, TenantId, UserId -from coaching.src.domain.entities.conversation import Conversation -from coaching.src.domain.exceptions.conversation_exceptions import ( - ConversationNotActive, - ConversationNotFound, -) -from coaching.src.domain.ports.conversation_repository_port import ConversationRepositoryPort - -logger = structlog.get_logger() - - -class ConversationApplicationService: - """ - Application service for conversation management. - - This service implements conversation-related use cases, - orchestrating domain logic and infrastructure concerns. - - Design Principles: - - Dependency injection (depends on ports, not implementations) - - Use case-driven methods (one method per use case) - - Transaction boundaries explicit - - Domain events published - - Clear error handling - """ - - def __init__(self, conversation_repository: ConversationRepositoryPort): - """ - Initialize conversation application service. - - Args: - conversation_repository: Repository for conversation persistence - """ - self.repository = conversation_repository - logger.info("Conversation application service initialized") - - async def start_conversation( - self, - user_id: UserId, - tenant_id: TenantId, - topic: CoachingTopic, - initial_message_content: str, - metadata: dict[str, Any] | None = None, - ) -> Conversation: - """ - Start a new coaching conversation. - - Use Case: User initiates a new coaching session - - Args: - user_id: User identifier - tenant_id: Tenant identifier - topic: Coaching topic for this conversation - initial_message_content: Initial greeting message - metadata: Optional metadata - - Returns: - Created conversation entity - - Business Rule: Only one active conversation per user per topic - """ - try: - # Check if user has active conversation for this topic - active_count = await self.repository.get_active_count(user_id, tenant_id) - if active_count >= 5: # Business rule: max 5 active conversations - logger.warning( - "User has too many active conversations", - user_id=user_id, - active_count=active_count, - ) - - # Create new conversation - conv_id = ConversationId(f"conv_{user_id}_{int(datetime.now(UTC).timestamp())}") - conversation = Conversation( - conversation_id=conv_id, - user_id=user_id, - tenant_id=tenant_id, - topic=topic, - metadata=metadata or {}, - ) - - # Add initial assistant message - conversation.add_message( - role=MessageRole.ASSISTANT, - content=initial_message_content, - ) - - # Persist - await self.repository.save(conversation) - - logger.info( - "Conversation started", - conversation_id=conversation.conversation_id, - user_id=user_id, - topic=topic.value, - ) - - return conversation - - except Exception as e: - logger.error( - "Failed to start conversation", - user_id=user_id, - topic=topic.value, - error=str(e), - ) - raise - - async def add_message( - self, - conversation_id: ConversationId, - tenant_id: TenantId, - role: MessageRole, - content: str, - ) -> Conversation: - """ - Add a message to an existing conversation. - - Use Case: User or assistant adds a message - - Args: - conversation_id: Conversation identifier - tenant_id: Tenant identifier (for isolation) - role: Message role (USER or ASSISTANT) - content: Message content - - Returns: - Updated conversation entity - - Raises: - ConversationNotFound: If conversation doesn't exist - ConversationNotActive: If conversation is not active - """ - try: - # Retrieve conversation - conversation = await self.repository.get_by_id(conversation_id, tenant_id) - if not conversation: - raise ConversationNotFound(conversation_id, tenant_id) - - # Check if active - if conversation.status != ConversationStatus.ACTIVE: - raise ConversationNotActive(conversation_id, conversation.status, "add message") - - # Add message (domain entity enforces rules) - conversation.add_message(role=role, content=content) - - # Persist - await self.repository.save(conversation) - - logger.info( - "Message added", - conversation_id=conversation_id, - role=role.value, - message_count=len(conversation.messages), - ) - - return conversation - - except (ConversationNotFound, ConversationNotActive): - raise - except Exception as e: - logger.error( - "Failed to add message", - conversation_id=conversation_id, - error=str(e), - ) - raise - - async def get_conversation( - self, conversation_id: ConversationId, tenant_id: TenantId - ) -> Conversation: - """ - Retrieve a conversation by ID. - - Use Case: Load conversation for display or continuation - - Args: - conversation_id: Conversation identifier - tenant_id: Tenant identifier (for isolation) - - Returns: - Conversation entity - - Raises: - ConversationNotFound: If conversation doesn't exist - """ - conversation = await self.repository.get_by_id(conversation_id, tenant_id) - if not conversation: - raise ConversationNotFound(conversation_id, tenant_id) - - return conversation - - async def list_user_conversations( - self, - user_id: UserId, - tenant_id: TenantId, - limit: int = 10, - active_only: bool = False, - ) -> list[Conversation]: - """ - List conversations for a user. - - Use Case: Display user's conversation history - - Args: - user_id: User identifier - tenant_id: Tenant identifier - limit: Maximum conversations to return - active_only: If True, only active conversations - - Returns: - List of conversation entities (most recent first) - """ - conversations = await self.repository.get_by_user( - user_id=user_id, - tenant_id=tenant_id, - limit=limit, - active_only=active_only, - ) - - logger.debug( - "Conversations listed", - user_id=user_id, - count=len(conversations), - active_only=active_only, - ) - - return conversations - - async def pause_conversation( - self, conversation_id: ConversationId, tenant_id: TenantId - ) -> Conversation: - """ - Pause an active conversation. - - Use Case: User pauses conversation for later continuation - - Args: - conversation_id: Conversation identifier - tenant_id: Tenant identifier - - Returns: - Updated conversation entity - - Raises: - ConversationNotFound: If conversation doesn't exist - """ - conversation = await self.get_conversation(conversation_id, tenant_id) - - # Pause (domain entity enforces rules) - conversation.mark_paused() - - # Persist - await self.repository.save(conversation) - - logger.info("Conversation paused", conversation_id=conversation_id) - - return conversation - - async def resume_conversation( - self, conversation_id: ConversationId, tenant_id: TenantId - ) -> Conversation: - """ - Resume a paused conversation. - - Use Case: User resumes previously paused conversation - - Args: - conversation_id: Conversation identifier - tenant_id: Tenant identifier - - Returns: - Updated conversation entity - - Raises: - ConversationNotFound: If conversation doesn't exist - """ - conversation = await self.get_conversation(conversation_id, tenant_id) - - # Resume (domain entity enforces rules) - conversation.resume() - - # Persist - await self.repository.save(conversation) - - logger.info("Conversation resumed", conversation_id=conversation_id) - - return conversation - - async def complete_conversation( - self, conversation_id: ConversationId, tenant_id: TenantId - ) -> Conversation: - """ - Mark conversation as completed. - - Use Case: User or system marks conversation as done - - Args: - conversation_id: Conversation identifier - tenant_id: Tenant identifier - - Returns: - Updated conversation entity - - Raises: - ConversationNotFound: If conversation doesn't exist - """ - conversation = await self.get_conversation(conversation_id, tenant_id) - - # Complete (domain entity enforces rules) - conversation.mark_completed() - - # Persist - await self.repository.save(conversation) - - logger.info("Conversation completed", conversation_id=conversation_id) - - return conversation - - async def abandon_conversation( - self, conversation_id: ConversationId, tenant_id: TenantId - ) -> bool: - """ - Abandon (soft delete) a conversation. - - Use Case: User or system abandons conversation - - Args: - conversation_id: Conversation identifier - tenant_id: Tenant identifier - - Returns: - True if abandoned successfully - - Raises: - ConversationNotFound: If conversation doesn't exist - """ - deleted = await self.repository.delete(conversation_id, tenant_id) - - if deleted: - logger.info("Conversation abandoned", conversation_id=conversation_id) - else: - logger.warning( - "Conversation not found for abandonment", conversation_id=conversation_id - ) - - return deleted - - -__all__ = ["ConversationApplicationService"] +"""Conversation application service. + +This service orchestrates conversation-related use cases, coordinating +domain entities, repositories, and infrastructure services. +""" + +from datetime import UTC, datetime +from typing import Any + +import structlog + +from coaching.src.core.constants import CoachingTopic, ConversationStatus, MessageRole +from coaching.src.core.types import ConversationId, TenantId, UserId +from coaching.src.domain.entities.conversation import Conversation +from coaching.src.domain.exceptions.conversation_exceptions import ( + ConversationNotActive, + ConversationNotFound, +) +from coaching.src.domain.ports.conversation_repository_port import ConversationRepositoryPort + +logger = structlog.get_logger() + + +class ConversationApplicationService: + """ + Application service for conversation management. + + This service implements conversation-related use cases, + orchestrating domain logic and infrastructure concerns. + + Design Principles: + - Dependency injection (depends on ports, not implementations) + - Use case-driven methods (one method per use case) + - Transaction boundaries explicit + - Domain events published + - Clear error handling + """ + + def __init__(self, conversation_repository: ConversationRepositoryPort): + """ + Initialize conversation application service. + + Args: + conversation_repository: Repository for conversation persistence + """ + self.repository = conversation_repository + logger.info("Conversation application service initialized") + + async def start_conversation( + self, + user_id: UserId, + tenant_id: TenantId, + topic: CoachingTopic, + initial_message_content: str, + metadata: dict[str, Any] | None = None, + ) -> Conversation: + """ + Start a new coaching conversation. + + Use Case: User initiates a new coaching session + + Args: + user_id: User identifier + tenant_id: Tenant identifier + topic: Coaching topic for this conversation + initial_message_content: Initial greeting message + metadata: Optional metadata + + Returns: + Created conversation entity + + Business Rule: Only one active conversation per user per topic + """ + try: + # Check if user has active conversation for this topic + active_count = await self.repository.get_active_count(user_id, tenant_id) + if active_count >= 5: # Business rule: max 5 active conversations + logger.warning( + "User has too many active conversations", + user_id=user_id, + active_count=active_count, + ) + + # Create new conversation + conv_id = ConversationId(f"conv_{user_id}_{int(datetime.now(UTC).timestamp())}") + conversation = Conversation( + conversation_id=conv_id, + user_id=user_id, + tenant_id=tenant_id, + topic=topic, + metadata=metadata or {}, + ) + + # Add initial assistant message + conversation.add_message( + role=MessageRole.ASSISTANT, + content=initial_message_content, + ) + + # Persist + await self.repository.save(conversation) + + logger.info( + "Conversation started", + conversation_id=conversation.conversation_id, + user_id=user_id, + topic=topic.value, + ) + + return conversation + + except Exception as e: + logger.error( + "Failed to start conversation", + user_id=user_id, + topic=topic.value, + error=str(e), + ) + raise + + async def add_message( + self, + conversation_id: ConversationId, + tenant_id: TenantId, + role: MessageRole, + content: str, + ) -> Conversation: + """ + Add a message to an existing conversation. + + Use Case: User or assistant adds a message + + Args: + conversation_id: Conversation identifier + tenant_id: Tenant identifier (for isolation) + role: Message role (USER or ASSISTANT) + content: Message content + + Returns: + Updated conversation entity + + Raises: + ConversationNotFound: If conversation doesn't exist + ConversationNotActive: If conversation is not active + """ + try: + # Retrieve conversation + conversation = await self.repository.get_by_id(conversation_id, tenant_id) + if not conversation: + raise ConversationNotFound(conversation_id, tenant_id) + + # Check if active + if conversation.status != ConversationStatus.ACTIVE: + raise ConversationNotActive(conversation_id, conversation.status, "add message") + + # Add message (domain entity enforces rules) + conversation.add_message(role=role, content=content) + + # Persist + await self.repository.save(conversation) + + logger.info( + "Message added", + conversation_id=conversation_id, + role=role.value, + message_count=len(conversation.messages), + ) + + return conversation + + except (ConversationNotFound, ConversationNotActive): + raise + except Exception as e: + logger.error( + "Failed to add message", + conversation_id=conversation_id, + error=str(e), + ) + raise + + async def get_conversation( + self, conversation_id: ConversationId, tenant_id: TenantId + ) -> Conversation: + """ + Retrieve a conversation by ID. + + Use Case: Load conversation for display or continuation + + Args: + conversation_id: Conversation identifier + tenant_id: Tenant identifier (for isolation) + + Returns: + Conversation entity + + Raises: + ConversationNotFound: If conversation doesn't exist + """ + conversation = await self.repository.get_by_id(conversation_id, tenant_id) + if not conversation: + raise ConversationNotFound(conversation_id, tenant_id) + + return conversation + + async def list_user_conversations( + self, + user_id: UserId, + tenant_id: TenantId, + limit: int = 10, + active_only: bool = False, + ) -> list[Conversation]: + """ + List conversations for a user. + + Use Case: Display user's conversation history + + Args: + user_id: User identifier + tenant_id: Tenant identifier + limit: Maximum conversations to return + active_only: If True, only active conversations + + Returns: + List of conversation entities (most recent first) + """ + conversations = await self.repository.get_by_user( + user_id=user_id, + tenant_id=tenant_id, + limit=limit, + active_only=active_only, + ) + + logger.debug( + "Conversations listed", + user_id=user_id, + count=len(conversations), + active_only=active_only, + ) + + return conversations + + async def pause_conversation( + self, conversation_id: ConversationId, tenant_id: TenantId + ) -> Conversation: + """ + Pause an active conversation. + + Use Case: User pauses conversation for later continuation + + Args: + conversation_id: Conversation identifier + tenant_id: Tenant identifier + + Returns: + Updated conversation entity + + Raises: + ConversationNotFound: If conversation doesn't exist + """ + conversation = await self.get_conversation(conversation_id, tenant_id) + + # Pause (domain entity enforces rules) + conversation.mark_paused() + + # Persist + await self.repository.save(conversation) + + logger.info("Conversation paused", conversation_id=conversation_id) + + return conversation + + async def resume_conversation( + self, conversation_id: ConversationId, tenant_id: TenantId + ) -> Conversation: + """ + Resume a paused conversation. + + Use Case: User resumes previously paused conversation + + Args: + conversation_id: Conversation identifier + tenant_id: Tenant identifier + + Returns: + Updated conversation entity + + Raises: + ConversationNotFound: If conversation doesn't exist + """ + conversation = await self.get_conversation(conversation_id, tenant_id) + + # Resume (domain entity enforces rules) + conversation.resume() + + # Persist + await self.repository.save(conversation) + + logger.info("Conversation resumed", conversation_id=conversation_id) + + return conversation + + async def complete_conversation( + self, conversation_id: ConversationId, tenant_id: TenantId + ) -> Conversation: + """ + Mark conversation as completed. + + Use Case: User or system marks conversation as done + + Args: + conversation_id: Conversation identifier + tenant_id: Tenant identifier + + Returns: + Updated conversation entity + + Raises: + ConversationNotFound: If conversation doesn't exist + """ + conversation = await self.get_conversation(conversation_id, tenant_id) + + # Complete (domain entity enforces rules) + conversation.mark_completed() + + # Persist + await self.repository.save(conversation) + + logger.info("Conversation completed", conversation_id=conversation_id) + + return conversation + + async def abandon_conversation( + self, conversation_id: ConversationId, tenant_id: TenantId + ) -> bool: + """ + Abandon (soft delete) a conversation. + + Use Case: User or system abandons conversation + + Args: + conversation_id: Conversation identifier + tenant_id: Tenant identifier + + Returns: + True if abandoned successfully + + Raises: + ConversationNotFound: If conversation doesn't exist + """ + deleted = await self.repository.delete(conversation_id, tenant_id) + + if deleted: + logger.info("Conversation abandoned", conversation_id=conversation_id) + else: + logger.warning( + "Conversation not found for abandonment", conversation_id=conversation_id + ) + + return deleted + + +__all__ = ["ConversationApplicationService"] diff --git a/coaching/src/application/enrichment/base_enrichment_service.py b/coaching/src/application/enrichment/base_enrichment_service.py index 9affeb60..a63b17f3 100644 --- a/coaching/src/application/enrichment/base_enrichment_service.py +++ b/coaching/src/application/enrichment/base_enrichment_service.py @@ -1,154 +1,155 @@ -"""Base enrichment service. - -This module provides the abstract base for enrichment services, -defining common patterns for adding context to analysis requests. -""" - -from abc import ABC, abstractmethod -from typing import Any - -import structlog -from coaching.src.infrastructure.cache.in_memory_cache import InMemoryCache - -logger = structlog.get_logger() - - -class BaseEnrichmentService(ABC): - """ - Abstract base class for enrichment services. - - Enrichment services add business context to analysis requests, - pulling data from external sources and caching for performance. - - Design Principles: - - Template Method pattern - - Caching for performance - - Graceful degradation on failures - - Timeout protection - - Mock-friendly for testing - """ - - def __init__(self, cache: InMemoryCache | None = None, cache_ttl: int = 3600): - """ - Initialize base enrichment service. - - Args: - cache: Optional cache implementation (defaults to in-memory) - cache_ttl: Cache TTL in seconds (default: 1 hour) - """ - self.cache = cache or InMemoryCache(default_ttl=cache_ttl) - self.cache_ttl = cache_ttl - logger.info(f"{self.__class__.__name__} initialized with caching") - - @abstractmethod - def get_enrichment_type(self) -> str: - """ - Get the enrichment type identifier. - - Returns: - Enrichment type string - - Implemented by subclasses. - """ - pass - - @abstractmethod - async def fetch_enrichment_data(self, context: dict[str, Any]) -> dict[str, Any]: - """ - Fetch enrichment data from external sources. - - Args: - context: Request context with identifiers - - Returns: - Enrichment data - - Implemented by subclasses to fetch type-specific data. - Must handle errors gracefully and return partial data if needed. - """ - pass - - def build_cache_key(self, context: dict[str, Any]) -> str: - """ - Build cache key from context. - - Args: - context: Request context - - Returns: - Cache key string - - Can be overridden for custom cache key logic. - """ - enrichment_type = self.get_enrichment_type() - user_id = context.get("user_id", "unknown") - tenant_id = context.get("tenant_id", "unknown") - - return f"enrichment:{enrichment_type}:{tenant_id}:{user_id}" - - async def enrich(self, context: dict[str, Any], use_cache: bool = True) -> dict[str, Any]: - """ - Enrich context with additional data (Template Method). - - This is the main entry point for enrichment. - - Workflow: - 1. Build cache key - 2. Check cache if enabled - 3. Fetch data if cache miss - 4. Cache result - 5. Return enriched context - - Args: - context: Base context to enrich - use_cache: Whether to use caching - - Returns: - Enriched context with additional data - - Note: Never fails - returns original context on error - """ - enrichment_type = self.get_enrichment_type() - - try: - logger.info("Enrichment started", enrichment_type=enrichment_type) - - # Build cache key - cache_key = self.build_cache_key(context) - - # Check cache - if use_cache: - cached_data = await self.cache.get(cache_key) - if cached_data: - logger.info("Enrichment cache hit", enrichment_type=enrichment_type) - return {**context, **cached_data} - - # Fetch enrichment data - enrichment_data = await self.fetch_enrichment_data(context) - - # Cache result - if use_cache and enrichment_data: - await self.cache.set(cache_key, enrichment_data, ttl=self.cache_ttl) - - # Merge with original context - enriched_context = {**context, **enrichment_data} - - logger.info( - "Enrichment completed", - enrichment_type=enrichment_type, - keys_added=len(enrichment_data), - ) - - return enriched_context - - except Exception as e: - # Graceful degradation - return original context - logger.error( - "Enrichment failed, returning original context", - enrichment_type=enrichment_type, - error=str(e), - ) - return context - - -__all__ = ["BaseEnrichmentService"] +"""Base enrichment service. + +This module provides the abstract base for enrichment services, +defining common patterns for adding context to analysis requests. +""" + +from abc import ABC, abstractmethod +from typing import Any + +import structlog + +from coaching.src.infrastructure.cache.in_memory_cache import InMemoryCache + +logger = structlog.get_logger() + + +class BaseEnrichmentService(ABC): + """ + Abstract base class for enrichment services. + + Enrichment services add business context to analysis requests, + pulling data from external sources and caching for performance. + + Design Principles: + - Template Method pattern + - Caching for performance + - Graceful degradation on failures + - Timeout protection + - Mock-friendly for testing + """ + + def __init__(self, cache: InMemoryCache | None = None, cache_ttl: int = 3600): + """ + Initialize base enrichment service. + + Args: + cache: Optional cache implementation (defaults to in-memory) + cache_ttl: Cache TTL in seconds (default: 1 hour) + """ + self.cache = cache or InMemoryCache(default_ttl=cache_ttl) + self.cache_ttl = cache_ttl + logger.info(f"{self.__class__.__name__} initialized with caching") + + @abstractmethod + def get_enrichment_type(self) -> str: + """ + Get the enrichment type identifier. + + Returns: + Enrichment type string + + Implemented by subclasses. + """ + pass + + @abstractmethod + async def fetch_enrichment_data(self, context: dict[str, Any]) -> dict[str, Any]: + """ + Fetch enrichment data from external sources. + + Args: + context: Request context with identifiers + + Returns: + Enrichment data + + Implemented by subclasses to fetch type-specific data. + Must handle errors gracefully and return partial data if needed. + """ + pass + + def build_cache_key(self, context: dict[str, Any]) -> str: + """ + Build cache key from context. + + Args: + context: Request context + + Returns: + Cache key string + + Can be overridden for custom cache key logic. + """ + enrichment_type = self.get_enrichment_type() + user_id = context.get("user_id", "unknown") + tenant_id = context.get("tenant_id", "unknown") + + return f"enrichment:{enrichment_type}:{tenant_id}:{user_id}" + + async def enrich(self, context: dict[str, Any], use_cache: bool = True) -> dict[str, Any]: + """ + Enrich context with additional data (Template Method). + + This is the main entry point for enrichment. + + Workflow: + 1. Build cache key + 2. Check cache if enabled + 3. Fetch data if cache miss + 4. Cache result + 5. Return enriched context + + Args: + context: Base context to enrich + use_cache: Whether to use caching + + Returns: + Enriched context with additional data + + Note: Never fails - returns original context on error + """ + enrichment_type = self.get_enrichment_type() + + try: + logger.info("Enrichment started", enrichment_type=enrichment_type) + + # Build cache key + cache_key = self.build_cache_key(context) + + # Check cache + if use_cache: + cached_data = await self.cache.get(cache_key) + if cached_data: + logger.info("Enrichment cache hit", enrichment_type=enrichment_type) + return {**context, **cached_data} + + # Fetch enrichment data + enrichment_data = await self.fetch_enrichment_data(context) + + # Cache result + if use_cache and enrichment_data: + await self.cache.set(cache_key, enrichment_data, ttl=self.cache_ttl) + + # Merge with original context + enriched_context = {**context, **enrichment_data} + + logger.info( + "Enrichment completed", + enrichment_type=enrichment_type, + keys_added=len(enrichment_data), + ) + + return enriched_context + + except Exception as e: + # Graceful degradation - return original context + logger.error( + "Enrichment failed, returning original context", + enrichment_type=enrichment_type, + error=str(e), + ) + return context + + +__all__ = ["BaseEnrichmentService"] diff --git a/coaching/src/application/enrichment/business_context_enricher.py b/coaching/src/application/enrichment/business_context_enricher.py index e4c78342..6ce768d1 100644 --- a/coaching/src/application/enrichment/business_context_enricher.py +++ b/coaching/src/application/enrichment/business_context_enricher.py @@ -1,118 +1,119 @@ -"""Business context enrichment service. - -Enriches analysis requests with business data from the Business API. -""" - -from typing import Any - -import structlog -from coaching.src.application.enrichment.base_enrichment_service import BaseEnrichmentService -from coaching.src.infrastructure.external.business_api_client import BusinessApiClient - -logger = structlog.get_logger() - - -class BusinessContextEnricher(BaseEnrichmentService): - """ - Service for enriching with business context. - - Fetches and adds business data such as: - - User profile and role - - Organizational context - - User goals - - Note: User performance metrics not in MVP scope. - - Design: - - Caching to reduce API calls - - Graceful degradation on failures - - Selective enrichment (only fetch what's needed) - """ - - def __init__( - self, - business_api_client: BusinessApiClient, - cache: Any | None = None, - cache_ttl: int = 3600, - ): - """ - Initialize business context enricher. - - Args: - business_api_client: Client for Business API - cache: Optional cache implementation - cache_ttl: Cache TTL in seconds - """ - super().__init__(cache, cache_ttl) - self.business_api = business_api_client - logger.info("Business context enricher initialized") - - def get_enrichment_type(self) -> str: - """Return 'business_context' enrichment type.""" - return "business_context" - - async def fetch_enrichment_data(self, context: dict[str, Any]) -> dict[str, Any]: - """ - Fetch business context data from Business API. - - Args: - context: Request context with user_id and tenant_id - - Returns: - Enrichment data with business context - - Required context fields: - - user_id: User identifier - - tenant_id: Tenant identifier - - Optional context fields: - - include_goals: Whether to include user goals (default: True) - - include_org_context: Whether to include org context (default: True) - """ - user_id = context.get("user_id") - tenant_id = context.get("tenant_id") - - if not user_id or not tenant_id: - logger.warning("Missing user_id or tenant_id for enrichment") - return {} - - try: - enrichment_data: dict[str, Any] = {} - - # Fetch user context (always) - user_context = await self.business_api.get_user_context(user_id, tenant_id) - enrichment_data["user_profile"] = user_context - - # Fetch organizational context (if requested) - if context.get("include_org_context", True): - org_context = await self.business_api.get_organizational_context(tenant_id) - enrichment_data["organization"] = org_context - - # Fetch user goals (if requested) - if context.get("include_goals", True): - goals = await self.business_api.get_user_goals(user_id, tenant_id) - enrichment_data["user_goals"] = goals - - # Note: User metrics not in MVP scope - removed get_metrics() call - # For tenant-wide metrics, use Traction Service endpoints directly - - logger.info( - "Business context fetched", - user_id=user_id, - components=list(enrichment_data.keys()), - ) - - return enrichment_data - - except Exception as e: - # Log error but don't fail - return partial data or empty - logger.error( - "Failed to fetch business context", - user_id=user_id, - tenant_id=tenant_id, - error=str(e), - ) - return {} - - -__all__ = ["BusinessContextEnricher"] +"""Business context enrichment service. + +Enriches analysis requests with business data from the Business API. +""" + +from typing import Any + +import structlog + +from coaching.src.application.enrichment.base_enrichment_service import BaseEnrichmentService +from coaching.src.infrastructure.external.business_api_client import BusinessApiClient + +logger = structlog.get_logger() + + +class BusinessContextEnricher(BaseEnrichmentService): + """ + Service for enriching with business context. + + Fetches and adds business data such as: + - User profile and role + - Organizational context + - User goals + + Note: User performance metrics not in MVP scope. + + Design: + - Caching to reduce API calls + - Graceful degradation on failures + - Selective enrichment (only fetch what's needed) + """ + + def __init__( + self, + business_api_client: BusinessApiClient, + cache: Any | None = None, + cache_ttl: int = 3600, + ): + """ + Initialize business context enricher. + + Args: + business_api_client: Client for Business API + cache: Optional cache implementation + cache_ttl: Cache TTL in seconds + """ + super().__init__(cache, cache_ttl) + self.business_api = business_api_client + logger.info("Business context enricher initialized") + + def get_enrichment_type(self) -> str: + """Return 'business_context' enrichment type.""" + return "business_context" + + async def fetch_enrichment_data(self, context: dict[str, Any]) -> dict[str, Any]: + """ + Fetch business context data from Business API. + + Args: + context: Request context with user_id and tenant_id + + Returns: + Enrichment data with business context + + Required context fields: + - user_id: User identifier + - tenant_id: Tenant identifier + + Optional context fields: + - include_goals: Whether to include user goals (default: True) + - include_org_context: Whether to include org context (default: True) + """ + user_id = context.get("user_id") + tenant_id = context.get("tenant_id") + + if not user_id or not tenant_id: + logger.warning("Missing user_id or tenant_id for enrichment") + return {} + + try: + enrichment_data: dict[str, Any] = {} + + # Fetch user context (always) + user_context = await self.business_api.get_user_context(user_id, tenant_id) + enrichment_data["user_profile"] = user_context + + # Fetch organizational context (if requested) + if context.get("include_org_context", True): + org_context = await self.business_api.get_organizational_context(tenant_id) + enrichment_data["organization"] = org_context + + # Fetch user goals (if requested) + if context.get("include_goals", True): + goals = await self.business_api.get_user_goals(user_id, tenant_id) + enrichment_data["user_goals"] = goals + + # Note: User metrics not in MVP scope - removed get_metrics() call + # For tenant-wide metrics, use Traction Service endpoints directly + + logger.info( + "Business context fetched", + user_id=user_id, + components=list(enrichment_data.keys()), + ) + + return enrichment_data + + except Exception as e: + # Log error but don't fail - return partial data or empty + logger.error( + "Failed to fetch business context", + user_id=user_id, + tenant_id=tenant_id, + error=str(e), + ) + return {} + + +__all__ = ["BusinessContextEnricher"] diff --git a/coaching/src/application/llm/llm_service.py b/coaching/src/application/llm/llm_service.py index 587f4805..45df96d1 100644 --- a/coaching/src/application/llm/llm_service.py +++ b/coaching/src/application/llm/llm_service.py @@ -1,294 +1,295 @@ -"""LLM application service. - -This service orchestrates LLM interactions, abstracting provider details -and providing use-case specific methods for different LLM operations. -""" - -from collections.abc import AsyncIterator -from typing import Any - -import structlog -from coaching.src.domain.ports.llm_provider_port import ( - LLMMessage, - LLMProviderPort, - LLMResponse, -) - -logger = structlog.get_logger() - - -class LLMApplicationService: - """ - Application service for LLM orchestration. - - This service provides high-level LLM operations for different use cases, - abstracting away provider-specific details. - - Design Principles: - - Provider-agnostic (uses LLMProviderPort) - - Use case-driven methods - - Token tracking and metrics - - Error handling and retries - - Streaming support - """ - - def __init__(self, llm_provider: LLMProviderPort): - """ - Initialize LLM application service. - - Args: - llm_provider: LLM provider implementation - """ - self.provider = llm_provider - logger.info( - "LLM application service initialized", - provider=self.provider.provider_name, - ) - - async def generate_coaching_response( - self, - conversation_history: list[LLMMessage], - system_prompt: str | None = None, - model: str | None = None, - temperature: float = 0.7, - max_tokens: int | None = None, - ) -> LLMResponse: - """ - Generate coaching response from conversation. - - Use Case: Generate coach response in conversation - - Args: - conversation_history: List of conversation messages - system_prompt: Optional system prompt for context - model: Model to use (None = provider default) - temperature: Sampling temperature - max_tokens: Max tokens to generate - - Returns: - LLM response with content and metadata - - Business Rule: Temperature should be 0.5-0.8 for coaching (balanced creativity/consistency) - """ - try: - # Select model if not specified - if not model: - model = self._select_default_model() - - # Validate model - if not await self.provider.validate_model(model): - raise ValueError( - f"Model {model} not supported by provider {self.provider.provider_name}" - ) - - # Generate - response = await self.provider.generate( - messages=conversation_history, - model=model, - temperature=temperature, - max_tokens=max_tokens, - system_prompt=system_prompt, - ) - - logger.info( - "Coaching response generated", - model=model, - tokens=response.usage.get("total_tokens", 0), - finish_reason=response.finish_reason, - ) - - return response - - except Exception as e: - logger.error( - "Failed to generate coaching response", - model=model, - error=str(e), - ) - raise - - async def generate_analysis( - self, - analysis_prompt: str, - context: dict[str, Any] | None = None, - model: str | None = None, - temperature: float = 0.3, - max_tokens: int | None = None, - ) -> LLMResponse: - """ - Generate one-shot analysis. - - Use Case: Generate analysis (alignment, strategy, SWOT, etc.) - - Args: - analysis_prompt: The analysis prompt - context: Additional context as key-value pairs - model: Model to use - temperature: Sampling temperature - max_tokens: Max tokens to generate - - Returns: - LLM response with analysis - - Business Rule: Lower temperature (0.2-0.4) for analysis (more deterministic) - """ - try: - # Select model - if not model: - model = self._select_default_model() - - # Build messages - messages = [LLMMessage(role="user", content=analysis_prompt)] - - # Build system prompt with context - system_prompt = None - if context: - context_str = "\n".join([f"{k}: {v}" for k, v in context.items()]) - system_prompt = f"Analysis Context:\n{context_str}" - - # Generate - response = await self.provider.generate( - messages=messages, - model=model, - temperature=temperature, - max_tokens=max_tokens, - system_prompt=system_prompt, - ) - - logger.info( - "Analysis generated", - model=model, - tokens=response.usage.get("total_tokens", 0), - ) - - return response - - except Exception as e: - logger.error("Failed to generate analysis", model=model, error=str(e)) - raise - - async def generate_streaming_response( - self, - messages: list[LLMMessage], - model: str | None = None, - temperature: float = 0.7, - max_tokens: int | None = None, - system_prompt: str | None = None, - ) -> AsyncIterator[str]: - """ - Generate streaming response for real-time UX. - - Use Case: Stream response tokens for immediate user feedback - - Args: - messages: Conversation messages - model: Model to use - temperature: Sampling temperature - max_tokens: Max tokens - system_prompt: Optional system prompt - - Yields: - Token strings as they're generated - """ - try: - if not model: - model = self._select_default_model() - - logger.info("Starting streaming generation", model=model) - - async for token in self.provider.generate_stream( - _messages=messages, - _model=model, - _temperature=temperature, - _max_tokens=max_tokens, - _system_prompt=system_prompt, - ): - yield token - - except Exception as e: - logger.error("Streaming generation failed", model=model, error=str(e)) - raise - - async def count_message_tokens( - self, messages: list[LLMMessage], model: str | None = None - ) -> int: - """ - Count tokens in messages. - - Use Case: Track token usage, enforce limits - - Args: - messages: Messages to count - model: Model for tokenization - - Returns: - Total token count - """ - if not model: - model = self._select_default_model() - - # Combine all message content - combined_text = " ".join([msg.content for msg in messages]) - - token_count = await self.provider.count_tokens(combined_text, model) - - logger.debug("Tokens counted", model=model, count=token_count) - - return token_count - - async def validate_model_availability(self, model: str) -> bool: - """ - Check if model is available. - - Use Case: Pre-flight check before generation - - Args: - model: Model identifier - - Returns: - True if available - """ - is_valid = await self.provider.validate_model(model) - - logger.debug("Model validation", model=model, is_valid=is_valid) - - return is_valid - - def get_supported_models(self) -> list[str]: - """ - Get list of supported models. - - Use Case: Display model options to users/admins - - Returns: - List of model identifiers - """ - return self.provider.supported_models - - def get_provider_name(self) -> str: - """ - Get current provider name. - - Use Case: Diagnostics, logging - - Returns: - Provider identifier - """ - return self.provider.provider_name - - def _select_default_model(self) -> str: - """ - Select default model based on provider. - - Returns: - Default model identifier - """ - models = self.provider.supported_models - if not models: - raise ValueError(f"No models available from provider {self.provider.provider_name}") - - # Return first model as default - return models[0] - - -__all__ = ["LLMApplicationService"] +"""LLM application service. + +This service orchestrates LLM interactions, abstracting provider details +and providing use-case specific methods for different LLM operations. +""" + +from collections.abc import AsyncIterator +from typing import Any + +import structlog + +from coaching.src.domain.ports.llm_provider_port import ( + LLMMessage, + LLMProviderPort, + LLMResponse, +) + +logger = structlog.get_logger() + + +class LLMApplicationService: + """ + Application service for LLM orchestration. + + This service provides high-level LLM operations for different use cases, + abstracting away provider-specific details. + + Design Principles: + - Provider-agnostic (uses LLMProviderPort) + - Use case-driven methods + - Token tracking and metrics + - Error handling and retries + - Streaming support + """ + + def __init__(self, llm_provider: LLMProviderPort): + """ + Initialize LLM application service. + + Args: + llm_provider: LLM provider implementation + """ + self.provider = llm_provider + logger.info( + "LLM application service initialized", + provider=self.provider.provider_name, + ) + + async def generate_coaching_response( + self, + conversation_history: list[LLMMessage], + system_prompt: str | None = None, + model: str | None = None, + temperature: float = 0.7, + max_tokens: int | None = None, + ) -> LLMResponse: + """ + Generate coaching response from conversation. + + Use Case: Generate coach response in conversation + + Args: + conversation_history: List of conversation messages + system_prompt: Optional system prompt for context + model: Model to use (None = provider default) + temperature: Sampling temperature + max_tokens: Max tokens to generate + + Returns: + LLM response with content and metadata + + Business Rule: Temperature should be 0.5-0.8 for coaching (balanced creativity/consistency) + """ + try: + # Select model if not specified + if not model: + model = self._select_default_model() + + # Validate model + if not await self.provider.validate_model(model): + raise ValueError( + f"Model {model} not supported by provider {self.provider.provider_name}" + ) + + # Generate + response = await self.provider.generate( + messages=conversation_history, + model=model, + temperature=temperature, + max_tokens=max_tokens, + system_prompt=system_prompt, + ) + + logger.info( + "Coaching response generated", + model=model, + tokens=response.usage.get("total_tokens", 0), + finish_reason=response.finish_reason, + ) + + return response + + except Exception as e: + logger.error( + "Failed to generate coaching response", + model=model, + error=str(e), + ) + raise + + async def generate_analysis( + self, + analysis_prompt: str, + context: dict[str, Any] | None = None, + model: str | None = None, + temperature: float = 0.3, + max_tokens: int | None = None, + ) -> LLMResponse: + """ + Generate one-shot analysis. + + Use Case: Generate analysis (alignment, strategy, SWOT, etc.) + + Args: + analysis_prompt: The analysis prompt + context: Additional context as key-value pairs + model: Model to use + temperature: Sampling temperature + max_tokens: Max tokens to generate + + Returns: + LLM response with analysis + + Business Rule: Lower temperature (0.2-0.4) for analysis (more deterministic) + """ + try: + # Select model + if not model: + model = self._select_default_model() + + # Build messages + messages = [LLMMessage(role="user", content=analysis_prompt)] + + # Build system prompt with context + system_prompt = None + if context: + context_str = "\n".join([f"{k}: {v}" for k, v in context.items()]) + system_prompt = f"Analysis Context:\n{context_str}" + + # Generate + response = await self.provider.generate( + messages=messages, + model=model, + temperature=temperature, + max_tokens=max_tokens, + system_prompt=system_prompt, + ) + + logger.info( + "Analysis generated", + model=model, + tokens=response.usage.get("total_tokens", 0), + ) + + return response + + except Exception as e: + logger.error("Failed to generate analysis", model=model, error=str(e)) + raise + + async def generate_streaming_response( + self, + messages: list[LLMMessage], + model: str | None = None, + temperature: float = 0.7, + max_tokens: int | None = None, + system_prompt: str | None = None, + ) -> AsyncIterator[str]: + """ + Generate streaming response for real-time UX. + + Use Case: Stream response tokens for immediate user feedback + + Args: + messages: Conversation messages + model: Model to use + temperature: Sampling temperature + max_tokens: Max tokens + system_prompt: Optional system prompt + + Yields: + Token strings as they're generated + """ + try: + if not model: + model = self._select_default_model() + + logger.info("Starting streaming generation", model=model) + + async for token in self.provider.generate_stream( + _messages=messages, + _model=model, + _temperature=temperature, + _max_tokens=max_tokens, + _system_prompt=system_prompt, + ): + yield token + + except Exception as e: + logger.error("Streaming generation failed", model=model, error=str(e)) + raise + + async def count_message_tokens( + self, messages: list[LLMMessage], model: str | None = None + ) -> int: + """ + Count tokens in messages. + + Use Case: Track token usage, enforce limits + + Args: + messages: Messages to count + model: Model for tokenization + + Returns: + Total token count + """ + if not model: + model = self._select_default_model() + + # Combine all message content + combined_text = " ".join([msg.content for msg in messages]) + + token_count = await self.provider.count_tokens(combined_text, model) + + logger.debug("Tokens counted", model=model, count=token_count) + + return token_count + + async def validate_model_availability(self, model: str) -> bool: + """ + Check if model is available. + + Use Case: Pre-flight check before generation + + Args: + model: Model identifier + + Returns: + True if available + """ + is_valid = await self.provider.validate_model(model) + + logger.debug("Model validation", model=model, is_valid=is_valid) + + return is_valid + + def get_supported_models(self) -> list[str]: + """ + Get list of supported models. + + Use Case: Display model options to users/admins + + Returns: + List of model identifiers + """ + return self.provider.supported_models + + def get_provider_name(self) -> str: + """ + Get current provider name. + + Use Case: Diagnostics, logging + + Returns: + Provider identifier + """ + return self.provider.provider_name + + def _select_default_model(self) -> str: + """ + Select default model based on provider. + + Returns: + Default model identifier + """ + models = self.provider.supported_models + if not models: + raise ValueError(f"No models available from provider {self.provider.provider_name}") + + # Return first model as default + return models[0] + + +__all__ = ["LLMApplicationService"] diff --git a/coaching/src/application/llm_usage/llm_usage_recording_service.py b/coaching/src/application/llm_usage/llm_usage_recording_service.py index ea4b5683..9e9e8cac 100644 --- a/coaching/src/application/llm_usage/llm_usage_recording_service.py +++ b/coaching/src/application/llm_usage/llm_usage_recording_service.py @@ -6,6 +6,7 @@ from datetime import UTC, datetime import structlog + from coaching.src.application.llm_usage.llm_invocation_context import LlmInvocationContext from coaching.src.application.llm_usage.token_usage import normalize_token_counts from coaching.src.domain.entities.llm_topic import LLMTopic diff --git a/coaching/src/application/llm_usage/llm_usage_summary.py b/coaching/src/application/llm_usage/llm_usage_summary.py index c6cd1cdb..9371844b 100644 --- a/coaching/src/application/llm_usage/llm_usage_summary.py +++ b/coaching/src/application/llm_usage/llm_usage_summary.py @@ -1,8 +1,9 @@ """Aggregate metrics from usage rows (on-the-fly quota-oriented sums).""" -from coaching.src.domain.entities.llm_usage_record import LlmUsageRecord from pydantic import BaseModel, Field +from coaching.src.domain.entities.llm_usage_record import LlmUsageRecord + class LlmUsageSummary(BaseModel): """Roll-up over a set of usage rows (same source as Dynamo detail store).""" diff --git a/coaching/src/application/prompt/prompt_service.py b/coaching/src/application/prompt/prompt_service.py index c63142bd..16201df1 100644 --- a/coaching/src/application/prompt/prompt_service.py +++ b/coaching/src/application/prompt/prompt_service.py @@ -1,326 +1,327 @@ -"""Prompt application service. - -This service orchestrates prompt template management use cases, -supporting both end-user consumption and admin UI management. -""" - -import structlog -from coaching.src.core.constants import CoachingTopic -from coaching.src.core.types import PromptTemplateId -from coaching.src.domain.entities.prompt_template import PromptTemplate -from coaching.src.domain.ports.prompt_repository_port import PromptRepositoryPort - -logger = structlog.get_logger() - - -class PromptApplicationService: - """ - Application service for prompt template management. - - This service implements prompt-related use cases for both: - - End users: Retrieve templates for coaching conversations - - Admin users: CRUD operations for template management - - Design Principles: - - Dependency injection (depends on ports) - - Separate read and write use cases - - Version management built-in - - Cache-friendly operations - """ - - def __init__(self, prompt_repository: PromptRepositoryPort): - """ - Initialize prompt application service. - - Args: - prompt_repository: Repository for prompt template persistence - """ - self.repository = prompt_repository - logger.info("Prompt application service initialized") - - # ========== Read Operations (End User) ========== - - async def get_template_for_topic( - self, topic: CoachingTopic, version: str = "latest" - ) -> PromptTemplate | None: - """ - Get prompt template for a coaching topic. - - Use Case: Retrieve template for conversation (end user) - - Args: - topic: The coaching topic - version: Template version (default: "latest") - - Returns: - PromptTemplate if found, None otherwise - """ - template = await self.repository.get_by_topic(topic, version) - - if template: - logger.debug( - "Template retrieved", - topic=topic.value, - version=version, - template_id=template.template_id, - ) - else: - logger.warning("Template not found", topic=topic.value, version=version) - - return template - - async def get_template_by_id(self, template_id: PromptTemplateId) -> PromptTemplate | None: - """ - Get prompt template by ID. - - Use Case: Direct template retrieval by ID - - Args: - template_id: Unique template identifier - - Returns: - PromptTemplate if found, None otherwise - """ - template = await self.repository.get_by_id(template_id) - - if template: - logger.debug("Template retrieved by ID", template_id=template_id) - else: - logger.warning("Template not found by ID", template_id=template_id) - - return template - - async def list_template_versions(self, topic: CoachingTopic) -> list[str]: - """ - List all available versions for a topic. - - Use Case: Display version history (admin UI) - - Args: - topic: The coaching topic - - Returns: - List of version strings (newest first) - """ - versions = await self.repository.list_versions(topic) - - logger.debug("Template versions listed", topic=topic.value, count=len(versions)) - - return versions - - async def template_exists(self, topic: CoachingTopic, version: str = "latest") -> bool: - """ - Check if a template exists. - - Use Case: Validation before using template - - Args: - topic: The coaching topic - version: Template version - - Returns: - True if exists, False otherwise - """ - exists = await self.repository.exists(topic, version) - - logger.debug( - "Template existence checked", topic=topic.value, version=version, exists=exists - ) - - return exists - - # ========== Write Operations (Admin UI) ========== - - async def create_template(self, template: PromptTemplate, version: str) -> None: - """ - Create a new prompt template version. - - Use Case: Admin creates new template version - - Args: - template: The prompt template entity - version: Version identifier - - Raises: - ValueError: If version already exists or format invalid - """ - try: - await self.repository.save(template, version) - - logger.info( - "Template created", - topic=template.topic.value, - version=version, - template_id=template.template_id, - ) - - except ValueError as e: - logger.error( - "Template creation failed", - topic=template.topic.value, - version=version, - error=str(e), - ) - raise - except Exception as e: - logger.error( - "Template creation failed", - topic=template.topic.value, - version=version, - error=str(e), - ) - raise - - async def update_template(self, template: PromptTemplate, version: str) -> None: - """ - Update template (creates new version - templates are immutable). - - Use Case: Admin updates template (actually creates new version) - - Args: - template: The updated prompt template entity - version: New version identifier - - Raises: - ValueError: If version already exists - - Note: Templates are immutable, so "update" creates a new version - """ - await self.create_template(template, version) - - logger.info( - "Template updated (new version created)", - topic=template.topic.value, - version=version, - ) - - async def delete_template_version(self, topic: CoachingTopic, version: str) -> bool: - """ - Delete a specific template version. - - Use Case: Admin deletes old/unused template version - - Args: - topic: The coaching topic - version: Version to delete - - Returns: - True if deleted, False if not found - - Raises: - ValueError: If trying to delete "latest" version - - Business Rule: Cannot delete latest version without reassignment - """ - try: - deleted = await self.repository.delete(topic, version) - - if deleted: - logger.info("Template version deleted", topic=topic.value, version=version) - else: - logger.warning( - "Template version not found for deletion", topic=topic.value, version=version - ) - - return deleted - - except ValueError as e: - logger.error( - "Cannot delete template version", topic=topic.value, version=version, error=str(e) - ) - raise - except Exception as e: - logger.error( - "Template deletion failed", topic=topic.value, version=version, error=str(e) - ) - raise - - async def set_latest_version(self, topic: CoachingTopic, version: str) -> None: - """ - Mark a version as the latest/production version. - - Use Case: Admin promotes version to production - - Args: - topic: The coaching topic - version: Version to mark as latest - - Raises: - ValueError: If version doesn't exist - """ - try: - await self.repository.set_latest(topic, version) - - logger.info("Latest version updated", topic=topic.value, version=version) - - except ValueError as e: - logger.error( - "Cannot set latest version", topic=topic.value, version=version, error=str(e) - ) - raise - except Exception as e: - logger.error( - "Set latest version failed", topic=topic.value, version=version, error=str(e) - ) - raise - - async def create_draft_from_version( - self, topic: CoachingTopic, source_version: str, draft_version: str - ) -> PromptTemplate: - """ - Create a draft version by copying an existing version. - - Use Case: Admin creates draft for editing from production template - - Args: - topic: The coaching topic - source_version: Version to copy from (e.g., "v2.0") - draft_version: New draft version (e.g., "v2.1-draft") - - Returns: - The newly created draft template - - Raises: - ValueError: If source doesn't exist or draft already exists - - Typical workflow: - 1. create_draft_from_version("goals", "v2.0", "v2.1-draft") - 2. Admin edits draft in UI - 3. create_template(edited_template, "v2.1") - 4. set_latest_version("goals", "v2.1") - """ - try: - new_template = await self.repository.create_new_version( - topic, source_version, draft_version - ) - - logger.info( - "Draft template created", - topic=topic.value, - source=source_version, - draft=draft_version, - ) - - return new_template - - except ValueError as e: - logger.error( - "Draft creation failed", - topic=topic.value, - source=source_version, - draft=draft_version, - error=str(e), - ) - raise - except Exception as e: - logger.error( - "Draft creation failed", - topic=topic.value, - source=source_version, - draft=draft_version, - error=str(e), - ) - raise - - -__all__ = ["PromptApplicationService"] +"""Prompt application service. + +This service orchestrates prompt template management use cases, +supporting both end-user consumption and admin UI management. +""" + +import structlog + +from coaching.src.core.constants import CoachingTopic +from coaching.src.core.types import PromptTemplateId +from coaching.src.domain.entities.prompt_template import PromptTemplate +from coaching.src.domain.ports.prompt_repository_port import PromptRepositoryPort + +logger = structlog.get_logger() + + +class PromptApplicationService: + """ + Application service for prompt template management. + + This service implements prompt-related use cases for both: + - End users: Retrieve templates for coaching conversations + - Admin users: CRUD operations for template management + + Design Principles: + - Dependency injection (depends on ports) + - Separate read and write use cases + - Version management built-in + - Cache-friendly operations + """ + + def __init__(self, prompt_repository: PromptRepositoryPort): + """ + Initialize prompt application service. + + Args: + prompt_repository: Repository for prompt template persistence + """ + self.repository = prompt_repository + logger.info("Prompt application service initialized") + + # ========== Read Operations (End User) ========== + + async def get_template_for_topic( + self, topic: CoachingTopic, version: str = "latest" + ) -> PromptTemplate | None: + """ + Get prompt template for a coaching topic. + + Use Case: Retrieve template for conversation (end user) + + Args: + topic: The coaching topic + version: Template version (default: "latest") + + Returns: + PromptTemplate if found, None otherwise + """ + template = await self.repository.get_by_topic(topic, version) + + if template: + logger.debug( + "Template retrieved", + topic=topic.value, + version=version, + template_id=template.template_id, + ) + else: + logger.warning("Template not found", topic=topic.value, version=version) + + return template + + async def get_template_by_id(self, template_id: PromptTemplateId) -> PromptTemplate | None: + """ + Get prompt template by ID. + + Use Case: Direct template retrieval by ID + + Args: + template_id: Unique template identifier + + Returns: + PromptTemplate if found, None otherwise + """ + template = await self.repository.get_by_id(template_id) + + if template: + logger.debug("Template retrieved by ID", template_id=template_id) + else: + logger.warning("Template not found by ID", template_id=template_id) + + return template + + async def list_template_versions(self, topic: CoachingTopic) -> list[str]: + """ + List all available versions for a topic. + + Use Case: Display version history (admin UI) + + Args: + topic: The coaching topic + + Returns: + List of version strings (newest first) + """ + versions = await self.repository.list_versions(topic) + + logger.debug("Template versions listed", topic=topic.value, count=len(versions)) + + return versions + + async def template_exists(self, topic: CoachingTopic, version: str = "latest") -> bool: + """ + Check if a template exists. + + Use Case: Validation before using template + + Args: + topic: The coaching topic + version: Template version + + Returns: + True if exists, False otherwise + """ + exists = await self.repository.exists(topic, version) + + logger.debug( + "Template existence checked", topic=topic.value, version=version, exists=exists + ) + + return exists + + # ========== Write Operations (Admin UI) ========== + + async def create_template(self, template: PromptTemplate, version: str) -> None: + """ + Create a new prompt template version. + + Use Case: Admin creates new template version + + Args: + template: The prompt template entity + version: Version identifier + + Raises: + ValueError: If version already exists or format invalid + """ + try: + await self.repository.save(template, version) + + logger.info( + "Template created", + topic=template.topic.value, + version=version, + template_id=template.template_id, + ) + + except ValueError as e: + logger.error( + "Template creation failed", + topic=template.topic.value, + version=version, + error=str(e), + ) + raise + except Exception as e: + logger.error( + "Template creation failed", + topic=template.topic.value, + version=version, + error=str(e), + ) + raise + + async def update_template(self, template: PromptTemplate, version: str) -> None: + """ + Update template (creates new version - templates are immutable). + + Use Case: Admin updates template (actually creates new version) + + Args: + template: The updated prompt template entity + version: New version identifier + + Raises: + ValueError: If version already exists + + Note: Templates are immutable, so "update" creates a new version + """ + await self.create_template(template, version) + + logger.info( + "Template updated (new version created)", + topic=template.topic.value, + version=version, + ) + + async def delete_template_version(self, topic: CoachingTopic, version: str) -> bool: + """ + Delete a specific template version. + + Use Case: Admin deletes old/unused template version + + Args: + topic: The coaching topic + version: Version to delete + + Returns: + True if deleted, False if not found + + Raises: + ValueError: If trying to delete "latest" version + + Business Rule: Cannot delete latest version without reassignment + """ + try: + deleted = await self.repository.delete(topic, version) + + if deleted: + logger.info("Template version deleted", topic=topic.value, version=version) + else: + logger.warning( + "Template version not found for deletion", topic=topic.value, version=version + ) + + return deleted + + except ValueError as e: + logger.error( + "Cannot delete template version", topic=topic.value, version=version, error=str(e) + ) + raise + except Exception as e: + logger.error( + "Template deletion failed", topic=topic.value, version=version, error=str(e) + ) + raise + + async def set_latest_version(self, topic: CoachingTopic, version: str) -> None: + """ + Mark a version as the latest/production version. + + Use Case: Admin promotes version to production + + Args: + topic: The coaching topic + version: Version to mark as latest + + Raises: + ValueError: If version doesn't exist + """ + try: + await self.repository.set_latest(topic, version) + + logger.info("Latest version updated", topic=topic.value, version=version) + + except ValueError as e: + logger.error( + "Cannot set latest version", topic=topic.value, version=version, error=str(e) + ) + raise + except Exception as e: + logger.error( + "Set latest version failed", topic=topic.value, version=version, error=str(e) + ) + raise + + async def create_draft_from_version( + self, topic: CoachingTopic, source_version: str, draft_version: str + ) -> PromptTemplate: + """ + Create a draft version by copying an existing version. + + Use Case: Admin creates draft for editing from production template + + Args: + topic: The coaching topic + source_version: Version to copy from (e.g., "v2.0") + draft_version: New draft version (e.g., "v2.1-draft") + + Returns: + The newly created draft template + + Raises: + ValueError: If source doesn't exist or draft already exists + + Typical workflow: + 1. create_draft_from_version("goals", "v2.0", "v2.1-draft") + 2. Admin edits draft in UI + 3. create_template(edited_template, "v2.1") + 4. set_latest_version("goals", "v2.1") + """ + try: + new_template = await self.repository.create_new_version( + topic, source_version, draft_version + ) + + logger.info( + "Draft template created", + topic=topic.value, + source=source_version, + draft=draft_version, + ) + + return new_template + + except ValueError as e: + logger.error( + "Draft creation failed", + topic=topic.value, + source=source_version, + draft=draft_version, + error=str(e), + ) + raise + except Exception as e: + logger.error( + "Draft creation failed", + topic=topic.value, + source=source_version, + draft=draft_version, + error=str(e), + ) + raise + + +__all__ = ["PromptApplicationService"] diff --git a/coaching/src/core/config_multitenant.py b/coaching/src/core/config_multitenant.py index f1ac4818..bb17cabc 100644 --- a/coaching/src/core/config_multitenant.py +++ b/coaching/src/core/config_multitenant.py @@ -335,6 +335,7 @@ def get_google_vertex_credentials() -> dict[str, Any] | None: ) try: import structlog + from shared.services.aws_helpers import get_secretsmanager_client log = structlog.get_logger() diff --git a/coaching/src/core/response_model_registry.py b/coaching/src/core/response_model_registry.py index 5e3eddee..d09b350b 100644 --- a/coaching/src/core/response_model_registry.py +++ b/coaching/src/core/response_model_registry.py @@ -12,6 +12,8 @@ from typing import Any import structlog +from pydantic import BaseModel + from coaching.src.api.models.analysis import ( AlignmentAnalysisResponse, AlignmentExplanationResponse, @@ -49,7 +51,6 @@ InsightResponse, InsightsGenerationResponse, ) -from pydantic import BaseModel from shared.models.schemas import PaginatedResponse logger = structlog.get_logger() diff --git a/coaching/src/core/retrieval_method_registry.py b/coaching/src/core/retrieval_method_registry.py index 07a6684a..91c38287 100644 --- a/coaching/src/core/retrieval_method_registry.py +++ b/coaching/src/core/retrieval_method_registry.py @@ -30,6 +30,7 @@ async def my_method(context: RetrievalContext) -> dict[str, Any]: from typing import Any import structlog + from coaching.src.infrastructure.external.business_api_client import BusinessApiClient logger = structlog.get_logger() diff --git a/coaching/src/domain/entities/analysis_request.py b/coaching/src/domain/entities/analysis_request.py index faac071a..e27f4119 100644 --- a/coaching/src/domain/entities/analysis_request.py +++ b/coaching/src/domain/entities/analysis_request.py @@ -1,61 +1,62 @@ -"""AnalysisRequest value object. - -This module defines the AnalysisRequest for requesting business analysis. -""" - -from datetime import UTC, datetime -from typing import Any - -from coaching.src.core.constants import AnalysisType -from coaching.src.core.types import AnalysisRequestId, ConversationId, UserId -from pydantic import BaseModel, Field - - -class AnalysisRequest(BaseModel): - """ - Value object representing a business analysis request. - - Captures all information needed to perform a business analysis - including context, goals, and analysis type. - - Attributes: - request_id: Unique identifier for this request - conversation_id: Related conversation ID - user_id: User requesting the analysis - analysis_type: Type of analysis to perform - context_data: Business context for analysis - goals: Specific goals or questions for analysis - created_at: When request was created - """ - - request_id: AnalysisRequestId = Field(..., description="Unique request ID") - conversation_id: ConversationId = Field(..., description="Related conversation ID") - user_id: UserId = Field(..., description="User ID") - analysis_type: AnalysisType = Field(..., description="Type of analysis") - context_data: dict[str, Any] = Field(..., description="Business context for analysis") - goals: list[str] = Field(default_factory=list, description="Specific goals for analysis") - created_at: datetime = Field( - default_factory=lambda: datetime.now(UTC), - description="Request creation time", - ) - - model_config = {"frozen": True, "extra": "forbid"} - - def has_goals(self) -> bool: - """Check if request has specific goals.""" - return len(self.goals) > 0 - - def is_alignment_analysis(self) -> bool: - """Check if this is an alignment analysis.""" - return self.analysis_type == AnalysisType.ALIGNMENT - - def is_strategy_analysis(self) -> bool: - """Check if this is a strategy analysis.""" - return self.analysis_type == AnalysisType.STRATEGY - - def is_swot_analysis(self) -> bool: - """Check if this is a SWOT analysis.""" - return self.analysis_type == AnalysisType.SWOT - - -__all__ = ["AnalysisRequest"] +"""AnalysisRequest value object. + +This module defines the AnalysisRequest for requesting business analysis. +""" + +from datetime import UTC, datetime +from typing import Any + +from pydantic import BaseModel, Field + +from coaching.src.core.constants import AnalysisType +from coaching.src.core.types import AnalysisRequestId, ConversationId, UserId + + +class AnalysisRequest(BaseModel): + """ + Value object representing a business analysis request. + + Captures all information needed to perform a business analysis + including context, goals, and analysis type. + + Attributes: + request_id: Unique identifier for this request + conversation_id: Related conversation ID + user_id: User requesting the analysis + analysis_type: Type of analysis to perform + context_data: Business context for analysis + goals: Specific goals or questions for analysis + created_at: When request was created + """ + + request_id: AnalysisRequestId = Field(..., description="Unique request ID") + conversation_id: ConversationId = Field(..., description="Related conversation ID") + user_id: UserId = Field(..., description="User ID") + analysis_type: AnalysisType = Field(..., description="Type of analysis") + context_data: dict[str, Any] = Field(..., description="Business context for analysis") + goals: list[str] = Field(default_factory=list, description="Specific goals for analysis") + created_at: datetime = Field( + default_factory=lambda: datetime.now(UTC), + description="Request creation time", + ) + + model_config = {"frozen": True, "extra": "forbid"} + + def has_goals(self) -> bool: + """Check if request has specific goals.""" + return len(self.goals) > 0 + + def is_alignment_analysis(self) -> bool: + """Check if this is an alignment analysis.""" + return self.analysis_type == AnalysisType.ALIGNMENT + + def is_strategy_analysis(self) -> bool: + """Check if this is a strategy analysis.""" + return self.analysis_type == AnalysisType.STRATEGY + + def is_swot_analysis(self) -> bool: + """Check if this is a SWOT analysis.""" + return self.analysis_type == AnalysisType.SWOT + + +__all__ = ["AnalysisRequest"] diff --git a/coaching/src/domain/entities/coaching_session.py b/coaching/src/domain/entities/coaching_session.py index 4a91c707..71d8e491 100644 --- a/coaching/src/domain/entities/coaching_session.py +++ b/coaching/src/domain/entities/coaching_session.py @@ -10,6 +10,8 @@ from datetime import UTC, datetime, timedelta from typing import Any +from pydantic import BaseModel, Field, field_validator + from coaching.src.core.constants import ConversationStatus, MessageRole from coaching.src.core.types import ( SessionId, @@ -22,7 +24,6 @@ SessionExpiredError, SessionNotActiveError, ) -from pydantic import BaseModel, Field, field_validator class CoachingMessage(BaseModel): diff --git a/coaching/src/domain/entities/conversation.py b/coaching/src/domain/entities/conversation.py index 7feb2de6..5c995c30 100644 --- a/coaching/src/domain/entities/conversation.py +++ b/coaching/src/domain/entities/conversation.py @@ -1,314 +1,315 @@ -"""Conversation aggregate root. - -This module defines the Conversation entity as an aggregate root that enforces -business rules for coaching conversations. -""" - -from datetime import UTC, datetime -from typing import Any - -from coaching.src.core.constants import ( - PHASE_PROGRESS_WEIGHTS, - ConversationPhase, - ConversationStatus, - MessageRole, -) -from coaching.src.core.types import ConversationId, TenantId, UserId -from coaching.src.domain.value_objects.conversation_context import ( - ConversationContext, -) -from coaching.src.domain.value_objects.message import Message -from pydantic import BaseModel, Field, field_validator - - -class Conversation(BaseModel): - """ - Conversation aggregate root enforcing business rules. - - This is the main aggregate for coaching conversations, responsible for - maintaining conversation invariants, managing messages, and enforcing - phase transition rules. - - Attributes: - conversation_id: Unique identifier for this conversation - user_id: ID of the user having the conversation - tenant_id: ID of the tenant/organization - topic: Coaching topic for this conversation - status: Current status of the conversation - messages: List of messages in the conversation - context: Conversation context and progress tracking - created_at: When conversation was created - updated_at: When conversation was last updated - completed_at: When conversation was completed (if applicable) - - Business Rules: - - Cannot add messages to non-active conversations - - Conversations can be paused/resumed and marked complete based on - simple completion rules (no phase-based workflow) - """ - - conversation_id: ConversationId = Field(..., description="Unique conversation ID") - user_id: UserId = Field(..., description="User ID") - tenant_id: TenantId = Field(..., description="Tenant ID") - topic: str = Field(..., description="Coaching topic") - status: ConversationStatus = Field( - default=ConversationStatus.ACTIVE, description="Conversation status" - ) - messages: list[Message] = Field(default_factory=list, description="Conversation messages") - context: ConversationContext = Field( - default_factory=ConversationContext, - description="Conversation context", - ) - created_at: datetime = Field( - default_factory=lambda: datetime.now(UTC), - description="Creation timestamp", - ) - updated_at: datetime = Field( - default_factory=lambda: datetime.now(UTC), - description="Last update timestamp", - ) - completed_at: datetime | None = Field(default=None, description="Completion timestamp") - metadata: dict[str, Any] = Field(default_factory=dict, description="Additional metadata") - - model_config = {"extra": "forbid"} - - @classmethod - def create( - cls, - user_id: str, - tenant_id: str, - topic: str, - conversation_id: str | None = None, - metadata: dict[str, Any] | None = None, - ) -> "Conversation": - """Factory method to create a new conversation.""" - from uuid import uuid4 - - return cls( - conversation_id=ConversationId(conversation_id or str(uuid4())), - user_id=UserId(user_id), - tenant_id=TenantId(tenant_id), - topic=topic, - metadata=metadata or {}, - ) - - @field_validator("messages") - @classmethod - def validate_messages_chronological(cls, v: list[Message]) -> list[Message]: - """Ensure messages are in chronological order.""" - if len(v) > 1: - for i in range(len(v) - 1): - if v[i].timestamp > v[i + 1].timestamp: - raise ValueError("Messages must be in chronological order") - return v - - def add_message( - self, role: MessageRole, content: str, metadata: dict[str, Any] | None = None - ) -> None: - """ - Add a message to the conversation. - - Args: - role: Role of the message sender - content: Message content - metadata: Optional message metadata - - Raises: - ValueError: If conversation is not active - - Business Rule: Can only add messages to active conversations - """ - if not self.is_active(): - raise ValueError(f"Cannot add message to {self.status.value} conversation") - - message = Message(role=role, content=content, metadata=metadata or {}) - - # Use object.__setattr__ to modify frozen list - object.__setattr__(self, "messages", [*self.messages, message]) - object.__setattr__(self, "updated_at", datetime.now(UTC)) - - # Update context response count for user messages - if role == MessageRole.USER: - new_context = ConversationContext( - current_phase=self.context.current_phase, - insights=self.context.insights, - response_count=self.context.response_count + 1, - progress_percentage=self.context.progress_percentage, - metadata=self.context.metadata, - ) - object.__setattr__(self, "context", new_context) - - def add_user_message(self, content: str) -> None: - """Alias for add_message with role=USER.""" - self.add_message(MessageRole.USER, content) - - def add_assistant_message(self, content: str) -> None: - """Alias for add_message with role=ASSISTANT.""" - self.add_message(MessageRole.ASSISTANT, content) - - def add_insight(self, insight: str) -> None: - """ - Add an insight to the conversation context. - - Args: - insight: The insight to add - - Business Rule: Insights must be non-empty - """ - if not insight.strip(): - raise ValueError("Insight cannot be empty") - - new_insights = [*self.context.insights, insight.strip()] - new_context = ConversationContext( - current_phase=self.context.current_phase, - insights=new_insights, - response_count=self.context.response_count, - progress_percentage=self.context.progress_percentage, - metadata=self.context.metadata, - ) - - object.__setattr__(self, "context", new_context) - object.__setattr__(self, "updated_at", datetime.now(UTC)) - - def mark_completed(self) -> None: - """ - Mark conversation as completed. - - Raises: - ValueError: If conversation cannot be completed - - Business Rule: Can only complete conversations in validation/completion phase - """ - if not self.is_active(): - raise ValueError(f"Cannot complete {self.status.value} conversation") - - # Check phase requirements - allowed_phases = [ConversationPhase.VALIDATION, ConversationPhase.COMPLETION] - if self.context.current_phase not in allowed_phases: - raise ValueError( - f"Cannot complete conversation in {self.context.current_phase.value} phase" - ) - - now = datetime.now(UTC) - object.__setattr__(self, "status", ConversationStatus.COMPLETED) - object.__setattr__(self, "completed_at", now) - object.__setattr__(self, "updated_at", now) - - def complete(self) -> None: - """Alias for mark_completed.""" - self.mark_completed() - - def mark_paused(self) -> None: - """ - Mark conversation as paused. - - Business Rule: Can only pause active conversations - """ - if not self.is_active(): - raise ValueError(f"Cannot pause {self.status.value} conversation") - - now = datetime.now(UTC) - object.__setattr__(self, "status", ConversationStatus.PAUSED) - object.__setattr__(self, "updated_at", now) - - def pause(self) -> None: - """Alias for mark_paused.""" - self.mark_paused() - - def resume(self) -> None: - """ - Resume a paused conversation. - - Raises: - ValueError: If conversation is not paused - - Business Rule: Can only resume paused conversations - """ - if self.status != ConversationStatus.PAUSED: - raise ValueError(f"Cannot resume {self.status.value} conversation, must be paused") - - object.__setattr__(self, "status", ConversationStatus.ACTIVE) - object.__setattr__(self, "updated_at", datetime.now(UTC)) - - def is_active(self) -> bool: - """Check if conversation is active.""" - return self.status == ConversationStatus.ACTIVE - - def is_completed(self) -> bool: - """Check if conversation is completed.""" - return self.status == ConversationStatus.COMPLETED - - def is_paused(self) -> bool: - """Check if conversation is paused.""" - return self.status == ConversationStatus.PAUSED - - def get_message_count(self) -> int: - """Get total number of messages.""" - return len(self.messages) - - def get_user_message_count(self) -> int: - """Get number of user messages.""" - return sum(1 for msg in self.messages if msg.is_from_user()) - - def get_assistant_message_count(self) -> int: - """Get number of assistant messages.""" - return sum(1 for msg in self.messages if msg.is_from_assistant()) - - def calculate_progress_percentage(self) -> float: - """Calculate current progress percentage. - - Returns the progress percentage from the conversation context. - """ - return self.context.progress_percentage - - def transition_to_phase(self, new_phase: ConversationPhase) -> None: - """ - Transition conversation to a new phase. - - Args: - new_phase: The new conversation phase to transition to - - Business Rule: Phase transitions update the conversation context - and progress percentage based on the phase progress weights. - Cannot transition backward or if paused. - """ - if not self.is_active(): - raise ValueError(f"Cannot transition {self.status.value} conversation") - - current_weight = PHASE_PROGRESS_WEIGHTS[self.context.current_phase] - new_weight = PHASE_PROGRESS_WEIGHTS[new_phase] - - if new_weight < current_weight: - raise ValueError( - f"Cannot move backward from {self.context.current_phase.value} to {new_phase.value}" - ) - - # Calculate progress percentage based on phase - progress_percentage = new_weight * 100.0 - - new_context = ConversationContext( - current_phase=new_phase, - insights=self.context.insights, - response_count=self.context.response_count, - progress_percentage=progress_percentage, - metadata=self.context.metadata, - ) - - object.__setattr__(self, "context", new_context) - object.__setattr__(self, "updated_at", datetime.now(UTC)) - - def get_conversation_history(self, max_messages: int | None = None) -> list[dict[str, str]]: - """ - Get conversation history in LLM-compatible format. - - Args: - max_messages: Maximum number of recent messages to include - - Returns: - list: Messages in format suitable for LLM - """ - messages = self.messages[-max_messages:] if max_messages else self.messages - return [{"role": msg.role.value, "content": msg.content} for msg in messages] - - -__all__ = ["Conversation"] +"""Conversation aggregate root. + +This module defines the Conversation entity as an aggregate root that enforces +business rules for coaching conversations. +""" + +from datetime import UTC, datetime +from typing import Any + +from pydantic import BaseModel, Field, field_validator + +from coaching.src.core.constants import ( + PHASE_PROGRESS_WEIGHTS, + ConversationPhase, + ConversationStatus, + MessageRole, +) +from coaching.src.core.types import ConversationId, TenantId, UserId +from coaching.src.domain.value_objects.conversation_context import ( + ConversationContext, +) +from coaching.src.domain.value_objects.message import Message + + +class Conversation(BaseModel): + """ + Conversation aggregate root enforcing business rules. + + This is the main aggregate for coaching conversations, responsible for + maintaining conversation invariants, managing messages, and enforcing + phase transition rules. + + Attributes: + conversation_id: Unique identifier for this conversation + user_id: ID of the user having the conversation + tenant_id: ID of the tenant/organization + topic: Coaching topic for this conversation + status: Current status of the conversation + messages: List of messages in the conversation + context: Conversation context and progress tracking + created_at: When conversation was created + updated_at: When conversation was last updated + completed_at: When conversation was completed (if applicable) + + Business Rules: + - Cannot add messages to non-active conversations + - Conversations can be paused/resumed and marked complete based on + simple completion rules (no phase-based workflow) + """ + + conversation_id: ConversationId = Field(..., description="Unique conversation ID") + user_id: UserId = Field(..., description="User ID") + tenant_id: TenantId = Field(..., description="Tenant ID") + topic: str = Field(..., description="Coaching topic") + status: ConversationStatus = Field( + default=ConversationStatus.ACTIVE, description="Conversation status" + ) + messages: list[Message] = Field(default_factory=list, description="Conversation messages") + context: ConversationContext = Field( + default_factory=ConversationContext, + description="Conversation context", + ) + created_at: datetime = Field( + default_factory=lambda: datetime.now(UTC), + description="Creation timestamp", + ) + updated_at: datetime = Field( + default_factory=lambda: datetime.now(UTC), + description="Last update timestamp", + ) + completed_at: datetime | None = Field(default=None, description="Completion timestamp") + metadata: dict[str, Any] = Field(default_factory=dict, description="Additional metadata") + + model_config = {"extra": "forbid"} + + @classmethod + def create( + cls, + user_id: str, + tenant_id: str, + topic: str, + conversation_id: str | None = None, + metadata: dict[str, Any] | None = None, + ) -> "Conversation": + """Factory method to create a new conversation.""" + from uuid import uuid4 + + return cls( + conversation_id=ConversationId(conversation_id or str(uuid4())), + user_id=UserId(user_id), + tenant_id=TenantId(tenant_id), + topic=topic, + metadata=metadata or {}, + ) + + @field_validator("messages") + @classmethod + def validate_messages_chronological(cls, v: list[Message]) -> list[Message]: + """Ensure messages are in chronological order.""" + if len(v) > 1: + for i in range(len(v) - 1): + if v[i].timestamp > v[i + 1].timestamp: + raise ValueError("Messages must be in chronological order") + return v + + def add_message( + self, role: MessageRole, content: str, metadata: dict[str, Any] | None = None + ) -> None: + """ + Add a message to the conversation. + + Args: + role: Role of the message sender + content: Message content + metadata: Optional message metadata + + Raises: + ValueError: If conversation is not active + + Business Rule: Can only add messages to active conversations + """ + if not self.is_active(): + raise ValueError(f"Cannot add message to {self.status.value} conversation") + + message = Message(role=role, content=content, metadata=metadata or {}) + + # Use object.__setattr__ to modify frozen list + object.__setattr__(self, "messages", [*self.messages, message]) + object.__setattr__(self, "updated_at", datetime.now(UTC)) + + # Update context response count for user messages + if role == MessageRole.USER: + new_context = ConversationContext( + current_phase=self.context.current_phase, + insights=self.context.insights, + response_count=self.context.response_count + 1, + progress_percentage=self.context.progress_percentage, + metadata=self.context.metadata, + ) + object.__setattr__(self, "context", new_context) + + def add_user_message(self, content: str) -> None: + """Alias for add_message with role=USER.""" + self.add_message(MessageRole.USER, content) + + def add_assistant_message(self, content: str) -> None: + """Alias for add_message with role=ASSISTANT.""" + self.add_message(MessageRole.ASSISTANT, content) + + def add_insight(self, insight: str) -> None: + """ + Add an insight to the conversation context. + + Args: + insight: The insight to add + + Business Rule: Insights must be non-empty + """ + if not insight.strip(): + raise ValueError("Insight cannot be empty") + + new_insights = [*self.context.insights, insight.strip()] + new_context = ConversationContext( + current_phase=self.context.current_phase, + insights=new_insights, + response_count=self.context.response_count, + progress_percentage=self.context.progress_percentage, + metadata=self.context.metadata, + ) + + object.__setattr__(self, "context", new_context) + object.__setattr__(self, "updated_at", datetime.now(UTC)) + + def mark_completed(self) -> None: + """ + Mark conversation as completed. + + Raises: + ValueError: If conversation cannot be completed + + Business Rule: Can only complete conversations in validation/completion phase + """ + if not self.is_active(): + raise ValueError(f"Cannot complete {self.status.value} conversation") + + # Check phase requirements + allowed_phases = [ConversationPhase.VALIDATION, ConversationPhase.COMPLETION] + if self.context.current_phase not in allowed_phases: + raise ValueError( + f"Cannot complete conversation in {self.context.current_phase.value} phase" + ) + + now = datetime.now(UTC) + object.__setattr__(self, "status", ConversationStatus.COMPLETED) + object.__setattr__(self, "completed_at", now) + object.__setattr__(self, "updated_at", now) + + def complete(self) -> None: + """Alias for mark_completed.""" + self.mark_completed() + + def mark_paused(self) -> None: + """ + Mark conversation as paused. + + Business Rule: Can only pause active conversations + """ + if not self.is_active(): + raise ValueError(f"Cannot pause {self.status.value} conversation") + + now = datetime.now(UTC) + object.__setattr__(self, "status", ConversationStatus.PAUSED) + object.__setattr__(self, "updated_at", now) + + def pause(self) -> None: + """Alias for mark_paused.""" + self.mark_paused() + + def resume(self) -> None: + """ + Resume a paused conversation. + + Raises: + ValueError: If conversation is not paused + + Business Rule: Can only resume paused conversations + """ + if self.status != ConversationStatus.PAUSED: + raise ValueError(f"Cannot resume {self.status.value} conversation, must be paused") + + object.__setattr__(self, "status", ConversationStatus.ACTIVE) + object.__setattr__(self, "updated_at", datetime.now(UTC)) + + def is_active(self) -> bool: + """Check if conversation is active.""" + return self.status == ConversationStatus.ACTIVE + + def is_completed(self) -> bool: + """Check if conversation is completed.""" + return self.status == ConversationStatus.COMPLETED + + def is_paused(self) -> bool: + """Check if conversation is paused.""" + return self.status == ConversationStatus.PAUSED + + def get_message_count(self) -> int: + """Get total number of messages.""" + return len(self.messages) + + def get_user_message_count(self) -> int: + """Get number of user messages.""" + return sum(1 for msg in self.messages if msg.is_from_user()) + + def get_assistant_message_count(self) -> int: + """Get number of assistant messages.""" + return sum(1 for msg in self.messages if msg.is_from_assistant()) + + def calculate_progress_percentage(self) -> float: + """Calculate current progress percentage. + + Returns the progress percentage from the conversation context. + """ + return self.context.progress_percentage + + def transition_to_phase(self, new_phase: ConversationPhase) -> None: + """ + Transition conversation to a new phase. + + Args: + new_phase: The new conversation phase to transition to + + Business Rule: Phase transitions update the conversation context + and progress percentage based on the phase progress weights. + Cannot transition backward or if paused. + """ + if not self.is_active(): + raise ValueError(f"Cannot transition {self.status.value} conversation") + + current_weight = PHASE_PROGRESS_WEIGHTS[self.context.current_phase] + new_weight = PHASE_PROGRESS_WEIGHTS[new_phase] + + if new_weight < current_weight: + raise ValueError( + f"Cannot move backward from {self.context.current_phase.value} to {new_phase.value}" + ) + + # Calculate progress percentage based on phase + progress_percentage = new_weight * 100.0 + + new_context = ConversationContext( + current_phase=new_phase, + insights=self.context.insights, + response_count=self.context.response_count, + progress_percentage=progress_percentage, + metadata=self.context.metadata, + ) + + object.__setattr__(self, "context", new_context) + object.__setattr__(self, "updated_at", datetime.now(UTC)) + + def get_conversation_history(self, max_messages: int | None = None) -> list[dict[str, str]]: + """ + Get conversation history in LLM-compatible format. + + Args: + max_messages: Maximum number of recent messages to include + + Returns: + list: Messages in format suitable for LLM + """ + messages = self.messages[-max_messages:] if max_messages else self.messages + return [{"role": msg.role.value, "content": msg.content} for msg in messages] + + +__all__ = ["Conversation"] diff --git a/coaching/src/domain/entities/prompt_template.py b/coaching/src/domain/entities/prompt_template.py index 46b0be17..698d2c32 100644 --- a/coaching/src/domain/entities/prompt_template.py +++ b/coaching/src/domain/entities/prompt_template.py @@ -11,9 +11,10 @@ from datetime import UTC, datetime from typing import Any +from pydantic import BaseModel, Field, field_validator + from coaching.src.core.constants import CoachingTopic from coaching.src.core.types import TemplateId -from pydantic import BaseModel, Field, field_validator class PromptTemplate(BaseModel): diff --git a/coaching/src/domain/events/analysis_events.py b/coaching/src/domain/events/analysis_events.py index 85d5fae6..af4f12bc 100644 --- a/coaching/src/domain/events/analysis_events.py +++ b/coaching/src/domain/events/analysis_events.py @@ -4,9 +4,10 @@ (alignment scoring, strategy recommendations, etc.). """ +from pydantic import Field + from coaching.src.core.constants import AnalysisType from coaching.src.domain.events.base_event import DomainEvent -from pydantic import Field class AnalysisRequested(DomainEvent): diff --git a/coaching/src/domain/events/conversation_events.py b/coaching/src/domain/events/conversation_events.py index 165fbfad..e78f082d 100644 --- a/coaching/src/domain/events/conversation_events.py +++ b/coaching/src/domain/events/conversation_events.py @@ -1,153 +1,154 @@ -""" -This module contains all events related to conversation lifecycle and interactions. -""" - -from datetime import datetime - -from coaching.src.core.constants import CoachingTopic, ConversationPhase, MessageRole -from coaching.src.domain.events.base_event import DomainEvent -from pydantic import Field - - -class ConversationInitiated(DomainEvent): - """ - Event emitted when a new conversation is created. - - This marks the beginning of a coaching conversation session. - - Attributes: - user_id: ID of the user starting the conversation - tenant_id: ID of the tenant (organization) - topic: The coaching topic for this conversation - initial_phase: Starting phase (typically INTRODUCTION) - """ - - event_type: str = Field(default="ConversationInitiated", frozen=True) - aggregate_type: str = Field(default="Conversation", frozen=True) - - user_id: str = Field(..., description="ID of the user") - tenant_id: str = Field(..., description="ID of the tenant/organization") - topic: CoachingTopic = Field(..., description="Coaching topic") - initial_phase: ConversationPhase = Field(..., description="Starting phase") - - -class MessageAdded(DomainEvent): - """ - Event emitted when a message is added to a conversation. - - Captures both user and assistant messages for conversation flow tracking. - - Attributes: - role: Who sent the message (user, assistant, system) - content_length: Length of message content (not the content itself for privacy) - message_index: Position of this message in conversation - phase: Current conversation phase when message was added - """ - - event_type: str = Field(default="MessageAdded", frozen=True) - aggregate_type: str = Field(default="Conversation", frozen=True) - - role: MessageRole = Field(..., description="Message sender role") - content_length: int = Field(..., ge=0, description="Length of message content") - message_index: int = Field(..., ge=0, description="Position in conversation") - phase: ConversationPhase = Field(..., description="Current conversation phase") - - -class PhaseTransitioned(DomainEvent): - """ - Event emitted when conversation transitions between phases. - - Phase transitions represent significant milestones in the coaching journey. - - Attributes: - from_phase: The phase being transitioned from - to_phase: The phase being transitioned to - reason: Optional reason or trigger for the transition - progress_percentage: Overall conversation progress (0-100) - """ - - event_type: str = Field(default="PhaseTransitioned", frozen=True) - aggregate_type: str = Field(default="Conversation", frozen=True) - - from_phase: ConversationPhase = Field(..., description="Previous phase") - to_phase: ConversationPhase = Field(..., description="New phase") - reason: str | None = Field(default=None, description="Reason for transition") - progress_percentage: float = Field(..., ge=0.0, le=100.0, description="Overall progress") - - -class ConversationCompleted(DomainEvent): - """ - Event emitted when a conversation successfully completes. - - Marks the successful conclusion of a coaching conversation. - - Attributes: - topic: The coaching topic that was addressed - total_messages: Total number of messages in conversation - duration_seconds: Duration from start to completion - insights_count: Number of insights gathered - final_phase: Final phase reached (should be COMPLETION) - """ - - event_type: str = Field(default="ConversationCompleted", frozen=True) - aggregate_type: str = Field(default="Conversation", frozen=True) - - topic: CoachingTopic = Field(..., description="Coaching topic") - total_messages: int = Field(..., ge=0, description="Total message count") - duration_seconds: float = Field(..., ge=0, description="Conversation duration") - insights_count: int = Field(..., ge=0, description="Number of insights gathered") - final_phase: ConversationPhase = Field(..., description="Final phase reached") - - -class ConversationPaused(DomainEvent): - """ - Event emitted when a conversation is paused. - - Allows users to pause and resume conversations later. - - Attributes: - reason: Reason for pausing (user request, timeout, etc.) - current_phase: Phase when conversation was paused - message_count: Number of messages at pause time - can_resume: Whether conversation can be resumed - """ - - event_type: str = Field(default="ConversationPaused", frozen=True) - aggregate_type: str = Field(default="Conversation", frozen=True) - - reason: str = Field(..., description="Reason for pausing") - current_phase: ConversationPhase = Field(..., description="Phase when paused") - message_count: int = Field(..., ge=0, description="Messages at pause time") - can_resume: bool = Field(default=True, description="Whether conversation can be resumed") - - -class ConversationResumed(DomainEvent): - """ - Event emitted when a paused conversation is resumed. - - Marks the continuation of a previously paused conversation. - - Attributes: - paused_at: When the conversation was originally paused - paused_duration_seconds: How long it was paused - resume_phase: Phase being resumed into - message_count: Number of messages at resume time - """ - - event_type: str = Field(default="ConversationResumed", frozen=True) - aggregate_type: str = Field(default="Conversation", frozen=True) - - paused_at: datetime = Field(..., description="When conversation was paused") - paused_duration_seconds: float = Field(..., ge=0, description="Duration of pause") - resume_phase: ConversationPhase = Field(..., description="Phase being resumed") - message_count: int = Field(..., ge=0, description="Messages at resume time") - - -__all__ = [ - "ConversationCompleted", - "ConversationInitiated", - "ConversationPaused", - "ConversationResumed", - "MessageAdded", - "PhaseTransitioned", -] +""" +This module contains all events related to conversation lifecycle and interactions. +""" + +from datetime import datetime + +from pydantic import Field + +from coaching.src.core.constants import CoachingTopic, ConversationPhase, MessageRole +from coaching.src.domain.events.base_event import DomainEvent + + +class ConversationInitiated(DomainEvent): + """ + Event emitted when a new conversation is created. + + This marks the beginning of a coaching conversation session. + + Attributes: + user_id: ID of the user starting the conversation + tenant_id: ID of the tenant (organization) + topic: The coaching topic for this conversation + initial_phase: Starting phase (typically INTRODUCTION) + """ + + event_type: str = Field(default="ConversationInitiated", frozen=True) + aggregate_type: str = Field(default="Conversation", frozen=True) + + user_id: str = Field(..., description="ID of the user") + tenant_id: str = Field(..., description="ID of the tenant/organization") + topic: CoachingTopic = Field(..., description="Coaching topic") + initial_phase: ConversationPhase = Field(..., description="Starting phase") + + +class MessageAdded(DomainEvent): + """ + Event emitted when a message is added to a conversation. + + Captures both user and assistant messages for conversation flow tracking. + + Attributes: + role: Who sent the message (user, assistant, system) + content_length: Length of message content (not the content itself for privacy) + message_index: Position of this message in conversation + phase: Current conversation phase when message was added + """ + + event_type: str = Field(default="MessageAdded", frozen=True) + aggregate_type: str = Field(default="Conversation", frozen=True) + + role: MessageRole = Field(..., description="Message sender role") + content_length: int = Field(..., ge=0, description="Length of message content") + message_index: int = Field(..., ge=0, description="Position in conversation") + phase: ConversationPhase = Field(..., description="Current conversation phase") + + +class PhaseTransitioned(DomainEvent): + """ + Event emitted when conversation transitions between phases. + + Phase transitions represent significant milestones in the coaching journey. + + Attributes: + from_phase: The phase being transitioned from + to_phase: The phase being transitioned to + reason: Optional reason or trigger for the transition + progress_percentage: Overall conversation progress (0-100) + """ + + event_type: str = Field(default="PhaseTransitioned", frozen=True) + aggregate_type: str = Field(default="Conversation", frozen=True) + + from_phase: ConversationPhase = Field(..., description="Previous phase") + to_phase: ConversationPhase = Field(..., description="New phase") + reason: str | None = Field(default=None, description="Reason for transition") + progress_percentage: float = Field(..., ge=0.0, le=100.0, description="Overall progress") + + +class ConversationCompleted(DomainEvent): + """ + Event emitted when a conversation successfully completes. + + Marks the successful conclusion of a coaching conversation. + + Attributes: + topic: The coaching topic that was addressed + total_messages: Total number of messages in conversation + duration_seconds: Duration from start to completion + insights_count: Number of insights gathered + final_phase: Final phase reached (should be COMPLETION) + """ + + event_type: str = Field(default="ConversationCompleted", frozen=True) + aggregate_type: str = Field(default="Conversation", frozen=True) + + topic: CoachingTopic = Field(..., description="Coaching topic") + total_messages: int = Field(..., ge=0, description="Total message count") + duration_seconds: float = Field(..., ge=0, description="Conversation duration") + insights_count: int = Field(..., ge=0, description="Number of insights gathered") + final_phase: ConversationPhase = Field(..., description="Final phase reached") + + +class ConversationPaused(DomainEvent): + """ + Event emitted when a conversation is paused. + + Allows users to pause and resume conversations later. + + Attributes: + reason: Reason for pausing (user request, timeout, etc.) + current_phase: Phase when conversation was paused + message_count: Number of messages at pause time + can_resume: Whether conversation can be resumed + """ + + event_type: str = Field(default="ConversationPaused", frozen=True) + aggregate_type: str = Field(default="Conversation", frozen=True) + + reason: str = Field(..., description="Reason for pausing") + current_phase: ConversationPhase = Field(..., description="Phase when paused") + message_count: int = Field(..., ge=0, description="Messages at pause time") + can_resume: bool = Field(default=True, description="Whether conversation can be resumed") + + +class ConversationResumed(DomainEvent): + """ + Event emitted when a paused conversation is resumed. + + Marks the continuation of a previously paused conversation. + + Attributes: + paused_at: When the conversation was originally paused + paused_duration_seconds: How long it was paused + resume_phase: Phase being resumed into + message_count: Number of messages at resume time + """ + + event_type: str = Field(default="ConversationResumed", frozen=True) + aggregate_type: str = Field(default="Conversation", frozen=True) + + paused_at: datetime = Field(..., description="When conversation was paused") + paused_duration_seconds: float = Field(..., ge=0, description="Duration of pause") + resume_phase: ConversationPhase = Field(..., description="Phase being resumed") + message_count: int = Field(..., ge=0, description="Messages at resume time") + + +__all__ = [ + "ConversationCompleted", + "ConversationInitiated", + "ConversationPaused", + "ConversationResumed", + "MessageAdded", + "PhaseTransitioned", +] diff --git a/coaching/src/domain/value_objects/conversation_context.py b/coaching/src/domain/value_objects/conversation_context.py index eaf44f45..d92b55e3 100644 --- a/coaching/src/domain/value_objects/conversation_context.py +++ b/coaching/src/domain/value_objects/conversation_context.py @@ -1,124 +1,125 @@ -"""ConversationContext value object for coaching conversations. - -This module defines the immutable ConversationContext that tracks the state -and progress of a coaching conversation. -""" - -from typing import Any - -from coaching.src.core.constants import ConversationPhase -from pydantic import BaseModel, Field, field_validator - - -class ConversationContext(BaseModel): - """ - Immutable value object representing conversation context and progress. - - Tracks conversation phase, collected insights, response count, and - overall progress percentage for a coaching conversation. - - Attributes: - current_phase: Current conversation phase - insights: List of key insights gathered during the conversation - response_count: Number of user responses received - progress_percentage: Overall progress (0-100) - metadata: Optional additional context information - - Example: - >>> context = ConversationContext( - ... current_phase=ConversationPhase.EXPLORATION, - ... insights=["Values autonomy", "Seeks growth"], - ... response_count=5, - ... progress_percentage=30.0 - ... ) - """ - - current_phase: ConversationPhase = Field( - default=ConversationPhase.INTRODUCTION, description="Current conversation phase" - ) - insights: list[str] = Field(default_factory=list, description="Key insights collected") - response_count: int = Field(default=0, ge=0, description="Number of user responses") - progress_percentage: float = Field( - default=0.0, ge=0.0, le=100.0, description="Overall progress (0-100)" - ) - metadata: dict[str, Any] = Field(default_factory=dict, description="Optional context metadata") - - model_config = {"frozen": True, "extra": "forbid"} - - @field_validator("insights") - @classmethod - def validate_insights_not_empty_strings(cls, v: list[str]) -> list[str]: - """Ensure insights are not empty strings.""" - if any(not insight.strip() for insight in v): - raise ValueError("Insights cannot contain empty strings") - return [insight.strip() for insight in v] - - def has_insights(self) -> bool: - """ - Check if any insights have been collected. - - Returns: - bool: True if insights list is not empty - """ - return len(self.insights) > 0 - - def get_insight_count(self) -> int: - """ - Get the number of insights collected. - - Returns: - int: Number of insights - """ - return len(self.insights) - - def has_sufficient_responses(self, minimum: int) -> bool: - """ - Check if conversation has minimum number of responses. - - Args: - minimum: Minimum required responses - - Returns: - bool: True if response_count >= minimum - """ - return self.response_count >= minimum - - def is_complete(self) -> bool: - """ - Check if conversation progress is complete (100%). - - Returns: - bool: True if progress is 100% - """ - return self.progress_percentage >= 100.0 - - def is_in_phase(self, phase: ConversationPhase) -> bool: - """Check if currently in specific phase.""" - return self.current_phase == phase - - def is_introduction_phase(self) -> bool: - """Check if in introduction phase.""" - return self.current_phase == ConversationPhase.INTRODUCTION - - def is_exploration_phase(self) -> bool: - """Check if in exploration phase.""" - return self.current_phase == ConversationPhase.EXPLORATION - - def is_deepening_phase(self) -> bool: - """Check if in deepening phase.""" - return self.current_phase == ConversationPhase.DEEPENING - - def is_synthesis_phase(self) -> bool: - """Check if in synthesis phase.""" - return self.current_phase == ConversationPhase.SYNTHESIS - - def is_validation_phase(self) -> bool: - """Check if in validation phase.""" - return self.current_phase == ConversationPhase.VALIDATION - - def is_completion_phase(self) -> bool: - """Check if in completion phase.""" - return self.current_phase == ConversationPhase.COMPLETION - - -__all__ = ["ConversationContext"] +"""ConversationContext value object for coaching conversations. + +This module defines the immutable ConversationContext that tracks the state +and progress of a coaching conversation. +""" + +from typing import Any + +from pydantic import BaseModel, Field, field_validator + +from coaching.src.core.constants import ConversationPhase + + +class ConversationContext(BaseModel): + """ + Immutable value object representing conversation context and progress. + + Tracks conversation phase, collected insights, response count, and + overall progress percentage for a coaching conversation. + + Attributes: + current_phase: Current conversation phase + insights: List of key insights gathered during the conversation + response_count: Number of user responses received + progress_percentage: Overall progress (0-100) + metadata: Optional additional context information + + Example: + >>> context = ConversationContext( + ... current_phase=ConversationPhase.EXPLORATION, + ... insights=["Values autonomy", "Seeks growth"], + ... response_count=5, + ... progress_percentage=30.0 + ... ) + """ + + current_phase: ConversationPhase = Field( + default=ConversationPhase.INTRODUCTION, description="Current conversation phase" + ) + insights: list[str] = Field(default_factory=list, description="Key insights collected") + response_count: int = Field(default=0, ge=0, description="Number of user responses") + progress_percentage: float = Field( + default=0.0, ge=0.0, le=100.0, description="Overall progress (0-100)" + ) + metadata: dict[str, Any] = Field(default_factory=dict, description="Optional context metadata") + + model_config = {"frozen": True, "extra": "forbid"} + + @field_validator("insights") + @classmethod + def validate_insights_not_empty_strings(cls, v: list[str]) -> list[str]: + """Ensure insights are not empty strings.""" + if any(not insight.strip() for insight in v): + raise ValueError("Insights cannot contain empty strings") + return [insight.strip() for insight in v] + + def has_insights(self) -> bool: + """ + Check if any insights have been collected. + + Returns: + bool: True if insights list is not empty + """ + return len(self.insights) > 0 + + def get_insight_count(self) -> int: + """ + Get the number of insights collected. + + Returns: + int: Number of insights + """ + return len(self.insights) + + def has_sufficient_responses(self, minimum: int) -> bool: + """ + Check if conversation has minimum number of responses. + + Args: + minimum: Minimum required responses + + Returns: + bool: True if response_count >= minimum + """ + return self.response_count >= minimum + + def is_complete(self) -> bool: + """ + Check if conversation progress is complete (100%). + + Returns: + bool: True if progress is 100% + """ + return self.progress_percentage >= 100.0 + + def is_in_phase(self, phase: ConversationPhase) -> bool: + """Check if currently in specific phase.""" + return self.current_phase == phase + + def is_introduction_phase(self) -> bool: + """Check if in introduction phase.""" + return self.current_phase == ConversationPhase.INTRODUCTION + + def is_exploration_phase(self) -> bool: + """Check if in exploration phase.""" + return self.current_phase == ConversationPhase.EXPLORATION + + def is_deepening_phase(self) -> bool: + """Check if in deepening phase.""" + return self.current_phase == ConversationPhase.DEEPENING + + def is_synthesis_phase(self) -> bool: + """Check if in synthesis phase.""" + return self.current_phase == ConversationPhase.SYNTHESIS + + def is_validation_phase(self) -> bool: + """Check if in validation phase.""" + return self.current_phase == ConversationPhase.VALIDATION + + def is_completion_phase(self) -> bool: + """Check if in completion phase.""" + return self.current_phase == ConversationPhase.COMPLETION + + +__all__ = ["ConversationContext"] diff --git a/coaching/src/domain/value_objects/message.py b/coaching/src/domain/value_objects/message.py index d23ebef5..6c33f7ce 100644 --- a/coaching/src/domain/value_objects/message.py +++ b/coaching/src/domain/value_objects/message.py @@ -1,134 +1,135 @@ -"""Message value object for coaching conversations. - -This module defines the immutable Message value object that represents -a single message within a coaching conversation. -""" - -from datetime import UTC, datetime -from typing import Any - -from coaching.src.core.constants import MessageRole -from coaching.src.core.types import MessageId, create_message_id -from pydantic import BaseModel, Field, field_validator - - -class Message(BaseModel): - """ - Immutable value object representing a message in a conversation. - - A message captures a single exchange with role, content, timestamp, - and optional metadata. Messages are immutable once created. - - Attributes: - message_id: Unique identifier for this message - role: The role of the message sender (user, assistant, system) - content: The actual message content - timestamp: When the message was created (UTC) - metadata: Optional additional information about the message - tokens: Token usage data (input, output, total) for LLM responses - cost: Calculated cost in USD for this message - model_id: LLM model identifier used to generate this message - - Example: - >>> message = Message( - ... role=MessageRole.USER, - ... content="What are my core values?", - ... metadata={"source": "web"} - ... ) - >>> print(message.role) - MessageRole.USER - """ - - message_id: MessageId = Field( - default_factory=create_message_id, - description="Unique identifier for this message", - ) - role: MessageRole = Field(..., description="Role of the message sender") - content: str = Field(..., min_length=1, max_length=10000, description="Message content") - timestamp: datetime = Field( - default_factory=lambda: datetime.now(UTC), - description="Message creation timestamp (UTC)", - ) - metadata: dict[str, Any] = Field(default_factory=dict, description="Optional message metadata") - - # Token tracking fields for LLM usage analytics - tokens: dict[str, int] | None = Field( - default=None, - description="Token usage: {'input': int, 'output': int, 'total': int}", - ) - cost: float | None = Field( - default=None, - description="Calculated cost in USD for this message", - ge=0.0, - ) - model_id: str | None = Field( - default=None, - description="LLM model identifier (e.g., 'anthropic.claude-3-5-sonnet-20241022-v2:0')", - ) - - model_config = {"frozen": True, "extra": "forbid"} - - @field_validator("content") - @classmethod - def validate_content_not_empty(cls, v: str) -> str: - """Ensure content is not just whitespace.""" - if not v.strip(): - raise ValueError("Message content cannot be empty or whitespace only") - return v.strip() - - @field_validator("timestamp") - @classmethod - def validate_timestamp_not_future(cls, v: datetime) -> datetime: - """Ensure timestamp is not in the future.""" - now = datetime.now(UTC) - if v > now: - raise ValueError("Message timestamp cannot be in the future") - return v - - def is_from_user(self) -> bool: - """ - Check if this message is from the user. - - Returns: - bool: True if the message role is USER - """ - return self.role == MessageRole.USER - - def is_from_assistant(self) -> bool: - """ - Check if this message is from the assistant. - - Returns: - bool: True if the message role is ASSISTANT - """ - return self.role == MessageRole.ASSISTANT - - def is_system_message(self) -> bool: - """ - Check if this message is a system message. - - Returns: - bool: True if the message role is SYSTEM - """ - return self.role == MessageRole.SYSTEM - - def get_content_length(self) -> int: - """ - Get the character count of the message content. - - Returns: - int: Number of characters in the content - """ - return len(self.content) - - def has_metadata(self) -> bool: - """ - Check if this message has any metadata. - - Returns: - bool: True if metadata dict is not empty - """ - return len(self.metadata) > 0 - - -__all__ = ["Message"] +"""Message value object for coaching conversations. + +This module defines the immutable Message value object that represents +a single message within a coaching conversation. +""" + +from datetime import UTC, datetime +from typing import Any + +from pydantic import BaseModel, Field, field_validator + +from coaching.src.core.constants import MessageRole +from coaching.src.core.types import MessageId, create_message_id + + +class Message(BaseModel): + """ + Immutable value object representing a message in a conversation. + + A message captures a single exchange with role, content, timestamp, + and optional metadata. Messages are immutable once created. + + Attributes: + message_id: Unique identifier for this message + role: The role of the message sender (user, assistant, system) + content: The actual message content + timestamp: When the message was created (UTC) + metadata: Optional additional information about the message + tokens: Token usage data (input, output, total) for LLM responses + cost: Calculated cost in USD for this message + model_id: LLM model identifier used to generate this message + + Example: + >>> message = Message( + ... role=MessageRole.USER, + ... content="What are my core values?", + ... metadata={"source": "web"} + ... ) + >>> print(message.role) + MessageRole.USER + """ + + message_id: MessageId = Field( + default_factory=create_message_id, + description="Unique identifier for this message", + ) + role: MessageRole = Field(..., description="Role of the message sender") + content: str = Field(..., min_length=1, max_length=10000, description="Message content") + timestamp: datetime = Field( + default_factory=lambda: datetime.now(UTC), + description="Message creation timestamp (UTC)", + ) + metadata: dict[str, Any] = Field(default_factory=dict, description="Optional message metadata") + + # Token tracking fields for LLM usage analytics + tokens: dict[str, int] | None = Field( + default=None, + description="Token usage: {'input': int, 'output': int, 'total': int}", + ) + cost: float | None = Field( + default=None, + description="Calculated cost in USD for this message", + ge=0.0, + ) + model_id: str | None = Field( + default=None, + description="LLM model identifier (e.g., 'anthropic.claude-3-5-sonnet-20241022-v2:0')", + ) + + model_config = {"frozen": True, "extra": "forbid"} + + @field_validator("content") + @classmethod + def validate_content_not_empty(cls, v: str) -> str: + """Ensure content is not just whitespace.""" + if not v.strip(): + raise ValueError("Message content cannot be empty or whitespace only") + return v.strip() + + @field_validator("timestamp") + @classmethod + def validate_timestamp_not_future(cls, v: datetime) -> datetime: + """Ensure timestamp is not in the future.""" + now = datetime.now(UTC) + if v > now: + raise ValueError("Message timestamp cannot be in the future") + return v + + def is_from_user(self) -> bool: + """ + Check if this message is from the user. + + Returns: + bool: True if the message role is USER + """ + return self.role == MessageRole.USER + + def is_from_assistant(self) -> bool: + """ + Check if this message is from the assistant. + + Returns: + bool: True if the message role is ASSISTANT + """ + return self.role == MessageRole.ASSISTANT + + def is_system_message(self) -> bool: + """ + Check if this message is a system message. + + Returns: + bool: True if the message role is SYSTEM + """ + return self.role == MessageRole.SYSTEM + + def get_content_length(self) -> int: + """ + Get the character count of the message content. + + Returns: + int: Number of characters in the content + """ + return len(self.content) + + def has_metadata(self) -> bool: + """ + Check if this message has any metadata. + + Returns: + bool: True if metadata dict is not empty + """ + return len(self.metadata) > 0 + + +__all__ = ["Message"] diff --git a/coaching/src/infrastructure/llm/bedrock_provider.py b/coaching/src/infrastructure/llm/bedrock_provider.py index 4deeda00..69cd4ec7 100644 --- a/coaching/src/infrastructure/llm/bedrock_provider.py +++ b/coaching/src/infrastructure/llm/bedrock_provider.py @@ -34,6 +34,7 @@ from typing import Any, ClassVar import structlog + from coaching.src.domain.ports.llm_provider_port import LLMMessage, LLMResponse logger = structlog.get_logger() diff --git a/coaching/src/infrastructure/llm/google_vertex_provider.py b/coaching/src/infrastructure/llm/google_vertex_provider.py index 65ada2c5..8b460e55 100644 --- a/coaching/src/infrastructure/llm/google_vertex_provider.py +++ b/coaching/src/infrastructure/llm/google_vertex_provider.py @@ -27,6 +27,7 @@ from typing import Any, ClassVar import structlog + from coaching.src.domain.ports.llm_provider_port import LLMMessage, LLMResponse logger = structlog.get_logger() @@ -148,11 +149,12 @@ async def _get_client(self) -> Any: project_id = self.project_id credentials = self.credentials + from google.oauth2 import service_account + from coaching.src.core.config_multitenant import ( get_google_vertex_credentials, get_settings, ) - from google.oauth2 import service_account settings = get_settings() diff --git a/coaching/src/infrastructure/llm/openai_provider.py b/coaching/src/infrastructure/llm/openai_provider.py index 3079df50..2f241405 100644 --- a/coaching/src/infrastructure/llm/openai_provider.py +++ b/coaching/src/infrastructure/llm/openai_provider.py @@ -1,426 +1,427 @@ -"""OpenAI LLM provider implementation. - -This module provides an OpenAI-backed implementation of the LLM provider -port interface, supporting GPT-4o, GPT-5 series (including GPT-5 Pro), and other OpenAI models. - -Uses the Responses API (/v1/responses) which supports all models including -GPT-5 Pro which is exclusive to this API. - -Prompt Caching: - OpenAI automatically caches identical prompt prefixes server-side. - No explicit configuration required - caching happens automatically for: - - Repeated identical system prompts - - Common conversation prefixes - - Benefits: - - Up to 50% reduction in latency for cache hits - - Up to 50% reduction in input token costs - - Cache TTL: Up to 1 hour (managed by OpenAI) - - Optimization tips: - - Put static content (system prompts) at the beginning - - Keep variable content (user messages) at the end - - Use consistent system prompts across requests - - See: https://platform.openai.com/docs/guides/prompt-caching -""" - -from collections.abc import AsyncIterator -from typing import Any, ClassVar - -import structlog -from coaching.src.domain.ports.llm_provider_port import LLMMessage, LLMResponse - -logger = structlog.get_logger() - - -class OpenAILLMProvider: - """ - OpenAI adapter implementing LLMProviderPort. - - This adapter provides OpenAI-backed LLM access using the Responses API, - implementing the provider port interface defined in the domain layer. - - Design: - - Uses Responses API (/v1/responses) for all models - - Supports all OpenAI models including GPT-5 Pro (exclusive to Responses API) - - Handles both streaming and non-streaming - - Includes retry logic and error handling - - Provides usage metrics - """ - - # Supported OpenAI model IDs - SUPPORTED_MODELS: ClassVar[list[str]] = [ - # GPT-4o Series - "gpt-4o", - "gpt-4o-mini", - "gpt-4-turbo", - # GPT-5 Series - "gpt-5-pro", - "gpt-5", - "gpt-5-mini", - "gpt-5-nano", - "gpt-5-chat", - # GPT 5.2 Series (Latest - December 2025) - "gpt-5.2", - "gpt-5.2-pro", - ] - - def __init__(self, api_key: str | None = None, organization: str | None = None): - """ - Initialize OpenAI LLM provider. - - Args: - api_key: OpenAI API key (optional - will retrieve from Secrets Manager if not provided) - organization: Optional organization ID - """ - self.api_key = api_key - self.organization = organization - self._client: Any | None = None - logger.info("OpenAI LLM provider initialized") - - @property - def provider_name(self) -> str: - """Get the provider name.""" - return "openai" - - @property - def supported_models(self) -> list[str]: - """Get list of supported models.""" - return self.SUPPORTED_MODELS.copy() - - async def _get_client(self) -> Any: - """Get or create OpenAI async client (lazy initialization). - - Uses aiohttp for faster async HTTP performance when available. - """ - if self._client is None: - try: - from openai import AsyncOpenAI - except ImportError as e: - raise ImportError( - "OpenAI Python SDK not installed. Install with: pip install openai>=1.0.0" - ) from e - - # Get API key from Secrets Manager if not provided - api_key = self.api_key - if not api_key: - from coaching.src.core.config_multitenant import get_openai_api_key - - api_key = get_openai_api_key() - if not api_key: - raise ValueError( - "OpenAI API key not configured. " - "Set OPENAI_API_KEY environment variable or configure AWS secret" - ) - - # Try to use aiohttp for faster async HTTP (if installed) - http_client = None - try: - from openai import DefaultAioHttpClient - - http_client = DefaultAioHttpClient() - logger.debug("Using aiohttp for OpenAI client (faster async)") - except ImportError: - logger.debug("aiohttp not available, using default httpx client") - - self._client = AsyncOpenAI( - api_key=api_key, - organization=self.organization, - http_client=http_client, - ) - logger.info("OpenAI client initialized") - return self._client - - async def generate( - self, - messages: list[LLMMessage], - model: str, - temperature: float = 0.7, - max_tokens: int | None = None, - system_prompt: str | None = None, - response_schema: dict[str, object] | None = None, - ) -> LLMResponse: - """ - Generate a completion from OpenAI using the Responses API. - - Args: - messages: Conversation history - model: Model identifier - temperature: Sampling temperature (0.0-2.0 for OpenAI) - max_tokens: Maximum tokens to generate - system_prompt: Optional system prompt (passed as instructions) - response_schema: Optional JSON schema for structured output enforcement - - Returns: - LLMResponse with generated content and metadata - - Business Rule: Temperature must be between 0.0 and 2.0 for OpenAI - """ - if not 0.0 <= temperature <= 2.0: - raise ValueError( - f"Temperature must be between 0.0 and 2.0 for OpenAI, got {temperature}" - ) - - if model not in self.SUPPORTED_MODELS: - raise ValueError(f"Model {model} not supported. Supported: {self.SUPPORTED_MODELS}") - - try: - client = await self._get_client() - - # Build input for Responses API - # Convert messages to the input format expected by Responses API - input_items: list[dict[str, Any]] = [] - for msg in messages: - input_items.append( - { - "role": msg.role, - "content": msg.content, - } - ) - - # Call OpenAI Responses API - logger.info( - "Calling OpenAI Responses API", - model=model, - num_messages=len(input_items), - temperature=temperature, - has_schema=response_schema is not None, - ) - - # Build API parameters - params: dict[str, Any] = { - "model": model, - "input": input_items, - "store": False, # Don't store responses by default - } - - # Add system prompt as instructions if provided - if system_prompt: - params["instructions"] = system_prompt - - # Add max_output_tokens if specified - if max_tokens: - params["max_output_tokens"] = max_tokens - - # Add structured output format if schema is provided - # This ensures the model returns valid JSON matching the schema - if response_schema: - params["text"] = { - "format": { - "type": "json_schema", - "name": response_schema.get("title", "Response"), - "schema": response_schema, - "strict": True, # Enforce strict schema validation - } - } - logger.debug( - "Using structured JSON output", - schema_name=response_schema.get("title", "Response"), - ) - - # GPT-5 reasoning models don't support temperature parameter - # Only set temperature for models that support it - models_without_temperature = { - "gpt-5-pro", - "gpt-5", - "gpt-5-mini", - "gpt-5-nano", - "gpt-5.2", - "gpt-5.2-pro", - } - if model not in models_without_temperature: - params["temperature"] = temperature - - response = await client.responses.create(**params) - - # Extract response content from Responses API format - content = "" - if response.output: - for output_item in response.output: - if output_item.type == "message": - for content_part in output_item.content: - if content_part.type == "output_text": - content += content_part.text - - # Determine finish reason from response status - finish_reason = "stop" if response.status == "completed" else response.status - - # Check for errors - if response.error: - logger.warning( - "OpenAI response contains error", - error_code=response.error.code, - error_message=response.error.message, - model=model, - ) - content = f"[Error: {response.error.message}]" - - # Debug log for empty responses - if not content and response.usage and response.usage.output_tokens > 0: - logger.warning( - "OpenAI returned empty content despite output tokens", - model=model, - output_tokens=response.usage.output_tokens, - status=response.status, - ) - - # Extract usage metrics - usage = { - "prompt_tokens": response.usage.input_tokens if response.usage else 0, - "completion_tokens": response.usage.output_tokens if response.usage else 0, - "total_tokens": response.usage.total_tokens if response.usage else 0, - } - - logger.info( - "OpenAI Responses API call successful", - model=model, - usage=usage, - finish_reason=finish_reason, - ) - - return LLMResponse( - content=content, - model=response.model, - usage=usage, - finish_reason=finish_reason, - provider=self.provider_name, - ) - - except Exception as e: - logger.error("OpenAI Responses API call failed", error=str(e), model=model) - raise RuntimeError(f"OpenAI API call failed: {e}") from e - - async def generate_stream( - self, - messages: list[LLMMessage], - model: str, - temperature: float = 0.7, - max_tokens: int | None = None, - system_prompt: str | None = None, - ) -> AsyncIterator[str]: - """ - Generate a completion with token streaming using Responses API. - - Args: - messages: Conversation history - model: Model identifier - temperature: Sampling temperature (0.0-2.0 for OpenAI) - max_tokens: Maximum tokens to generate - system_prompt: Optional system prompt - - Yields: - Token strings as they are generated - - Business Rule: Must yield tokens incrementally for real-time UX - """ - if not 0.0 <= temperature <= 2.0: - raise ValueError( - f"Temperature must be between 0.0 and 2.0 for OpenAI, got {temperature}" - ) - - if model not in self.SUPPORTED_MODELS: - raise ValueError(f"Model {model} not supported. Supported: {self.SUPPORTED_MODELS}") - - try: - client = await self._get_client() - - # Build input for Responses API - input_items: list[dict[str, Any]] = [] - for msg in messages: - input_items.append( - { - "role": msg.role, - "content": msg.content, - } - ) - - # Call OpenAI Responses API with streaming - logger.info( - "Calling OpenAI Responses API (streaming)", - model=model, - num_messages=len(input_items), - temperature=temperature, - ) - - # Build API parameters - params: dict[str, Any] = { - "model": model, - "input": input_items, - "store": False, - "stream": True, - } - - # Add system prompt as instructions if provided - if system_prompt: - params["instructions"] = system_prompt - - # Add max_output_tokens if specified - if max_tokens: - params["max_output_tokens"] = max_tokens - - # GPT-5 reasoning models don't support temperature parameter - models_without_temperature = { - "gpt-5-pro", - "gpt-5", - "gpt-5-mini", - "gpt-5-nano", - "gpt-5.2", - "gpt-5.2-pro", - } - if model not in models_without_temperature: - params["temperature"] = temperature - - # Use the streaming interface - async with client.responses.stream(**params) as stream: - async for event in stream: - # Handle different event types from Responses API streaming - if hasattr(event, "type"): - if ( - event.type == "response.output_text.delta" - and hasattr(event, "delta") - and event.delta - ): - yield event.delta - elif ( - event.type == "response.content_part.delta" - and hasattr(event, "delta") - and hasattr(event.delta, "text") - ): - yield event.delta.text - - except Exception as e: - logger.error("OpenAI Responses API streaming failed", error=str(e), model=model) - raise RuntimeError(f"OpenAI streaming API call failed: {e}") from e - - async def count_tokens(self, text: str, _model: str) -> int: - """ - Count tokens in text for a specific model. - - Args: - text: Text to tokenize - _model: Model identifier (unused in approximation mode) - - Returns: - Number of tokens - - Note: This is an approximation. For exact counts, use tiktoken library. - """ - # Simple approximation: ~4 characters per token - # For production, use tiktoken library for exact counts - return len(text) // 4 - - async def validate_model(self, model: str) -> bool: - """ - Validate if a model is supported and available. - - Args: - model: Model identifier to validate - - Returns: - True if model is supported and available - """ - return model in self.SUPPORTED_MODELS - - -__all__ = ["OpenAILLMProvider"] +"""OpenAI LLM provider implementation. + +This module provides an OpenAI-backed implementation of the LLM provider +port interface, supporting GPT-4o, GPT-5 series (including GPT-5 Pro), and other OpenAI models. + +Uses the Responses API (/v1/responses) which supports all models including +GPT-5 Pro which is exclusive to this API. + +Prompt Caching: + OpenAI automatically caches identical prompt prefixes server-side. + No explicit configuration required - caching happens automatically for: + - Repeated identical system prompts + - Common conversation prefixes + + Benefits: + - Up to 50% reduction in latency for cache hits + - Up to 50% reduction in input token costs + - Cache TTL: Up to 1 hour (managed by OpenAI) + + Optimization tips: + - Put static content (system prompts) at the beginning + - Keep variable content (user messages) at the end + - Use consistent system prompts across requests + + See: https://platform.openai.com/docs/guides/prompt-caching +""" + +from collections.abc import AsyncIterator +from typing import Any, ClassVar + +import structlog + +from coaching.src.domain.ports.llm_provider_port import LLMMessage, LLMResponse + +logger = structlog.get_logger() + + +class OpenAILLMProvider: + """ + OpenAI adapter implementing LLMProviderPort. + + This adapter provides OpenAI-backed LLM access using the Responses API, + implementing the provider port interface defined in the domain layer. + + Design: + - Uses Responses API (/v1/responses) for all models + - Supports all OpenAI models including GPT-5 Pro (exclusive to Responses API) + - Handles both streaming and non-streaming + - Includes retry logic and error handling + - Provides usage metrics + """ + + # Supported OpenAI model IDs + SUPPORTED_MODELS: ClassVar[list[str]] = [ + # GPT-4o Series + "gpt-4o", + "gpt-4o-mini", + "gpt-4-turbo", + # GPT-5 Series + "gpt-5-pro", + "gpt-5", + "gpt-5-mini", + "gpt-5-nano", + "gpt-5-chat", + # GPT 5.2 Series (Latest - December 2025) + "gpt-5.2", + "gpt-5.2-pro", + ] + + def __init__(self, api_key: str | None = None, organization: str | None = None): + """ + Initialize OpenAI LLM provider. + + Args: + api_key: OpenAI API key (optional - will retrieve from Secrets Manager if not provided) + organization: Optional organization ID + """ + self.api_key = api_key + self.organization = organization + self._client: Any | None = None + logger.info("OpenAI LLM provider initialized") + + @property + def provider_name(self) -> str: + """Get the provider name.""" + return "openai" + + @property + def supported_models(self) -> list[str]: + """Get list of supported models.""" + return self.SUPPORTED_MODELS.copy() + + async def _get_client(self) -> Any: + """Get or create OpenAI async client (lazy initialization). + + Uses aiohttp for faster async HTTP performance when available. + """ + if self._client is None: + try: + from openai import AsyncOpenAI + except ImportError as e: + raise ImportError( + "OpenAI Python SDK not installed. Install with: pip install openai>=1.0.0" + ) from e + + # Get API key from Secrets Manager if not provided + api_key = self.api_key + if not api_key: + from coaching.src.core.config_multitenant import get_openai_api_key + + api_key = get_openai_api_key() + if not api_key: + raise ValueError( + "OpenAI API key not configured. " + "Set OPENAI_API_KEY environment variable or configure AWS secret" + ) + + # Try to use aiohttp for faster async HTTP (if installed) + http_client = None + try: + from openai import DefaultAioHttpClient + + http_client = DefaultAioHttpClient() + logger.debug("Using aiohttp for OpenAI client (faster async)") + except ImportError: + logger.debug("aiohttp not available, using default httpx client") + + self._client = AsyncOpenAI( + api_key=api_key, + organization=self.organization, + http_client=http_client, + ) + logger.info("OpenAI client initialized") + return self._client + + async def generate( + self, + messages: list[LLMMessage], + model: str, + temperature: float = 0.7, + max_tokens: int | None = None, + system_prompt: str | None = None, + response_schema: dict[str, object] | None = None, + ) -> LLMResponse: + """ + Generate a completion from OpenAI using the Responses API. + + Args: + messages: Conversation history + model: Model identifier + temperature: Sampling temperature (0.0-2.0 for OpenAI) + max_tokens: Maximum tokens to generate + system_prompt: Optional system prompt (passed as instructions) + response_schema: Optional JSON schema for structured output enforcement + + Returns: + LLMResponse with generated content and metadata + + Business Rule: Temperature must be between 0.0 and 2.0 for OpenAI + """ + if not 0.0 <= temperature <= 2.0: + raise ValueError( + f"Temperature must be between 0.0 and 2.0 for OpenAI, got {temperature}" + ) + + if model not in self.SUPPORTED_MODELS: + raise ValueError(f"Model {model} not supported. Supported: {self.SUPPORTED_MODELS}") + + try: + client = await self._get_client() + + # Build input for Responses API + # Convert messages to the input format expected by Responses API + input_items: list[dict[str, Any]] = [] + for msg in messages: + input_items.append( + { + "role": msg.role, + "content": msg.content, + } + ) + + # Call OpenAI Responses API + logger.info( + "Calling OpenAI Responses API", + model=model, + num_messages=len(input_items), + temperature=temperature, + has_schema=response_schema is not None, + ) + + # Build API parameters + params: dict[str, Any] = { + "model": model, + "input": input_items, + "store": False, # Don't store responses by default + } + + # Add system prompt as instructions if provided + if system_prompt: + params["instructions"] = system_prompt + + # Add max_output_tokens if specified + if max_tokens: + params["max_output_tokens"] = max_tokens + + # Add structured output format if schema is provided + # This ensures the model returns valid JSON matching the schema + if response_schema: + params["text"] = { + "format": { + "type": "json_schema", + "name": response_schema.get("title", "Response"), + "schema": response_schema, + "strict": True, # Enforce strict schema validation + } + } + logger.debug( + "Using structured JSON output", + schema_name=response_schema.get("title", "Response"), + ) + + # GPT-5 reasoning models don't support temperature parameter + # Only set temperature for models that support it + models_without_temperature = { + "gpt-5-pro", + "gpt-5", + "gpt-5-mini", + "gpt-5-nano", + "gpt-5.2", + "gpt-5.2-pro", + } + if model not in models_without_temperature: + params["temperature"] = temperature + + response = await client.responses.create(**params) + + # Extract response content from Responses API format + content = "" + if response.output: + for output_item in response.output: + if output_item.type == "message": + for content_part in output_item.content: + if content_part.type == "output_text": + content += content_part.text + + # Determine finish reason from response status + finish_reason = "stop" if response.status == "completed" else response.status + + # Check for errors + if response.error: + logger.warning( + "OpenAI response contains error", + error_code=response.error.code, + error_message=response.error.message, + model=model, + ) + content = f"[Error: {response.error.message}]" + + # Debug log for empty responses + if not content and response.usage and response.usage.output_tokens > 0: + logger.warning( + "OpenAI returned empty content despite output tokens", + model=model, + output_tokens=response.usage.output_tokens, + status=response.status, + ) + + # Extract usage metrics + usage = { + "prompt_tokens": response.usage.input_tokens if response.usage else 0, + "completion_tokens": response.usage.output_tokens if response.usage else 0, + "total_tokens": response.usage.total_tokens if response.usage else 0, + } + + logger.info( + "OpenAI Responses API call successful", + model=model, + usage=usage, + finish_reason=finish_reason, + ) + + return LLMResponse( + content=content, + model=response.model, + usage=usage, + finish_reason=finish_reason, + provider=self.provider_name, + ) + + except Exception as e: + logger.error("OpenAI Responses API call failed", error=str(e), model=model) + raise RuntimeError(f"OpenAI API call failed: {e}") from e + + async def generate_stream( + self, + messages: list[LLMMessage], + model: str, + temperature: float = 0.7, + max_tokens: int | None = None, + system_prompt: str | None = None, + ) -> AsyncIterator[str]: + """ + Generate a completion with token streaming using Responses API. + + Args: + messages: Conversation history + model: Model identifier + temperature: Sampling temperature (0.0-2.0 for OpenAI) + max_tokens: Maximum tokens to generate + system_prompt: Optional system prompt + + Yields: + Token strings as they are generated + + Business Rule: Must yield tokens incrementally for real-time UX + """ + if not 0.0 <= temperature <= 2.0: + raise ValueError( + f"Temperature must be between 0.0 and 2.0 for OpenAI, got {temperature}" + ) + + if model not in self.SUPPORTED_MODELS: + raise ValueError(f"Model {model} not supported. Supported: {self.SUPPORTED_MODELS}") + + try: + client = await self._get_client() + + # Build input for Responses API + input_items: list[dict[str, Any]] = [] + for msg in messages: + input_items.append( + { + "role": msg.role, + "content": msg.content, + } + ) + + # Call OpenAI Responses API with streaming + logger.info( + "Calling OpenAI Responses API (streaming)", + model=model, + num_messages=len(input_items), + temperature=temperature, + ) + + # Build API parameters + params: dict[str, Any] = { + "model": model, + "input": input_items, + "store": False, + "stream": True, + } + + # Add system prompt as instructions if provided + if system_prompt: + params["instructions"] = system_prompt + + # Add max_output_tokens if specified + if max_tokens: + params["max_output_tokens"] = max_tokens + + # GPT-5 reasoning models don't support temperature parameter + models_without_temperature = { + "gpt-5-pro", + "gpt-5", + "gpt-5-mini", + "gpt-5-nano", + "gpt-5.2", + "gpt-5.2-pro", + } + if model not in models_without_temperature: + params["temperature"] = temperature + + # Use the streaming interface + async with client.responses.stream(**params) as stream: + async for event in stream: + # Handle different event types from Responses API streaming + if hasattr(event, "type"): + if ( + event.type == "response.output_text.delta" + and hasattr(event, "delta") + and event.delta + ): + yield event.delta + elif ( + event.type == "response.content_part.delta" + and hasattr(event, "delta") + and hasattr(event.delta, "text") + ): + yield event.delta.text + + except Exception as e: + logger.error("OpenAI Responses API streaming failed", error=str(e), model=model) + raise RuntimeError(f"OpenAI streaming API call failed: {e}") from e + + async def count_tokens(self, text: str, _model: str) -> int: + """ + Count tokens in text for a specific model. + + Args: + text: Text to tokenize + _model: Model identifier (unused in approximation mode) + + Returns: + Number of tokens + + Note: This is an approximation. For exact counts, use tiktoken library. + """ + # Simple approximation: ~4 characters per token + # For production, use tiktoken library for exact counts + return len(text) // 4 + + async def validate_model(self, model: str) -> bool: + """ + Validate if a model is supported and available. + + Args: + model: Model identifier to validate + + Returns: + True if model is supported and available + """ + return model in self.SUPPORTED_MODELS + + +__all__ = ["OpenAILLMProvider"] diff --git a/coaching/src/infrastructure/llm/provider_factory.py b/coaching/src/infrastructure/llm/provider_factory.py index e3d1fd69..38d5064a 100644 --- a/coaching/src/infrastructure/llm/provider_factory.py +++ b/coaching/src/infrastructure/llm/provider_factory.py @@ -1,411 +1,412 @@ -"""LLM Provider Factory for dynamic multi-provider model selection. - -This module provides a factory for creating and caching LLM providers based on -model configuration from MODEL_REGISTRY. It enables the system to dynamically -select the correct provider (Bedrock, OpenAI, Google Vertex, etc.) based on -the model code specified in topic configuration. - -Design: - - Singleton caching per provider type (not per model) - - Model code resolution to actual model name - - Lazy initialization of providers - - Provider-specific credential validation - -Architecture: - Infrastructure layer component following Clean Architecture. - Uses providers from the same layer and models from core layer. - -Usage: - factory = LLMProviderFactory(settings) - provider, model_name = factory.get_provider_for_model("GPT_5_MINI") - response = await provider.generate(messages, model=model_name, ...) - -Related Issues: - - Issue #136: Implement LLM Provider Factory - - Issue #75: Add support for Claude 4/4.5, GPT-5, and Gemini 2.5 models -""" - -from typing import Any - -import structlog -from coaching.src.core.config_multitenant import ( - Settings, - get_google_vertex_credentials, - get_openai_api_key, -) -from coaching.src.core.llm_models import ( - MODEL_REGISTRY, - LLMProvider, - SupportedModel, - get_model, -) -from coaching.src.infrastructure.llm.exceptions import ( - ModelNotAvailableError, - ModelNotFoundError, - ProviderNotConfiguredError, -) - -logger = structlog.get_logger(__name__) - - -class LLMProviderFactory: - """Factory for creating and managing LLM providers. - - This factory handles: - - Model code resolution to provider and model name - - Provider instantiation with appropriate credentials - - Provider caching (singleton per provider type) - - Validation of model availability and provider configuration - - Attributes: - _settings: Application settings for provider configuration - _providers: Cache of instantiated provider instances - _bedrock_client: Optional pre-injected Bedrock client for testing - """ - - def __init__( - self, - settings: Settings, - bedrock_client: Any | None = None, - ) -> None: - """Initialize the LLM Provider Factory. - - Args: - settings: Application settings with provider configuration - bedrock_client: Optional Bedrock client (for dependency injection in tests) - """ - self._settings = settings - # Use Any to avoid Protocol parameter name compatibility issues - self._providers: dict[LLMProvider, Any] = {} - self._bedrock_client = bedrock_client - logger.info("LLM Provider Factory initialized") - - def get_provider_for_model( - self, - model_code: str, - ) -> tuple[Any, str]: - """Get provider instance and resolved model name for a model code. - - This is the main entry point for model resolution. It: - 1. Looks up the model code in MODEL_REGISTRY - 2. Validates the model is active - 3. Gets or creates the appropriate provider - 4. Returns the provider and the actual model name for API calls - - Args: - model_code: Model code from MODEL_REGISTRY (e.g., "GPT_5_MINI", "CLAUDE_3_5_SONNET") - - Returns: - Tuple of (provider_instance, actual_model_name) - - provider_instance: The LLM provider to use for generation - - actual_model_name: The model identifier to pass to the provider API - - Raises: - ModelNotFoundError: If model_code not in MODEL_REGISTRY - ModelNotAvailableError: If model is inactive (is_active=False) - ProviderNotConfiguredError: If provider credentials are not set - """ - # Step 1: Lookup model in registry - try: - model_config = get_model(model_code) - except ValueError as e: - available_models = list(MODEL_REGISTRY.keys()) - logger.error( - "Model not found in registry", - model_code=model_code, - available_models=available_models, - ) - raise ModelNotFoundError(model_code, available_models) from e - - # Step 2: Validate model is active - if not model_config.is_active: - logger.warning( - "Attempted to use inactive model", - model_code=model_code, - model_name=model_config.model_name, - ) - raise ModelNotAvailableError( - model_code=model_code, - reason="Model is marked as inactive. Enable it in MODEL_REGISTRY.", - ) - - # Step 3: Get or create provider - provider = self._get_or_create_provider(model_config.provider) - - logger.info( - "Provider resolved for model", - model_code=model_code, - model_name=model_config.model_name, - provider=model_config.provider.value, - ) - - return provider, model_config.model_name - - def get_model_info(self, model_code: str) -> SupportedModel: - """Get model configuration from registry. - - Convenience method to access model metadata without creating a provider. - - Args: - model_code: Model code from MODEL_REGISTRY - - Returns: - SupportedModel configuration - - Raises: - ModelNotFoundError: If model_code not in registry - """ - try: - return get_model(model_code) - except ValueError as e: - available_models = list(MODEL_REGISTRY.keys()) - raise ModelNotFoundError(model_code, available_models) from e - - def is_provider_configured(self, provider: LLMProvider) -> bool: - """Check if a provider has required credentials configured. - - Args: - provider: Provider type to check - - Returns: - True if provider credentials are available - """ - if provider == LLMProvider.BEDROCK: - # Bedrock uses IAM roles, always considered configured - return True - elif provider == LLMProvider.OPENAI: - return get_openai_api_key() is not None - elif provider == LLMProvider.GOOGLE_VERTEX: - # Check if credentials are available (env var or secrets manager) - import os - - return ( - os.getenv("GOOGLE_APPLICATION_CREDENTIALS") is not None - or get_google_vertex_credentials() is not None - or self._settings.google_project_id is not None - ) - elif provider == LLMProvider.ANTHROPIC: - return self._settings.anthropic_api_key is not None - return False - - def _get_or_create_provider( - self, - provider_type: LLMProvider, - ) -> Any: - """Get cached provider or create new one. - - Providers are cached as singletons per provider type (not per model). - This ensures efficient resource usage while supporting multiple models - from the same provider. - - Args: - provider_type: Type of provider to create - - Returns: - LLM provider instance - - Raises: - ProviderNotConfiguredError: If provider credentials missing - """ - # Return cached provider if available - if provider_type in self._providers: - return self._providers[provider_type] - - # Create new provider - provider = self._create_provider(provider_type) - self._providers[provider_type] = provider - - logger.info( - "Created new provider instance", - provider_type=provider_type.value, - cached_providers=[p.value for p in self._providers], - ) - - return provider - - def _create_provider(self, provider_type: LLMProvider) -> Any: - """Create a new provider instance. - - Args: - provider_type: Type of provider to create - - Returns: - New LLM provider instance - - Raises: - ProviderNotConfiguredError: If required credentials are missing - ValueError: If provider type is unknown - """ - if provider_type == LLMProvider.BEDROCK: - return self._create_bedrock_provider() - elif provider_type == LLMProvider.OPENAI: - return self._create_openai_provider() - elif provider_type == LLMProvider.GOOGLE_VERTEX: - return self._create_google_vertex_provider() - elif provider_type == LLMProvider.ANTHROPIC: - # Anthropic direct API provider not yet implemented - # Use Bedrock for Claude models instead - raise ProviderNotConfiguredError( - provider="anthropic", - missing_config="Anthropic direct API provider not implemented. " - "Use Bedrock for Claude models.", - ) - else: - raise ValueError(f"Unknown provider type: {provider_type}") - - def _create_bedrock_provider(self) -> Any: - """Create AWS Bedrock provider. - - Bedrock uses IAM roles for authentication, so no API key is needed. - The boto3 client handles credential resolution automatically. - - Returns: - BedrockLLMProvider instance - """ - from coaching.src.infrastructure.llm.bedrock_provider import BedrockLLMProvider - from shared.services.aws_helpers import get_bedrock_client - - # Use injected client or create new one - bedrock_client = self._bedrock_client - if bedrock_client is None: - bedrock_client = get_bedrock_client(self._settings.bedrock_region) - - return BedrockLLMProvider( - bedrock_client=bedrock_client, - region=self._settings.bedrock_region, - ) - - def _create_openai_provider(self) -> Any: - """Create OpenAI provider. - - Requires OpenAI API key from environment or AWS Secrets Manager. - - Returns: - OpenAILLMProvider instance - - Raises: - ProviderNotConfiguredError: If API key not configured - """ - from coaching.src.infrastructure.llm.openai_provider import OpenAILLMProvider - - api_key = get_openai_api_key() - if not api_key: - raise ProviderNotConfiguredError( - provider="openai", - missing_config="OPENAI_API_KEY environment variable or AWS secret", - ) - - return OpenAILLMProvider(api_key=api_key) - - def _create_google_vertex_provider(self) -> Any: - """Create Google Vertex AI provider. - - Requires either GOOGLE_APPLICATION_CREDENTIALS env var or - credentials stored in AWS Secrets Manager. - - Returns: - GoogleVertexLLMProvider instance - - Raises: - ProviderNotConfiguredError: If credentials not configured - """ - from coaching.src.infrastructure.llm.google_vertex_provider import ( - GoogleVertexLLMProvider, - ) - - credentials = get_google_vertex_credentials() - project_id = self._settings.google_project_id - - # Check for GOOGLE_APPLICATION_CREDENTIALS env var (local dev) - import os - - if os.getenv("GOOGLE_APPLICATION_CREDENTIALS"): - # Let Google SDK handle credentials - return GoogleVertexLLMProvider( - project_id=project_id, - location=self._settings.google_vertex_location, - ) - - if credentials: - # Convert credentials dict to service_account.Credentials object - # This is required because aiplatform.init() expects a credentials object, - # not a raw dict - from google.oauth2 import service_account - - # Extract project_id from credentials if not set in settings - # This happens when credentials come from AWS Secrets Manager - if not project_id and "project_id" in credentials: - project_id = credentials["project_id"] - logger.info( - "Using project_id from credentials", - project_id=project_id, - ) - - credentials_obj = service_account.Credentials.from_service_account_info(credentials) # type: ignore[no-untyped-call] - return GoogleVertexLLMProvider( - project_id=project_id, - location=self._settings.google_vertex_location, - credentials=credentials_obj, - ) - - # If no credentials but project_id is set, try default credentials - if project_id: - return GoogleVertexLLMProvider( - project_id=project_id, - location=self._settings.google_vertex_location, - ) - - raise ProviderNotConfiguredError( - provider="google_vertex", - missing_config="GOOGLE_APPLICATION_CREDENTIALS, GOOGLE_PROJECT_ID, or AWS secret", - ) - - # NOTE: Anthropic direct API provider not yet implemented. - # Claude models should be accessed via Bedrock provider. - # When implementing, uncomment and add: - # def _create_anthropic_provider(self) -> Any: - # from coaching.src.infrastructure.llm.anthropic_provider import ( - # AnthropicLLMProvider, - # ) - # api_key = self._settings.anthropic_api_key - # if not api_key: - # raise ProviderNotConfiguredError( - # provider="anthropic", - # missing_config="ANTHROPIC_API_KEY environment variable", - # ) - # return AnthropicLLMProvider(api_key=api_key) - - def clear_cache(self) -> None: - """Clear the provider cache. - - Useful for testing or when credentials are updated. - """ - self._providers.clear() - logger.info("Provider cache cleared") - - -# Module-level singleton (initialized lazily) -_factory_instance: LLMProviderFactory | None = None - - -def get_provider_factory(settings: Settings | None = None) -> LLMProviderFactory: - """Get or create the global LLMProviderFactory singleton. - - Args: - settings: Optional settings override (uses default if not provided) - - Returns: - Global LLMProviderFactory instance - """ - global _factory_instance - if _factory_instance is None: - from coaching.src.core.config_multitenant import get_settings - - _factory_instance = LLMProviderFactory(settings or get_settings()) - return _factory_instance - - -__all__ = [ - "LLMProviderFactory", - "get_provider_factory", -] +"""LLM Provider Factory for dynamic multi-provider model selection. + +This module provides a factory for creating and caching LLM providers based on +model configuration from MODEL_REGISTRY. It enables the system to dynamically +select the correct provider (Bedrock, OpenAI, Google Vertex, etc.) based on +the model code specified in topic configuration. + +Design: + - Singleton caching per provider type (not per model) + - Model code resolution to actual model name + - Lazy initialization of providers + - Provider-specific credential validation + +Architecture: + Infrastructure layer component following Clean Architecture. + Uses providers from the same layer and models from core layer. + +Usage: + factory = LLMProviderFactory(settings) + provider, model_name = factory.get_provider_for_model("GPT_5_MINI") + response = await provider.generate(messages, model=model_name, ...) + +Related Issues: + - Issue #136: Implement LLM Provider Factory + - Issue #75: Add support for Claude 4/4.5, GPT-5, and Gemini 2.5 models +""" + +from typing import Any + +import structlog + +from coaching.src.core.config_multitenant import ( + Settings, + get_google_vertex_credentials, + get_openai_api_key, +) +from coaching.src.core.llm_models import ( + MODEL_REGISTRY, + LLMProvider, + SupportedModel, + get_model, +) +from coaching.src.infrastructure.llm.exceptions import ( + ModelNotAvailableError, + ModelNotFoundError, + ProviderNotConfiguredError, +) + +logger = structlog.get_logger(__name__) + + +class LLMProviderFactory: + """Factory for creating and managing LLM providers. + + This factory handles: + - Model code resolution to provider and model name + - Provider instantiation with appropriate credentials + - Provider caching (singleton per provider type) + - Validation of model availability and provider configuration + + Attributes: + _settings: Application settings for provider configuration + _providers: Cache of instantiated provider instances + _bedrock_client: Optional pre-injected Bedrock client for testing + """ + + def __init__( + self, + settings: Settings, + bedrock_client: Any | None = None, + ) -> None: + """Initialize the LLM Provider Factory. + + Args: + settings: Application settings with provider configuration + bedrock_client: Optional Bedrock client (for dependency injection in tests) + """ + self._settings = settings + # Use Any to avoid Protocol parameter name compatibility issues + self._providers: dict[LLMProvider, Any] = {} + self._bedrock_client = bedrock_client + logger.info("LLM Provider Factory initialized") + + def get_provider_for_model( + self, + model_code: str, + ) -> tuple[Any, str]: + """Get provider instance and resolved model name for a model code. + + This is the main entry point for model resolution. It: + 1. Looks up the model code in MODEL_REGISTRY + 2. Validates the model is active + 3. Gets or creates the appropriate provider + 4. Returns the provider and the actual model name for API calls + + Args: + model_code: Model code from MODEL_REGISTRY (e.g., "GPT_5_MINI", "CLAUDE_3_5_SONNET") + + Returns: + Tuple of (provider_instance, actual_model_name) + - provider_instance: The LLM provider to use for generation + - actual_model_name: The model identifier to pass to the provider API + + Raises: + ModelNotFoundError: If model_code not in MODEL_REGISTRY + ModelNotAvailableError: If model is inactive (is_active=False) + ProviderNotConfiguredError: If provider credentials are not set + """ + # Step 1: Lookup model in registry + try: + model_config = get_model(model_code) + except ValueError as e: + available_models = list(MODEL_REGISTRY.keys()) + logger.error( + "Model not found in registry", + model_code=model_code, + available_models=available_models, + ) + raise ModelNotFoundError(model_code, available_models) from e + + # Step 2: Validate model is active + if not model_config.is_active: + logger.warning( + "Attempted to use inactive model", + model_code=model_code, + model_name=model_config.model_name, + ) + raise ModelNotAvailableError( + model_code=model_code, + reason="Model is marked as inactive. Enable it in MODEL_REGISTRY.", + ) + + # Step 3: Get or create provider + provider = self._get_or_create_provider(model_config.provider) + + logger.info( + "Provider resolved for model", + model_code=model_code, + model_name=model_config.model_name, + provider=model_config.provider.value, + ) + + return provider, model_config.model_name + + def get_model_info(self, model_code: str) -> SupportedModel: + """Get model configuration from registry. + + Convenience method to access model metadata without creating a provider. + + Args: + model_code: Model code from MODEL_REGISTRY + + Returns: + SupportedModel configuration + + Raises: + ModelNotFoundError: If model_code not in registry + """ + try: + return get_model(model_code) + except ValueError as e: + available_models = list(MODEL_REGISTRY.keys()) + raise ModelNotFoundError(model_code, available_models) from e + + def is_provider_configured(self, provider: LLMProvider) -> bool: + """Check if a provider has required credentials configured. + + Args: + provider: Provider type to check + + Returns: + True if provider credentials are available + """ + if provider == LLMProvider.BEDROCK: + # Bedrock uses IAM roles, always considered configured + return True + elif provider == LLMProvider.OPENAI: + return get_openai_api_key() is not None + elif provider == LLMProvider.GOOGLE_VERTEX: + # Check if credentials are available (env var or secrets manager) + import os + + return ( + os.getenv("GOOGLE_APPLICATION_CREDENTIALS") is not None + or get_google_vertex_credentials() is not None + or self._settings.google_project_id is not None + ) + elif provider == LLMProvider.ANTHROPIC: + return self._settings.anthropic_api_key is not None + return False + + def _get_or_create_provider( + self, + provider_type: LLMProvider, + ) -> Any: + """Get cached provider or create new one. + + Providers are cached as singletons per provider type (not per model). + This ensures efficient resource usage while supporting multiple models + from the same provider. + + Args: + provider_type: Type of provider to create + + Returns: + LLM provider instance + + Raises: + ProviderNotConfiguredError: If provider credentials missing + """ + # Return cached provider if available + if provider_type in self._providers: + return self._providers[provider_type] + + # Create new provider + provider = self._create_provider(provider_type) + self._providers[provider_type] = provider + + logger.info( + "Created new provider instance", + provider_type=provider_type.value, + cached_providers=[p.value for p in self._providers], + ) + + return provider + + def _create_provider(self, provider_type: LLMProvider) -> Any: + """Create a new provider instance. + + Args: + provider_type: Type of provider to create + + Returns: + New LLM provider instance + + Raises: + ProviderNotConfiguredError: If required credentials are missing + ValueError: If provider type is unknown + """ + if provider_type == LLMProvider.BEDROCK: + return self._create_bedrock_provider() + elif provider_type == LLMProvider.OPENAI: + return self._create_openai_provider() + elif provider_type == LLMProvider.GOOGLE_VERTEX: + return self._create_google_vertex_provider() + elif provider_type == LLMProvider.ANTHROPIC: + # Anthropic direct API provider not yet implemented + # Use Bedrock for Claude models instead + raise ProviderNotConfiguredError( + provider="anthropic", + missing_config="Anthropic direct API provider not implemented. " + "Use Bedrock for Claude models.", + ) + else: + raise ValueError(f"Unknown provider type: {provider_type}") + + def _create_bedrock_provider(self) -> Any: + """Create AWS Bedrock provider. + + Bedrock uses IAM roles for authentication, so no API key is needed. + The boto3 client handles credential resolution automatically. + + Returns: + BedrockLLMProvider instance + """ + from coaching.src.infrastructure.llm.bedrock_provider import BedrockLLMProvider + from shared.services.aws_helpers import get_bedrock_client + + # Use injected client or create new one + bedrock_client = self._bedrock_client + if bedrock_client is None: + bedrock_client = get_bedrock_client(self._settings.bedrock_region) + + return BedrockLLMProvider( + bedrock_client=bedrock_client, + region=self._settings.bedrock_region, + ) + + def _create_openai_provider(self) -> Any: + """Create OpenAI provider. + + Requires OpenAI API key from environment or AWS Secrets Manager. + + Returns: + OpenAILLMProvider instance + + Raises: + ProviderNotConfiguredError: If API key not configured + """ + from coaching.src.infrastructure.llm.openai_provider import OpenAILLMProvider + + api_key = get_openai_api_key() + if not api_key: + raise ProviderNotConfiguredError( + provider="openai", + missing_config="OPENAI_API_KEY environment variable or AWS secret", + ) + + return OpenAILLMProvider(api_key=api_key) + + def _create_google_vertex_provider(self) -> Any: + """Create Google Vertex AI provider. + + Requires either GOOGLE_APPLICATION_CREDENTIALS env var or + credentials stored in AWS Secrets Manager. + + Returns: + GoogleVertexLLMProvider instance + + Raises: + ProviderNotConfiguredError: If credentials not configured + """ + from coaching.src.infrastructure.llm.google_vertex_provider import ( + GoogleVertexLLMProvider, + ) + + credentials = get_google_vertex_credentials() + project_id = self._settings.google_project_id + + # Check for GOOGLE_APPLICATION_CREDENTIALS env var (local dev) + import os + + if os.getenv("GOOGLE_APPLICATION_CREDENTIALS"): + # Let Google SDK handle credentials + return GoogleVertexLLMProvider( + project_id=project_id, + location=self._settings.google_vertex_location, + ) + + if credentials: + # Convert credentials dict to service_account.Credentials object + # This is required because aiplatform.init() expects a credentials object, + # not a raw dict + from google.oauth2 import service_account + + # Extract project_id from credentials if not set in settings + # This happens when credentials come from AWS Secrets Manager + if not project_id and "project_id" in credentials: + project_id = credentials["project_id"] + logger.info( + "Using project_id from credentials", + project_id=project_id, + ) + + credentials_obj = service_account.Credentials.from_service_account_info(credentials) # type: ignore[no-untyped-call] + return GoogleVertexLLMProvider( + project_id=project_id, + location=self._settings.google_vertex_location, + credentials=credentials_obj, + ) + + # If no credentials but project_id is set, try default credentials + if project_id: + return GoogleVertexLLMProvider( + project_id=project_id, + location=self._settings.google_vertex_location, + ) + + raise ProviderNotConfiguredError( + provider="google_vertex", + missing_config="GOOGLE_APPLICATION_CREDENTIALS, GOOGLE_PROJECT_ID, or AWS secret", + ) + + # NOTE: Anthropic direct API provider not yet implemented. + # Claude models should be accessed via Bedrock provider. + # When implementing, uncomment and add: + # def _create_anthropic_provider(self) -> Any: + # from coaching.src.infrastructure.llm.anthropic_provider import ( + # AnthropicLLMProvider, + # ) + # api_key = self._settings.anthropic_api_key + # if not api_key: + # raise ProviderNotConfiguredError( + # provider="anthropic", + # missing_config="ANTHROPIC_API_KEY environment variable", + # ) + # return AnthropicLLMProvider(api_key=api_key) + + def clear_cache(self) -> None: + """Clear the provider cache. + + Useful for testing or when credentials are updated. + """ + self._providers.clear() + logger.info("Provider cache cleared") + + +# Module-level singleton (initialized lazily) +_factory_instance: LLMProviderFactory | None = None + + +def get_provider_factory(settings: Settings | None = None) -> LLMProviderFactory: + """Get or create the global LLMProviderFactory singleton. + + Args: + settings: Optional settings override (uses default if not provided) + + Returns: + Global LLMProviderFactory instance + """ + global _factory_instance + if _factory_instance is None: + from coaching.src.core.config_multitenant import get_settings + + _factory_instance = LLMProviderFactory(settings or get_settings()) + return _factory_instance + + +__all__ = [ + "LLMProviderFactory", + "get_provider_factory", +] diff --git a/coaching/src/infrastructure/repositories/dynamodb_coaching_session_repository.py b/coaching/src/infrastructure/repositories/dynamodb_coaching_session_repository.py index 80dbe20c..fc42012a 100644 --- a/coaching/src/infrastructure/repositories/dynamodb_coaching_session_repository.py +++ b/coaching/src/infrastructure/repositories/dynamodb_coaching_session_repository.py @@ -10,6 +10,7 @@ import structlog from boto3.dynamodb.conditions import Attr, Key + from coaching.src.core.constants import ConversationStatus, MessageRole from coaching.src.core.types import SessionId, TenantId, UserId from coaching.src.domain.entities.coaching_session import ( diff --git a/coaching/src/infrastructure/repositories/dynamodb_conversation_repository.py b/coaching/src/infrastructure/repositories/dynamodb_conversation_repository.py index 41d87b37..3f893628 100644 --- a/coaching/src/infrastructure/repositories/dynamodb_conversation_repository.py +++ b/coaching/src/infrastructure/repositories/dynamodb_conversation_repository.py @@ -1,343 +1,344 @@ -"""DynamoDB implementation of ConversationRepositoryPort. - -This module provides a DynamoDB-backed implementation of the conversation -repository port interface, handling persistence and retrieval of conversations. -""" - -from datetime import UTC, datetime, timedelta -from typing import Any - -import structlog -from boto3.dynamodb.conditions import Attr, Key -from coaching.src.core.constants import ConversationStatus -from coaching.src.core.types import ConversationId, TenantId, UserId -from coaching.src.domain.entities.conversation import Conversation -from coaching.src.domain.value_objects.conversation_context import ConversationContext -from coaching.src.domain.value_objects.message import Message - -logger = structlog.get_logger() - - -class DynamoDBConversationRepository: - """ - DynamoDB adapter implementing ConversationRepositoryPort. - - This adapter provides DynamoDB-backed persistence for conversations, - implementing the repository port interface defined in the domain layer. - - Design: - - Maps domain entities to/from DynamoDB items - - Enforces multi-tenant isolation - - Handles TTL for automatic cleanup - - Includes observability hooks - """ - - def __init__( - self, - dynamodb_resource: Any, # boto3.resources.base.ServiceResource - table_name: str, - ): - """ - Initialize DynamoDB conversation repository. - - Args: - dynamodb_resource: Boto3 DynamoDB resource - table_name: DynamoDB table name for conversations - """ - self.dynamodb = dynamodb_resource - self.table = self.dynamodb.Table(table_name) - logger.info("DynamoDB conversation repository initialized", table_name=table_name) - - async def save(self, conversation: Conversation) -> None: - """ - Persist a conversation to DynamoDB. - - Args: - conversation: Conversation entity to persist - - Business Rule: Conversations are stored with 30-day TTL for automatic cleanup - """ - try: - item = self._to_dynamodb_item(conversation) - self.table.put_item(Item=item) - - logger.info( - "Conversation saved", - conversation_id=conversation.conversation_id, - user_id=conversation.user_id, - status=conversation.status.value, - ) - except Exception as e: - logger.error( - "Failed to save conversation", - conversation_id=conversation.conversation_id, - error=str(e), - ) - raise - - async def get_by_id( - self, conversation_id: ConversationId, tenant_id: TenantId | None = None - ) -> Conversation | None: - """ - Retrieve a conversation by ID. - - Args: - conversation_id: Unique conversation identifier - tenant_id: Optional tenant ID for multi-tenant isolation - - Returns: - Conversation entity if found, None otherwise - """ - try: - response = self.table.get_item(Key={"conversation_id": conversation_id}) - - if "Item" not in response: - logger.debug("Conversation not found", conversation_id=conversation_id) - return None - - item = response["Item"] - - # Enforce tenant isolation if tenant_id provided - if tenant_id and item.get("tenant_id") != tenant_id: - logger.warning( - "Tenant isolation violation attempt", - conversation_id=conversation_id, - requested_tenant=tenant_id, - actual_tenant=item.get("tenant_id"), - ) - return None - - conversation = self._from_dynamodb_item(item) - logger.debug("Conversation retrieved", conversation_id=conversation_id) - return conversation - - except Exception as e: - logger.error( - "Failed to retrieve conversation", - conversation_id=conversation_id, - error=str(e), - ) - raise - - async def get_by_user( - self, - user_id: UserId, - tenant_id: TenantId | None = None, - limit: int = 10, - active_only: bool = False, - ) -> list[Conversation]: - """ - Retrieve conversations for a specific user. - - Args: - user_id: User identifier - tenant_id: Optional tenant ID for multi-tenant isolation - limit: Maximum number of conversations to return - active_only: If True, only return active conversations - - Returns: - List of conversation entities (ordered by most recent first) - """ - try: - # Build query expression - key_condition = Key("user_id").eq(user_id) - - # Add tenant filter if provided - filter_expression: Any = None - if tenant_id: - filter_expression = Attr("tenant_id").eq(tenant_id) - - if active_only: - status_filter = Attr("status").eq(ConversationStatus.ACTIVE.value) - filter_expression = ( - status_filter if not filter_expression else filter_expression & status_filter - ) - - # Query with GSI on user_id - query_params: dict[str, Any] = { - "IndexName": "user_id-index", - "KeyConditionExpression": key_condition, - "Limit": limit, - "ScanIndexForward": False, # Most recent first - } - - if filter_expression: - query_params["FilterExpression"] = filter_expression - - response = self.table.query(**query_params) - - conversations = [self._from_dynamodb_item(item) for item in response.get("Items", [])] - - logger.debug( - "Conversations retrieved for user", - user_id=user_id, - count=len(conversations), - active_only=active_only, - ) - - return conversations - - except Exception as e: - logger.error("Failed to retrieve conversations for user", user_id=user_id, error=str(e)) - raise - - async def delete( - self, conversation_id: ConversationId, tenant_id: TenantId | None = None - ) -> bool: - """ - Delete a conversation (soft delete by marking as ABANDONED). - - Args: - conversation_id: Unique conversation identifier - tenant_id: Optional tenant ID for multi-tenant isolation - - Returns: - True if conversation was deleted, False if not found - - Business Rule: Soft delete is preferred; mark as ABANDONED instead of hard delete - """ - try: - # Get conversation first to enforce tenant isolation - conversation = await self.get_by_id(conversation_id, tenant_id) - - if not conversation: - return False - - # Soft delete: Update status to ABANDONED - self.table.update_item( - Key={"conversation_id": conversation_id}, - UpdateExpression="SET #status = :status, updated_at = :updated_at", - ExpressionAttributeNames={"#status": "status"}, - ExpressionAttributeValues={ - ":status": ConversationStatus.ABANDONED.value, - ":updated_at": datetime.now(UTC).isoformat(), - }, - ) - - logger.info("Conversation deleted (soft)", conversation_id=conversation_id) - return True - - except Exception as e: - logger.error( - "Failed to delete conversation", conversation_id=conversation_id, error=str(e) - ) - raise - - async def exists( - self, conversation_id: ConversationId, tenant_id: TenantId | None = None - ) -> bool: - """ - Check if a conversation exists. - - Args: - conversation_id: Unique conversation identifier - tenant_id: Optional tenant ID for multi-tenant isolation - - Returns: - True if conversation exists, False otherwise - """ - conversation = await self.get_by_id(conversation_id, tenant_id) - return conversation is not None - - async def get_active_count(self, user_id: UserId, tenant_id: TenantId | None = None) -> int: - """ - Get count of active conversations for a user. - - Args: - user_id: User identifier - tenant_id: Optional tenant ID for multi-tenant isolation - - Returns: - Number of active conversations - """ - conversations = await self.get_by_user( - user_id=user_id, tenant_id=tenant_id, active_only=True, limit=100 - ) - return len(conversations) - - def _to_dynamodb_item(self, conversation: Conversation) -> dict[str, Any]: - """ - Convert domain entity to DynamoDB item. - - Args: - conversation: Conversation domain entity - - Returns: - DynamoDB item dictionary - """ - # Calculate TTL (30 days from now) - ttl = int((datetime.now(UTC) + timedelta(days=30)).timestamp()) - - return { - "conversation_id": conversation.conversation_id, - "user_id": conversation.user_id, - "tenant_id": conversation.tenant_id, - "topic": conversation.topic, - "status": conversation.status.value, - "messages": [ - { - "role": msg.role.value, - "content": msg.content, - "timestamp": msg.timestamp.isoformat(), - "metadata": msg.metadata, - } - for msg in conversation.messages - ], - "context": conversation.context.model_dump(), - "created_at": conversation.created_at.isoformat(), - "updated_at": conversation.updated_at.isoformat(), - "completed_at": ( - conversation.completed_at.isoformat() if conversation.completed_at else None - ), - "metadata": conversation.metadata, - "ttl": ttl, - } - - def _from_dynamodb_item(self, item: dict[str, Any]) -> Conversation: - """ - Convert DynamoDB item to domain entity. - - Args: - item: DynamoDB item dictionary - - Returns: - Conversation domain entity - """ - # Parse messages - messages = [ - Message( - role=msg["role"], - content=msg["content"], - timestamp=datetime.fromisoformat(msg["timestamp"]), - metadata=msg.get("metadata", {}), - ) - for msg in item.get("messages", []) - ] - - # Parse context - context_data = item.get("context", {}) - context = ConversationContext(**context_data) - - # Parse timestamps - created_at = datetime.fromisoformat(item["created_at"]) - updated_at = datetime.fromisoformat(item["updated_at"]) - completed_at = ( - datetime.fromisoformat(item["completed_at"]) if item.get("completed_at") else None - ) - - return Conversation( - conversation_id=item["conversation_id"], - user_id=item["user_id"], - tenant_id=item["tenant_id"], - topic=item["topic"], - status=item["status"], - messages=messages, - context=context, - created_at=created_at, - updated_at=updated_at, - completed_at=completed_at, - metadata=item.get("metadata", {}), - ) - - -__all__ = ["DynamoDBConversationRepository"] +"""DynamoDB implementation of ConversationRepositoryPort. + +This module provides a DynamoDB-backed implementation of the conversation +repository port interface, handling persistence and retrieval of conversations. +""" + +from datetime import UTC, datetime, timedelta +from typing import Any + +import structlog +from boto3.dynamodb.conditions import Attr, Key + +from coaching.src.core.constants import ConversationStatus +from coaching.src.core.types import ConversationId, TenantId, UserId +from coaching.src.domain.entities.conversation import Conversation +from coaching.src.domain.value_objects.conversation_context import ConversationContext +from coaching.src.domain.value_objects.message import Message + +logger = structlog.get_logger() + + +class DynamoDBConversationRepository: + """ + DynamoDB adapter implementing ConversationRepositoryPort. + + This adapter provides DynamoDB-backed persistence for conversations, + implementing the repository port interface defined in the domain layer. + + Design: + - Maps domain entities to/from DynamoDB items + - Enforces multi-tenant isolation + - Handles TTL for automatic cleanup + - Includes observability hooks + """ + + def __init__( + self, + dynamodb_resource: Any, # boto3.resources.base.ServiceResource + table_name: str, + ): + """ + Initialize DynamoDB conversation repository. + + Args: + dynamodb_resource: Boto3 DynamoDB resource + table_name: DynamoDB table name for conversations + """ + self.dynamodb = dynamodb_resource + self.table = self.dynamodb.Table(table_name) + logger.info("DynamoDB conversation repository initialized", table_name=table_name) + + async def save(self, conversation: Conversation) -> None: + """ + Persist a conversation to DynamoDB. + + Args: + conversation: Conversation entity to persist + + Business Rule: Conversations are stored with 30-day TTL for automatic cleanup + """ + try: + item = self._to_dynamodb_item(conversation) + self.table.put_item(Item=item) + + logger.info( + "Conversation saved", + conversation_id=conversation.conversation_id, + user_id=conversation.user_id, + status=conversation.status.value, + ) + except Exception as e: + logger.error( + "Failed to save conversation", + conversation_id=conversation.conversation_id, + error=str(e), + ) + raise + + async def get_by_id( + self, conversation_id: ConversationId, tenant_id: TenantId | None = None + ) -> Conversation | None: + """ + Retrieve a conversation by ID. + + Args: + conversation_id: Unique conversation identifier + tenant_id: Optional tenant ID for multi-tenant isolation + + Returns: + Conversation entity if found, None otherwise + """ + try: + response = self.table.get_item(Key={"conversation_id": conversation_id}) + + if "Item" not in response: + logger.debug("Conversation not found", conversation_id=conversation_id) + return None + + item = response["Item"] + + # Enforce tenant isolation if tenant_id provided + if tenant_id and item.get("tenant_id") != tenant_id: + logger.warning( + "Tenant isolation violation attempt", + conversation_id=conversation_id, + requested_tenant=tenant_id, + actual_tenant=item.get("tenant_id"), + ) + return None + + conversation = self._from_dynamodb_item(item) + logger.debug("Conversation retrieved", conversation_id=conversation_id) + return conversation + + except Exception as e: + logger.error( + "Failed to retrieve conversation", + conversation_id=conversation_id, + error=str(e), + ) + raise + + async def get_by_user( + self, + user_id: UserId, + tenant_id: TenantId | None = None, + limit: int = 10, + active_only: bool = False, + ) -> list[Conversation]: + """ + Retrieve conversations for a specific user. + + Args: + user_id: User identifier + tenant_id: Optional tenant ID for multi-tenant isolation + limit: Maximum number of conversations to return + active_only: If True, only return active conversations + + Returns: + List of conversation entities (ordered by most recent first) + """ + try: + # Build query expression + key_condition = Key("user_id").eq(user_id) + + # Add tenant filter if provided + filter_expression: Any = None + if tenant_id: + filter_expression = Attr("tenant_id").eq(tenant_id) + + if active_only: + status_filter = Attr("status").eq(ConversationStatus.ACTIVE.value) + filter_expression = ( + status_filter if not filter_expression else filter_expression & status_filter + ) + + # Query with GSI on user_id + query_params: dict[str, Any] = { + "IndexName": "user_id-index", + "KeyConditionExpression": key_condition, + "Limit": limit, + "ScanIndexForward": False, # Most recent first + } + + if filter_expression: + query_params["FilterExpression"] = filter_expression + + response = self.table.query(**query_params) + + conversations = [self._from_dynamodb_item(item) for item in response.get("Items", [])] + + logger.debug( + "Conversations retrieved for user", + user_id=user_id, + count=len(conversations), + active_only=active_only, + ) + + return conversations + + except Exception as e: + logger.error("Failed to retrieve conversations for user", user_id=user_id, error=str(e)) + raise + + async def delete( + self, conversation_id: ConversationId, tenant_id: TenantId | None = None + ) -> bool: + """ + Delete a conversation (soft delete by marking as ABANDONED). + + Args: + conversation_id: Unique conversation identifier + tenant_id: Optional tenant ID for multi-tenant isolation + + Returns: + True if conversation was deleted, False if not found + + Business Rule: Soft delete is preferred; mark as ABANDONED instead of hard delete + """ + try: + # Get conversation first to enforce tenant isolation + conversation = await self.get_by_id(conversation_id, tenant_id) + + if not conversation: + return False + + # Soft delete: Update status to ABANDONED + self.table.update_item( + Key={"conversation_id": conversation_id}, + UpdateExpression="SET #status = :status, updated_at = :updated_at", + ExpressionAttributeNames={"#status": "status"}, + ExpressionAttributeValues={ + ":status": ConversationStatus.ABANDONED.value, + ":updated_at": datetime.now(UTC).isoformat(), + }, + ) + + logger.info("Conversation deleted (soft)", conversation_id=conversation_id) + return True + + except Exception as e: + logger.error( + "Failed to delete conversation", conversation_id=conversation_id, error=str(e) + ) + raise + + async def exists( + self, conversation_id: ConversationId, tenant_id: TenantId | None = None + ) -> bool: + """ + Check if a conversation exists. + + Args: + conversation_id: Unique conversation identifier + tenant_id: Optional tenant ID for multi-tenant isolation + + Returns: + True if conversation exists, False otherwise + """ + conversation = await self.get_by_id(conversation_id, tenant_id) + return conversation is not None + + async def get_active_count(self, user_id: UserId, tenant_id: TenantId | None = None) -> int: + """ + Get count of active conversations for a user. + + Args: + user_id: User identifier + tenant_id: Optional tenant ID for multi-tenant isolation + + Returns: + Number of active conversations + """ + conversations = await self.get_by_user( + user_id=user_id, tenant_id=tenant_id, active_only=True, limit=100 + ) + return len(conversations) + + def _to_dynamodb_item(self, conversation: Conversation) -> dict[str, Any]: + """ + Convert domain entity to DynamoDB item. + + Args: + conversation: Conversation domain entity + + Returns: + DynamoDB item dictionary + """ + # Calculate TTL (30 days from now) + ttl = int((datetime.now(UTC) + timedelta(days=30)).timestamp()) + + return { + "conversation_id": conversation.conversation_id, + "user_id": conversation.user_id, + "tenant_id": conversation.tenant_id, + "topic": conversation.topic, + "status": conversation.status.value, + "messages": [ + { + "role": msg.role.value, + "content": msg.content, + "timestamp": msg.timestamp.isoformat(), + "metadata": msg.metadata, + } + for msg in conversation.messages + ], + "context": conversation.context.model_dump(), + "created_at": conversation.created_at.isoformat(), + "updated_at": conversation.updated_at.isoformat(), + "completed_at": ( + conversation.completed_at.isoformat() if conversation.completed_at else None + ), + "metadata": conversation.metadata, + "ttl": ttl, + } + + def _from_dynamodb_item(self, item: dict[str, Any]) -> Conversation: + """ + Convert DynamoDB item to domain entity. + + Args: + item: DynamoDB item dictionary + + Returns: + Conversation domain entity + """ + # Parse messages + messages = [ + Message( + role=msg["role"], + content=msg["content"], + timestamp=datetime.fromisoformat(msg["timestamp"]), + metadata=msg.get("metadata", {}), + ) + for msg in item.get("messages", []) + ] + + # Parse context + context_data = item.get("context", {}) + context = ConversationContext(**context_data) + + # Parse timestamps + created_at = datetime.fromisoformat(item["created_at"]) + updated_at = datetime.fromisoformat(item["updated_at"]) + completed_at = ( + datetime.fromisoformat(item["completed_at"]) if item.get("completed_at") else None + ) + + return Conversation( + conversation_id=item["conversation_id"], + user_id=item["user_id"], + tenant_id=item["tenant_id"], + topic=item["topic"], + status=item["status"], + messages=messages, + context=context, + created_at=created_at, + updated_at=updated_at, + completed_at=completed_at, + metadata=item.get("metadata", {}), + ) + + +__all__ = ["DynamoDBConversationRepository"] diff --git a/coaching/src/infrastructure/repositories/dynamodb_job_repository.py b/coaching/src/infrastructure/repositories/dynamodb_job_repository.py index 87de5416..43ab4416 100644 --- a/coaching/src/infrastructure/repositories/dynamodb_job_repository.py +++ b/coaching/src/infrastructure/repositories/dynamodb_job_repository.py @@ -9,6 +9,7 @@ import structlog from boto3.dynamodb.conditions import Key + from coaching.src.domain.entities.ai_job import AIJob, AIJobErrorCode, AIJobStatus, AIJobType logger = structlog.get_logger() diff --git a/coaching/src/infrastructure/repositories/dynamodb_llm_usage_repository.py b/coaching/src/infrastructure/repositories/dynamodb_llm_usage_repository.py index f76efe58..2f7ff405 100644 --- a/coaching/src/infrastructure/repositories/dynamodb_llm_usage_repository.py +++ b/coaching/src/infrastructure/repositories/dynamodb_llm_usage_repository.py @@ -9,6 +9,7 @@ import structlog from boto3.dynamodb.conditions import Attr, Key + from coaching.src.domain.entities.llm_usage_record import LlmUsageRecord logger = structlog.get_logger() diff --git a/coaching/src/infrastructure/repositories/llm_config/template_metadata_repository.py b/coaching/src/infrastructure/repositories/llm_config/template_metadata_repository.py index 78008b42..0015ea8d 100644 --- a/coaching/src/infrastructure/repositories/llm_config/template_metadata_repository.py +++ b/coaching/src/infrastructure/repositories/llm_config/template_metadata_repository.py @@ -1,492 +1,493 @@ -"""DynamoDB repository for Template Metadata entities. - -This repository handles persistence and retrieval of template metadata that -tracks prompt templates stored in S3. -""" - -from datetime import datetime -from typing import Any -from uuid import uuid4 - -import structlog -from boto3.dynamodb.conditions import Attr, Key -from coaching.src.core.llm_interactions import get_interaction -from coaching.src.domain.entities.llm_config.template_metadata import TemplateMetadata - -logger = structlog.get_logger() - - -class TemplateMetadataRepository: - """ - DynamoDB repository for template metadata. - - Design: - - Stores metadata for templates (content is in S3) - - Validates interaction_code exists in INTERACTION_REGISTRY - - Supports version tracking - - Enforces single active template per template_code - - Multi-environment via separate tables - - Table Schema: - PK: template_id (string) - GSI1: interaction-index (for queries by interaction_code) - GSI2: code-index (for queries by template_code) - GSI3: active-index (for queries by is_active) - """ - - def __init__( - self, - dynamodb_resource: Any, # boto3.resources.base.ServiceResource - table_name: str, - ): - """ - Initialize template metadata repository. - - Args: - dynamodb_resource: Boto3 DynamoDB resource - table_name: DynamoDB table name for template metadata - """ - self.dynamodb = dynamodb_resource - self.table = self.dynamodb.Table(table_name) - logger.info("Template metadata repository initialized", table_name=table_name) - - async def create(self, metadata: TemplateMetadata) -> TemplateMetadata: - """ - Create new template metadata. - - Args: - metadata: Template metadata entity to create - - Returns: - Created metadata with generated ID if not provided - - Raises: - ValueError: If template_id already exists or interaction_code invalid - """ - try: - # Validate interaction exists in registry - self._validate_interaction_code(metadata.interaction_code) - - # Generate template_id if not provided - if not metadata.template_id: - metadata.template_id = f"tmpl_{uuid4().hex[:16]}" - - # Set timestamps - now = datetime.utcnow() - metadata.created_at = now - metadata.updated_at = now - - # Convert to DynamoDB item - item = self._to_dynamodb_item(metadata) - - # Use condition expression to prevent overwriting - self.table.put_item( - Item=item, - ConditionExpression=Attr("template_id").not_exists(), - ) - - logger.info( - "Template metadata created", - template_id=metadata.template_id, - template_code=metadata.template_code, - interaction_code=metadata.interaction_code, - ) - - return metadata - - except self.dynamodb.meta.client.exceptions.ConditionalCheckFailedException as e: - logger.error("Template ID already exists", template_id=metadata.template_id) - raise ValueError(f"Template ID already exists: {metadata.template_id}") from e - except Exception as e: - logger.error("Failed to create template metadata", error=str(e), exc_info=True) - raise - - async def get_by_id(self, template_id: str) -> TemplateMetadata | None: - """ - Get template metadata by ID. - - Args: - template_id: Unique template identifier - - Returns: - Template metadata if found, None otherwise - """ - try: - response = self.table.get_item(Key={"template_id": template_id}) - - if "Item" not in response: - logger.debug("Template not found", template_id=template_id) - return None - - logger.debug("Template retrieved", template_id=template_id) - return self._from_dynamodb_item(response["Item"]) - - except Exception as e: - logger.error( - "Failed to get template by ID", template_id=template_id, error=str(e), exc_info=True - ) - raise - - async def get_by_code(self, template_code: str) -> TemplateMetadata | None: - """ - Get template metadata by template code. - - Args: - template_code: Unique template code - - Returns: - Template metadata if found, None otherwise - """ - try: - response = self.table.query( - IndexName="code-index", - KeyConditionExpression=Key("template_code").eq(template_code), - Limit=1, - ) - - if not response.get("Items"): - logger.debug("Template not found by code", template_code=template_code) - return None - - logger.debug("Template retrieved by code", template_code=template_code) - return self._from_dynamodb_item(response["Items"][0]) - - except Exception as e: - logger.error( - "Failed to get template by code", - template_code=template_code, - error=str(e), - exc_info=True, - ) - raise - - async def get_by_interaction(self, interaction_code: str) -> list[TemplateMetadata]: - """ - Get all templates for a specific interaction. - - Args: - interaction_code: Interaction code from INTERACTION_REGISTRY - - Returns: - List of template metadata for the interaction (may be empty) - """ - try: - templates: list[TemplateMetadata] = [] - last_evaluated_key = None - - while True: - query_kwargs = { - "IndexName": "interaction-index", - "KeyConditionExpression": Key("interaction_code").eq(interaction_code), - } - - if last_evaluated_key: - query_kwargs["ExclusiveStartKey"] = last_evaluated_key - - response = self.table.query(**query_kwargs) - - for item in response.get("Items", []): - templates.append(self._from_dynamodb_item(item)) - - last_evaluated_key = response.get("LastEvaluatedKey") - if not last_evaluated_key: - break - - logger.debug( - "Templates retrieved by interaction", - interaction_code=interaction_code, - count=len(templates), - ) - - return templates - - except Exception as e: - logger.error( - "Failed to get templates by interaction", - interaction_code=interaction_code, - error=str(e), - exc_info=True, - ) - raise - - async def get_active_for_interaction(self, interaction_code: str) -> TemplateMetadata | None: - """ - Get the active template for a specific interaction. - - Args: - interaction_code: Interaction code from INTERACTION_REGISTRY - - Returns: - Active template metadata if found, None otherwise - """ - try: - # Get all templates for interaction - templates = await self.get_by_interaction(interaction_code) - - # Filter for active templates - active_templates = [t for t in templates if t.is_active] - - if not active_templates: - logger.debug( - "No active template for interaction", interaction_code=interaction_code - ) - return None - - if len(active_templates) > 1: - logger.warning( - "Multiple active templates for interaction", - interaction_code=interaction_code, - count=len(active_templates), - ) - # Return most recently updated - active_templates.sort(key=lambda t: t.updated_at, reverse=True) - - return active_templates[0] - - except Exception as e: - logger.error( - "Failed to get active template", - interaction_code=interaction_code, - error=str(e), - exc_info=True, - ) - raise - - async def list_versions(self, template_code: str) -> list[TemplateMetadata]: - """ - List all versions of a template by code. - - Args: - template_code: Template code to list versions for - - Returns: - List of template metadata versions, sorted by creation date (newest first) - """ - try: - versions: list[TemplateMetadata] = [] - last_evaluated_key = None - - while True: - query_kwargs = { - "IndexName": "code-index", - "KeyConditionExpression": Key("template_code").eq(template_code), - } - - if last_evaluated_key: - query_kwargs["ExclusiveStartKey"] = last_evaluated_key - - response = self.table.query(**query_kwargs) - - for item in response.get("Items", []): - versions.append(self._from_dynamodb_item(item)) - - last_evaluated_key = response.get("LastEvaluatedKey") - if not last_evaluated_key: - break - - # Sort by created_at descending (newest first) - versions.sort(key=lambda v: v.created_at, reverse=True) - - logger.debug( - "Template versions retrieved", template_code=template_code, count=len(versions) - ) - - return versions - - except Exception as e: - logger.error( - "Failed to list template versions", - template_code=template_code, - error=str(e), - exc_info=True, - ) - raise - - async def update(self, template_id: str, metadata: TemplateMetadata) -> TemplateMetadata: - """ - Update existing template metadata. - - Args: - template_id: Template ID to update - metadata: Updated metadata entity - - Returns: - Updated metadata - - Raises: - ValueError: If template not found or interaction_code invalid - """ - try: - # Validate interaction exists in registry - self._validate_interaction_code(metadata.interaction_code) - - # Ensure template_id matches - metadata.template_id = template_id - - # Update timestamp - metadata.updated_at = datetime.utcnow() - - # Convert to DynamoDB item - item = self._to_dynamodb_item(metadata) - - # Use condition expression to ensure template exists - self.table.put_item( - Item=item, - ConditionExpression=Attr("template_id").exists(), - ) - - logger.info("Template metadata updated", template_id=template_id) - - return metadata - - except self.dynamodb.meta.client.exceptions.ConditionalCheckFailedException as e: - logger.error("Template not found for update", template_id=template_id) - raise ValueError(f"Template not found: {template_id}") from e - except Exception as e: - logger.error("Failed to update template metadata", error=str(e), exc_info=True) - raise - - async def deactivate(self, template_id: str) -> bool: - """ - Deactivate a template (soft delete). - - Args: - template_id: Template ID to deactivate - - Returns: - True if successful - - Raises: - ValueError: If template not found - """ - try: - self.table.update_item( - Key={"template_id": template_id}, - UpdateExpression="SET is_active = :inactive, updated_at = :now", - ExpressionAttributeValues={ - ":inactive": False, - ":now": datetime.utcnow().isoformat(), - }, - ConditionExpression=Attr("template_id").exists(), - ) - - logger.info("Template deactivated", template_id=template_id) - return True - - except self.dynamodb.meta.client.exceptions.ConditionalCheckFailedException as e: - logger.error("Template not found for deactivation", template_id=template_id) - raise ValueError(f"Template not found: {template_id}") from e - except Exception as e: - logger.error("Failed to deactivate template", error=str(e), exc_info=True) - raise - - async def activate(self, template_id: str) -> bool: - """ - Activate a template. - - Args: - template_id: Template ID to activate - - Returns: - True if successful - - Raises: - ValueError: If template not found - """ - try: - self.table.update_item( - Key={"template_id": template_id}, - UpdateExpression="SET is_active = :active, updated_at = :now", - ExpressionAttributeValues={ - ":active": True, - ":now": datetime.utcnow().isoformat(), - }, - ConditionExpression=Attr("template_id").exists(), - ) - - logger.info("Template activated", template_id=template_id) - return True - - except self.dynamodb.meta.client.exceptions.ConditionalCheckFailedException as e: - logger.error("Template not found for activation", template_id=template_id) - raise ValueError(f"Template not found: {template_id}") from e - except Exception as e: - logger.error("Failed to activate template", error=str(e), exc_info=True) - raise - - def _validate_interaction_code(self, interaction_code: str) -> None: - """ - Validate interaction code exists in registry. - - Args: - interaction_code: Code to validate - - Raises: - ValueError: If interaction not in registry with helpful message - """ - try: - get_interaction(interaction_code) - except ValueError as e: - logger.error( - "Invalid interaction code for template", - interaction_code=interaction_code, - error=str(e), - ) - raise ValueError( - f"Cannot create/update template for unknown interaction: {e}. " - f"Interaction must be added to INTERACTION_REGISTRY in " - f"coaching/src/core/llm_interactions.py before creating templates." - ) from e - - def _to_dynamodb_item(self, metadata: TemplateMetadata) -> dict[str, Any]: - """ - Convert template metadata entity to DynamoDB item. - - Args: - metadata: Template metadata entity - - Returns: - DynamoDB item dictionary - """ - return { - "template_id": metadata.template_id, - "template_code": metadata.template_code, - "interaction_code": metadata.interaction_code, - "name": metadata.name, - "description": metadata.description, - "s3_bucket": metadata.s3_bucket, - "s3_key": metadata.s3_key, - "version": metadata.version, - "is_active": metadata.is_active, - "created_at": metadata.created_at.isoformat(), - "updated_at": metadata.updated_at.isoformat(), - "created_by": metadata.created_by, - } - - def _from_dynamodb_item(self, item: dict[str, Any]) -> TemplateMetadata: - """ - Convert DynamoDB item to template metadata entity. - - Args: - item: DynamoDB item dictionary - - Returns: - Template metadata entity - """ - return TemplateMetadata( - template_id=item["template_id"], - template_code=item["template_code"], - interaction_code=item["interaction_code"], - name=item["name"], - description=item["description"], - s3_bucket=item["s3_bucket"], - s3_key=item["s3_key"], - version=item["version"], - is_active=item.get("is_active", True), - created_at=datetime.fromisoformat(item["created_at"]), - updated_at=datetime.fromisoformat(item["updated_at"]), - created_by=item["created_by"], - ) - - -__all__ = ["TemplateMetadataRepository"] +"""DynamoDB repository for Template Metadata entities. + +This repository handles persistence and retrieval of template metadata that +tracks prompt templates stored in S3. +""" + +from datetime import datetime +from typing import Any +from uuid import uuid4 + +import structlog +from boto3.dynamodb.conditions import Attr, Key + +from coaching.src.core.llm_interactions import get_interaction +from coaching.src.domain.entities.llm_config.template_metadata import TemplateMetadata + +logger = structlog.get_logger() + + +class TemplateMetadataRepository: + """ + DynamoDB repository for template metadata. + + Design: + - Stores metadata for templates (content is in S3) + - Validates interaction_code exists in INTERACTION_REGISTRY + - Supports version tracking + - Enforces single active template per template_code + - Multi-environment via separate tables + + Table Schema: + PK: template_id (string) + GSI1: interaction-index (for queries by interaction_code) + GSI2: code-index (for queries by template_code) + GSI3: active-index (for queries by is_active) + """ + + def __init__( + self, + dynamodb_resource: Any, # boto3.resources.base.ServiceResource + table_name: str, + ): + """ + Initialize template metadata repository. + + Args: + dynamodb_resource: Boto3 DynamoDB resource + table_name: DynamoDB table name for template metadata + """ + self.dynamodb = dynamodb_resource + self.table = self.dynamodb.Table(table_name) + logger.info("Template metadata repository initialized", table_name=table_name) + + async def create(self, metadata: TemplateMetadata) -> TemplateMetadata: + """ + Create new template metadata. + + Args: + metadata: Template metadata entity to create + + Returns: + Created metadata with generated ID if not provided + + Raises: + ValueError: If template_id already exists or interaction_code invalid + """ + try: + # Validate interaction exists in registry + self._validate_interaction_code(metadata.interaction_code) + + # Generate template_id if not provided + if not metadata.template_id: + metadata.template_id = f"tmpl_{uuid4().hex[:16]}" + + # Set timestamps + now = datetime.utcnow() + metadata.created_at = now + metadata.updated_at = now + + # Convert to DynamoDB item + item = self._to_dynamodb_item(metadata) + + # Use condition expression to prevent overwriting + self.table.put_item( + Item=item, + ConditionExpression=Attr("template_id").not_exists(), + ) + + logger.info( + "Template metadata created", + template_id=metadata.template_id, + template_code=metadata.template_code, + interaction_code=metadata.interaction_code, + ) + + return metadata + + except self.dynamodb.meta.client.exceptions.ConditionalCheckFailedException as e: + logger.error("Template ID already exists", template_id=metadata.template_id) + raise ValueError(f"Template ID already exists: {metadata.template_id}") from e + except Exception as e: + logger.error("Failed to create template metadata", error=str(e), exc_info=True) + raise + + async def get_by_id(self, template_id: str) -> TemplateMetadata | None: + """ + Get template metadata by ID. + + Args: + template_id: Unique template identifier + + Returns: + Template metadata if found, None otherwise + """ + try: + response = self.table.get_item(Key={"template_id": template_id}) + + if "Item" not in response: + logger.debug("Template not found", template_id=template_id) + return None + + logger.debug("Template retrieved", template_id=template_id) + return self._from_dynamodb_item(response["Item"]) + + except Exception as e: + logger.error( + "Failed to get template by ID", template_id=template_id, error=str(e), exc_info=True + ) + raise + + async def get_by_code(self, template_code: str) -> TemplateMetadata | None: + """ + Get template metadata by template code. + + Args: + template_code: Unique template code + + Returns: + Template metadata if found, None otherwise + """ + try: + response = self.table.query( + IndexName="code-index", + KeyConditionExpression=Key("template_code").eq(template_code), + Limit=1, + ) + + if not response.get("Items"): + logger.debug("Template not found by code", template_code=template_code) + return None + + logger.debug("Template retrieved by code", template_code=template_code) + return self._from_dynamodb_item(response["Items"][0]) + + except Exception as e: + logger.error( + "Failed to get template by code", + template_code=template_code, + error=str(e), + exc_info=True, + ) + raise + + async def get_by_interaction(self, interaction_code: str) -> list[TemplateMetadata]: + """ + Get all templates for a specific interaction. + + Args: + interaction_code: Interaction code from INTERACTION_REGISTRY + + Returns: + List of template metadata for the interaction (may be empty) + """ + try: + templates: list[TemplateMetadata] = [] + last_evaluated_key = None + + while True: + query_kwargs = { + "IndexName": "interaction-index", + "KeyConditionExpression": Key("interaction_code").eq(interaction_code), + } + + if last_evaluated_key: + query_kwargs["ExclusiveStartKey"] = last_evaluated_key + + response = self.table.query(**query_kwargs) + + for item in response.get("Items", []): + templates.append(self._from_dynamodb_item(item)) + + last_evaluated_key = response.get("LastEvaluatedKey") + if not last_evaluated_key: + break + + logger.debug( + "Templates retrieved by interaction", + interaction_code=interaction_code, + count=len(templates), + ) + + return templates + + except Exception as e: + logger.error( + "Failed to get templates by interaction", + interaction_code=interaction_code, + error=str(e), + exc_info=True, + ) + raise + + async def get_active_for_interaction(self, interaction_code: str) -> TemplateMetadata | None: + """ + Get the active template for a specific interaction. + + Args: + interaction_code: Interaction code from INTERACTION_REGISTRY + + Returns: + Active template metadata if found, None otherwise + """ + try: + # Get all templates for interaction + templates = await self.get_by_interaction(interaction_code) + + # Filter for active templates + active_templates = [t for t in templates if t.is_active] + + if not active_templates: + logger.debug( + "No active template for interaction", interaction_code=interaction_code + ) + return None + + if len(active_templates) > 1: + logger.warning( + "Multiple active templates for interaction", + interaction_code=interaction_code, + count=len(active_templates), + ) + # Return most recently updated + active_templates.sort(key=lambda t: t.updated_at, reverse=True) + + return active_templates[0] + + except Exception as e: + logger.error( + "Failed to get active template", + interaction_code=interaction_code, + error=str(e), + exc_info=True, + ) + raise + + async def list_versions(self, template_code: str) -> list[TemplateMetadata]: + """ + List all versions of a template by code. + + Args: + template_code: Template code to list versions for + + Returns: + List of template metadata versions, sorted by creation date (newest first) + """ + try: + versions: list[TemplateMetadata] = [] + last_evaluated_key = None + + while True: + query_kwargs = { + "IndexName": "code-index", + "KeyConditionExpression": Key("template_code").eq(template_code), + } + + if last_evaluated_key: + query_kwargs["ExclusiveStartKey"] = last_evaluated_key + + response = self.table.query(**query_kwargs) + + for item in response.get("Items", []): + versions.append(self._from_dynamodb_item(item)) + + last_evaluated_key = response.get("LastEvaluatedKey") + if not last_evaluated_key: + break + + # Sort by created_at descending (newest first) + versions.sort(key=lambda v: v.created_at, reverse=True) + + logger.debug( + "Template versions retrieved", template_code=template_code, count=len(versions) + ) + + return versions + + except Exception as e: + logger.error( + "Failed to list template versions", + template_code=template_code, + error=str(e), + exc_info=True, + ) + raise + + async def update(self, template_id: str, metadata: TemplateMetadata) -> TemplateMetadata: + """ + Update existing template metadata. + + Args: + template_id: Template ID to update + metadata: Updated metadata entity + + Returns: + Updated metadata + + Raises: + ValueError: If template not found or interaction_code invalid + """ + try: + # Validate interaction exists in registry + self._validate_interaction_code(metadata.interaction_code) + + # Ensure template_id matches + metadata.template_id = template_id + + # Update timestamp + metadata.updated_at = datetime.utcnow() + + # Convert to DynamoDB item + item = self._to_dynamodb_item(metadata) + + # Use condition expression to ensure template exists + self.table.put_item( + Item=item, + ConditionExpression=Attr("template_id").exists(), + ) + + logger.info("Template metadata updated", template_id=template_id) + + return metadata + + except self.dynamodb.meta.client.exceptions.ConditionalCheckFailedException as e: + logger.error("Template not found for update", template_id=template_id) + raise ValueError(f"Template not found: {template_id}") from e + except Exception as e: + logger.error("Failed to update template metadata", error=str(e), exc_info=True) + raise + + async def deactivate(self, template_id: str) -> bool: + """ + Deactivate a template (soft delete). + + Args: + template_id: Template ID to deactivate + + Returns: + True if successful + + Raises: + ValueError: If template not found + """ + try: + self.table.update_item( + Key={"template_id": template_id}, + UpdateExpression="SET is_active = :inactive, updated_at = :now", + ExpressionAttributeValues={ + ":inactive": False, + ":now": datetime.utcnow().isoformat(), + }, + ConditionExpression=Attr("template_id").exists(), + ) + + logger.info("Template deactivated", template_id=template_id) + return True + + except self.dynamodb.meta.client.exceptions.ConditionalCheckFailedException as e: + logger.error("Template not found for deactivation", template_id=template_id) + raise ValueError(f"Template not found: {template_id}") from e + except Exception as e: + logger.error("Failed to deactivate template", error=str(e), exc_info=True) + raise + + async def activate(self, template_id: str) -> bool: + """ + Activate a template. + + Args: + template_id: Template ID to activate + + Returns: + True if successful + + Raises: + ValueError: If template not found + """ + try: + self.table.update_item( + Key={"template_id": template_id}, + UpdateExpression="SET is_active = :active, updated_at = :now", + ExpressionAttributeValues={ + ":active": True, + ":now": datetime.utcnow().isoformat(), + }, + ConditionExpression=Attr("template_id").exists(), + ) + + logger.info("Template activated", template_id=template_id) + return True + + except self.dynamodb.meta.client.exceptions.ConditionalCheckFailedException as e: + logger.error("Template not found for activation", template_id=template_id) + raise ValueError(f"Template not found: {template_id}") from e + except Exception as e: + logger.error("Failed to activate template", error=str(e), exc_info=True) + raise + + def _validate_interaction_code(self, interaction_code: str) -> None: + """ + Validate interaction code exists in registry. + + Args: + interaction_code: Code to validate + + Raises: + ValueError: If interaction not in registry with helpful message + """ + try: + get_interaction(interaction_code) + except ValueError as e: + logger.error( + "Invalid interaction code for template", + interaction_code=interaction_code, + error=str(e), + ) + raise ValueError( + f"Cannot create/update template for unknown interaction: {e}. " + f"Interaction must be added to INTERACTION_REGISTRY in " + f"coaching/src/core/llm_interactions.py before creating templates." + ) from e + + def _to_dynamodb_item(self, metadata: TemplateMetadata) -> dict[str, Any]: + """ + Convert template metadata entity to DynamoDB item. + + Args: + metadata: Template metadata entity + + Returns: + DynamoDB item dictionary + """ + return { + "template_id": metadata.template_id, + "template_code": metadata.template_code, + "interaction_code": metadata.interaction_code, + "name": metadata.name, + "description": metadata.description, + "s3_bucket": metadata.s3_bucket, + "s3_key": metadata.s3_key, + "version": metadata.version, + "is_active": metadata.is_active, + "created_at": metadata.created_at.isoformat(), + "updated_at": metadata.updated_at.isoformat(), + "created_by": metadata.created_by, + } + + def _from_dynamodb_item(self, item: dict[str, Any]) -> TemplateMetadata: + """ + Convert DynamoDB item to template metadata entity. + + Args: + item: DynamoDB item dictionary + + Returns: + Template metadata entity + """ + return TemplateMetadata( + template_id=item["template_id"], + template_code=item["template_code"], + interaction_code=item["interaction_code"], + name=item["name"], + description=item["description"], + s3_bucket=item["s3_bucket"], + s3_key=item["s3_key"], + version=item["version"], + is_active=item.get("is_active", True), + created_at=datetime.fromisoformat(item["created_at"]), + updated_at=datetime.fromisoformat(item["updated_at"]), + created_by=item["created_by"], + ) + + +__all__ = ["TemplateMetadataRepository"] diff --git a/coaching/src/integration/sql_template/cdata_mcp_client.py b/coaching/src/integration/sql_template/cdata_mcp_client.py index 4d5a1e8b..fe8c61a3 100644 --- a/coaching/src/integration/sql_template/cdata_mcp_client.py +++ b/coaching/src/integration/sql_template/cdata_mcp_client.py @@ -7,6 +7,7 @@ import httpx import structlog + from coaching.src.integration.sql_template.enums import ErrorCode, ErrorStage from coaching.src.integration.sql_template.errors import SqlTemplateGenerationError from coaching.src.integration.sql_template.models import DiscoveredColumn, RequestedDetail diff --git a/coaching/src/integration/sql_template/idempotency.py b/coaching/src/integration/sql_template/idempotency.py index 4b54e02e..07f5f21d 100644 --- a/coaching/src/integration/sql_template/idempotency.py +++ b/coaching/src/integration/sql_template/idempotency.py @@ -6,6 +6,7 @@ from typing import Any, Protocol from botocore.exceptions import ClientError + from coaching.src.integration.sql_template.models import GenerationRecord diff --git a/coaching/src/integration/sql_template/models.py b/coaching/src/integration/sql_template/models.py index ff321a35..332a8dcb 100644 --- a/coaching/src/integration/sql_template/models.py +++ b/coaching/src/integration/sql_template/models.py @@ -6,6 +6,8 @@ from typing import Literal from uuid import UUID +from pydantic import BaseModel, ConfigDict, Field, field_validator + from coaching.src.integration.sql_template.enums import ( AllowedOperator, ErrorCode, @@ -15,7 +17,6 @@ ValidationFailureCode, ValidationMethod, ) -from pydantic import BaseModel, ConfigDict, Field, field_validator class StrictModel(BaseModel): diff --git a/coaching/src/integration/sql_template/service.py b/coaching/src/integration/sql_template/service.py index 7c65af2f..8a01b971 100644 --- a/coaching/src/integration/sql_template/service.py +++ b/coaching/src/integration/sql_template/service.py @@ -9,6 +9,7 @@ from uuid import uuid4 import structlog + from coaching.src.integration.sql_template.cdata_mcp_client import SchemaDiscoveryClient from coaching.src.integration.sql_template.enums import ErrorCode, ErrorStage, GenerationStatus from coaching.src.integration.sql_template.errors import SqlTemplateGenerationError, ValidationError diff --git a/coaching/src/llm/workflow_orchestrator.py b/coaching/src/llm/workflow_orchestrator.py index ff89918a..8a2542d0 100644 --- a/coaching/src/llm/workflow_orchestrator.py +++ b/coaching/src/llm/workflow_orchestrator.py @@ -1,575 +1,576 @@ -""" -Enhanced workflow orchestrator with LangGraph-specific capabilities. - -Extends the base WorkflowOrchestrator with advanced LangGraph features: -- Graph construction utilities -- Advanced state management -- Enhanced workflow execution engine -- Provider integration -""" - -import uuid -from datetime import datetime -from typing import Any, TypedDict - -import structlog -from coaching.src.llm.providers.manager import provider_manager -from coaching.src.workflows.base import WorkflowConfig, WorkflowState, WorkflowStatus, WorkflowType -from coaching.src.workflows.orchestrator import WorkflowOrchestrator - -logger = structlog.get_logger(__name__) - - -class GraphState(TypedDict): - """Enhanced state for LangGraph workflows.""" - - # Core workflow data - workflow_id: str - workflow_type: str - user_id: str - session_id: str | None - - # Conversation and messaging - messages: list[dict[str, Any]] - current_step: str - step_data: dict[str, Any] - - # LLM and provider context - provider_id: str | None - model_config: dict[str, Any] - - # State management - status: str - results: dict[str, Any] - metadata: dict[str, Any] - - # Timing - created_at: str - updated_at: str - - -class LangGraphWorkflowOrchestrator(WorkflowOrchestrator): - """Enhanced workflow orchestrator with LangGraph-specific features.""" - - def __init__(self, cache_service: Any = None) -> None: - """Initialize the LangGraph workflow orchestrator. - - Args: - cache_service: Cache service for state persistence - """ - super().__init__(provider_manager, cache_service) - self._graph_utilities = GraphUtilities() - self._state_manager = AdvancedStateManager(cache_service) - - async def initialize(self) -> None: - """Initialize the orchestrator and provider manager.""" - await provider_manager.initialize() - logger.info("LangGraphWorkflowOrchestrator initialized") - - async def create_workflow_graph( - self, workflow_type: WorkflowType, config: WorkflowConfig | None = None - ) -> Any: - """Create a LangGraph StateGraph for the specified workflow type. - - Args: - workflow_type: Type of workflow to create - config: Optional workflow configuration - - Returns: - Configured StateGraph instance - """ - if workflow_type not in self._workflow_registry: - raise ValueError(f"Workflow type not registered: {workflow_type}") - - workflow_class = self._workflow_registry[workflow_type] - workflow_config = config or WorkflowConfig(workflow_type=workflow_type) - workflow = workflow_class(workflow_config) - - graph: Any = await workflow.build_graph() - return graph - - async def start_workflow( - self, - workflow_type: WorkflowType, - user_id: str, - initial_input: dict[str, Any], - config: WorkflowConfig | None = None, - session_id: str | None = None, - provider_id: str | None = None, - ) -> WorkflowState: - """Start a new LangGraph workflow execution. - - Args: - workflow_type: Type of workflow to start - user_id: User identifier - initial_input: Initial input data - config: Workflow configuration (optional) - session_id: Session identifier (optional) - provider_id: Specific provider to use (optional) - - Returns: - Initial workflow state - """ - if workflow_type not in self._workflow_registry: - raise ValueError(f"Workflow type not registered: {workflow_type}") - - # Create workflow configuration - if config is None: - config = WorkflowConfig(workflow_type=workflow_type) - - # Set provider configuration - if provider_id: - config.custom_config["provider_id"] = provider_id - - # Generate unique workflow ID - workflow_id = str(uuid.uuid4()) - - try: - # Create workflow instance - workflow_class = self._workflow_registry[workflow_type] - workflow = workflow_class(config) - - # Store workflow - self._active_workflows[workflow_id] = workflow - - # Create enhanced GraphState - graph_state = self._create_graph_state( - workflow_id=workflow_id, - workflow_type=workflow_type, - user_id=user_id, - session_id=session_id, - initial_input=initial_input, - provider_id=provider_id, - config=config, - ) - - logger.info( - "Starting LangGraph workflow", - workflow_id=workflow_id, - workflow_type=workflow_type.value, - user_id=user_id, - provider_id=provider_id, - ) - - # Execute workflow using LangGraph - final_state: WorkflowState = await workflow.execute(graph_state) - - # final_state is already a WorkflowState from workflow.execute() - workflow_state = final_state - self._workflow_states[workflow_id] = workflow_state - - # Persist state if configured - if config.enable_checkpoints: - await self._state_manager.save_state(workflow_id, workflow_state) - - logger.info( - "LangGraph workflow started", - workflow_id=workflow_id, - status=( - workflow_state.status.value - if hasattr(workflow_state.status, "value") - else workflow_state.status - ), - ) - return workflow_state - - except Exception as e: - logger.error("LangGraph workflow start failed", workflow_id=workflow_id, error=str(e)) - # Clean up failed workflow - self._active_workflows.pop(workflow_id, None) - raise - - async def continue_workflow( - self, - workflow_id: str, - user_input: dict[str, Any], - provider_id: str | None = None, - ) -> WorkflowState: - """Continue an existing LangGraph workflow with new user input. - - Args: - workflow_id: Workflow identifier - user_input: New user input - provider_id: Optional provider override - - Returns: - Updated workflow state - """ - if workflow_id not in self._active_workflows: - raise KeyError(f"Workflow not found: {workflow_id}") - - workflow = self._active_workflows[workflow_id] - current_state = self._workflow_states[workflow_id] - - if current_state.status not in [WorkflowStatus.WAITING_INPUT, WorkflowStatus.RUNNING]: - raise ValueError(f"Workflow cannot be continued in status: {current_state.status}") - - try: - logger.info( - "Continuing LangGraph workflow", - workflow_id=workflow_id, - current_step=current_state.current_step, - ) - - # Convert current state to GraphState - graph_state = self._workflow_state_to_graph_state(current_state) - - # Add new user input - graph_state["messages"].append( - { - "role": "user", - "content": user_input.get("content", ""), - "timestamp": datetime.utcnow().isoformat(), - } - ) - - # Update provider if specified - if provider_id: - graph_state["provider_id"] = provider_id - - # Resume workflow execution - final_state = await workflow.resume(WorkflowState(**graph_state), user_input) - - # Convert back and store - workflow_state = self._graph_state_to_workflow_state(final_state.model_dump()) - self._workflow_states[workflow_id] = workflow_state - - # Persist state - if workflow.config.enable_checkpoints: - await self._state_manager.save_state(workflow_id, workflow_state) - - logger.info( - "LangGraph workflow continued", - workflow_id=workflow_id, - status=workflow_state.status.value, - step=workflow_state.current_step, - ) - return workflow_state - - except Exception as e: - logger.error( - "LangGraph workflow continuation failed", workflow_id=workflow_id, error=str(e) - ) - # Mark workflow as failed - current_state.status = WorkflowStatus.FAILED - current_state.metadata["error"] = str(e) - self._workflow_states[workflow_id] = current_state - raise - - def _create_graph_state( - self, - workflow_id: str, - workflow_type: WorkflowType, - user_id: str, - session_id: str | None, - initial_input: dict[str, Any], - provider_id: str | None, - config: WorkflowConfig, - ) -> dict[str, Any]: - """Create initial GraphState for LangGraph execution.""" - # Extract analysis type if present (for analysis workflows) - analysis_type = initial_input.get("analysis_type") - - return { - "workflow_id": workflow_id, - "workflow_type": workflow_type.value, - "user_id": user_id, - "session_id": session_id, - "messages": [ - { - "role": "user", - "content": initial_input.get("content", ""), - "timestamp": datetime.utcnow().isoformat(), - } - ], - "analysis_type": analysis_type, # Add analysis_type for analysis workflows - "current_step": "start", - "step_data": {}, - "provider_id": provider_id, - "model_config": { - "temperature": config.temperature, - "max_tokens": config.max_tokens, - **config.custom_config, - }, - "status": WorkflowStatus.RUNNING.value, - "results": {}, - "metadata": { - "workflow_type": workflow_type.value, - "config": config.model_dump(), - }, - "created_at": datetime.utcnow().isoformat(), - "updated_at": datetime.utcnow().isoformat(), - } - - def _workflow_state_to_graph_state(self, state: WorkflowState) -> dict[str, Any]: - """Convert WorkflowState to GraphState format.""" - return { - "workflow_id": state.workflow_id, - "workflow_type": state.workflow_type.value, - "user_id": state.user_id, - "session_id": state.session_id, - "messages": state.conversation_history, - "current_step": state.current_step, - "step_data": state.step_data, - "provider_id": state.metadata.get("provider_id"), - "model_config": state.metadata.get("model_config", {}), - "status": state.status.value, - "results": state.results, - "metadata": state.metadata, - "created_at": state.created_at or datetime.utcnow().isoformat(), - "updated_at": datetime.utcnow().isoformat(), - } - - def _graph_state_to_workflow_state(self, graph_state: dict[str, Any]) -> WorkflowState: - """Convert GraphState to WorkflowState.""" - return WorkflowState( - workflow_id=graph_state["workflow_id"], - workflow_type=WorkflowType(graph_state["workflow_type"]), - status=WorkflowStatus(graph_state["status"]), - user_id=graph_state["user_id"], - session_id=graph_state.get("session_id"), - conversation_history=graph_state.get("messages", []), - current_step=graph_state["current_step"], - step_data=graph_state.get("step_data", {}), - workflow_context={ - "provider_id": graph_state.get("provider_id"), - "model_config": graph_state.get("model_config", {}), - }, - results=graph_state.get("results", {}), - metadata=graph_state.get("metadata", {}), - created_at=graph_state.get("created_at"), - updated_at=graph_state.get("updated_at"), - ) - - -class GraphUtilities: - """Utilities for LangGraph construction and management.""" - - @staticmethod - def create_standard_nodes() -> dict[str, Any]: - """Create standard node functions for common workflow patterns.""" - return { - "greeting": GraphUtilities.greeting_node, - "question_generation": GraphUtilities.question_generation_node, - "response_analysis": GraphUtilities.response_analysis_node, - "insight_extraction": GraphUtilities.insight_extraction_node, - "follow_up": GraphUtilities.follow_up_node, - "completion": GraphUtilities.completion_node, - } - - @staticmethod - async def greeting_node(state: GraphState) -> GraphState: - """Standard greeting node for conversational workflows.""" - state["current_step"] = "greeting" - state["messages"].append( - {"role": "assistant", "content": "Hello! How can I help you today?"} - ) - state["updated_at"] = datetime.utcnow().isoformat() - return state - - @staticmethod - async def question_generation_node(state: GraphState) -> GraphState: - """Generate follow-up questions based on context.""" - provider = provider_manager.get_provider(state.get("provider_id")) - - # Generate contextual question - prompt = "Based on the conversation context, generate a thoughtful follow-up question." - - response = await provider.generate_response( # type: ignore[attr-defined] - messages=state["messages"], system_prompt=prompt, **state.get("model_config", {}) - ) - - state["current_step"] = "question_generation" - state["messages"].append({"role": "assistant", "content": response.content}) - state["updated_at"] = datetime.utcnow().isoformat() - return state - - @staticmethod - async def response_analysis_node(state: GraphState) -> GraphState: - """Analyze user responses for insights.""" - provider = provider_manager.get_provider(state.get("provider_id")) - - # Get latest user message - user_messages = [msg for msg in state["messages"] if msg.get("role") == "user"] - latest_response = user_messages[-1]["content"] if user_messages else "" - - # Analyze response - analysis_prompt = """ - Analyze this user response for: - 1. Key themes and values - 2. Emotional indicators - 3. Areas for deeper exploration - - Return insights as structured analysis. - """ - - analysis = await provider.analyze_text( # type: ignore[attr-defined] - text=latest_response, analysis_prompt=analysis_prompt, **state.get("model_config", {}) - ) - - state["current_step"] = "response_analysis" - state["step_data"]["analysis"] = ( - analysis.model_dump() if hasattr(analysis, "model_dump") else str(analysis) - ) - state["updated_at"] = datetime.utcnow().isoformat() - return state - - @staticmethod - async def insight_extraction_node(state: GraphState) -> GraphState: - """Extract key insights from conversation.""" - insights = [] - - # Extract insights from conversation history - for message in state["messages"]: - if message.get("role") == "user": - content = message.get("content", "") - # Simple insight extraction logic - if any( - keyword in content.lower() - for keyword in ["value", "important", "believe", "feel"] - ): - insights.append(content[:100] + "..." if len(content) > 100 else content) - - state["current_step"] = "insight_extraction" - state["step_data"]["insights"] = insights - state["results"]["extracted_insights"] = insights - state["updated_at"] = datetime.utcnow().isoformat() - return state - - @staticmethod - async def follow_up_node(state: GraphState) -> GraphState: - """Determine if follow-up is needed.""" - # Simple logic to determine if more conversation is needed - message_count = len([msg for msg in state["messages"] if msg.get("role") == "user"]) - - if message_count < 3: - state["status"] = WorkflowStatus.WAITING_INPUT.value - else: - state["status"] = WorkflowStatus.COMPLETED.value - - state["current_step"] = "follow_up" - state["updated_at"] = datetime.utcnow().isoformat() - return state - - @staticmethod - async def completion_node(state: GraphState) -> GraphState: - """Complete the workflow.""" - state["current_step"] = "completion" - state["status"] = WorkflowStatus.COMPLETED.value - state["updated_at"] = datetime.utcnow().isoformat() - - # Add completion message - state["messages"].append( - { - "role": "assistant", - "content": "Thank you for the conversation. Here's what we discovered together.", - } - ) - - return state - - -class AdvancedStateManager: - """Advanced state management for LangGraph workflows.""" - - def __init__(self, cache_service: Any = None) -> None: - """Initialize state manager. - - Args: - cache_service: Cache service for persistence - """ - self.cache_service = cache_service - self._local_state_cache: dict[str, WorkflowState] = {} - - async def save_state(self, workflow_id: str, state: WorkflowState) -> None: - """Save workflow state with persistence. - - Args: - workflow_id: Workflow identifier - state: Workflow state to save - """ - # Store in local cache - self._local_state_cache[workflow_id] = state - - # Persist to external cache if available - if self.cache_service: - try: - await self.cache_service.save_workflow_state(workflow_id, state.model_dump()) - logger.debug("Workflow state persisted", workflow_id=workflow_id) - except Exception as e: - logger.warning( - "Failed to persist workflow state", workflow_id=workflow_id, error=str(e) - ) - - async def load_state(self, workflow_id: str) -> WorkflowState | None: - """Load workflow state from cache. - - Args: - workflow_id: Workflow identifier - - Returns: - Workflow state if found, None otherwise - """ - # Check local cache first - if workflow_id in self._local_state_cache: - return self._local_state_cache[workflow_id] - - # Try external cache - if self.cache_service: - try: - state_data = await self.cache_service.load_workflow_state(workflow_id) - if state_data: - state = WorkflowState(**state_data) - self._local_state_cache[workflow_id] = state - return state - except Exception as e: - logger.warning( - "Failed to load workflow state", workflow_id=workflow_id, error=str(e) - ) - - return None - - async def cleanup_old_states(self, max_age_hours: int = 24) -> int: - """Clean up old workflow states. - - Args: - max_age_hours: Maximum age in hours before cleanup - - Returns: - Number of states cleaned up - """ - cutoff_time = datetime.utcnow().timestamp() - (max_age_hours * 3600) - cleaned_count = 0 - - states_to_remove = [] - for workflow_id, state in self._local_state_cache.items(): - if state.completed_at: - # Handle both ISO format strings and timestamp floats - try: - # Try parsing as timestamp float first - completed_time = float(state.completed_at) - except (ValueError, TypeError): - # Fall back to ISO format parsing - try: - completed_time = datetime.fromisoformat(state.completed_at).timestamp() - except (ValueError, TypeError): - logger.warning( - "Invalid completed_at format", - workflow_id=workflow_id, - completed_at=state.completed_at, - ) - continue - - if completed_time < cutoff_time: - states_to_remove.append(workflow_id) - - for workflow_id in states_to_remove: - self._local_state_cache.pop(workflow_id, None) - cleaned_count += 1 - - logger.info("Cleaned up workflow states", count=cleaned_count) - return cleaned_count - - -# Global enhanced orchestrator instance -langgraph_orchestrator = LangGraphWorkflowOrchestrator() +""" +Enhanced workflow orchestrator with LangGraph-specific capabilities. + +Extends the base WorkflowOrchestrator with advanced LangGraph features: +- Graph construction utilities +- Advanced state management +- Enhanced workflow execution engine +- Provider integration +""" + +import uuid +from datetime import datetime +from typing import Any, TypedDict + +import structlog + +from coaching.src.llm.providers.manager import provider_manager +from coaching.src.workflows.base import WorkflowConfig, WorkflowState, WorkflowStatus, WorkflowType +from coaching.src.workflows.orchestrator import WorkflowOrchestrator + +logger = structlog.get_logger(__name__) + + +class GraphState(TypedDict): + """Enhanced state for LangGraph workflows.""" + + # Core workflow data + workflow_id: str + workflow_type: str + user_id: str + session_id: str | None + + # Conversation and messaging + messages: list[dict[str, Any]] + current_step: str + step_data: dict[str, Any] + + # LLM and provider context + provider_id: str | None + model_config: dict[str, Any] + + # State management + status: str + results: dict[str, Any] + metadata: dict[str, Any] + + # Timing + created_at: str + updated_at: str + + +class LangGraphWorkflowOrchestrator(WorkflowOrchestrator): + """Enhanced workflow orchestrator with LangGraph-specific features.""" + + def __init__(self, cache_service: Any = None) -> None: + """Initialize the LangGraph workflow orchestrator. + + Args: + cache_service: Cache service for state persistence + """ + super().__init__(provider_manager, cache_service) + self._graph_utilities = GraphUtilities() + self._state_manager = AdvancedStateManager(cache_service) + + async def initialize(self) -> None: + """Initialize the orchestrator and provider manager.""" + await provider_manager.initialize() + logger.info("LangGraphWorkflowOrchestrator initialized") + + async def create_workflow_graph( + self, workflow_type: WorkflowType, config: WorkflowConfig | None = None + ) -> Any: + """Create a LangGraph StateGraph for the specified workflow type. + + Args: + workflow_type: Type of workflow to create + config: Optional workflow configuration + + Returns: + Configured StateGraph instance + """ + if workflow_type not in self._workflow_registry: + raise ValueError(f"Workflow type not registered: {workflow_type}") + + workflow_class = self._workflow_registry[workflow_type] + workflow_config = config or WorkflowConfig(workflow_type=workflow_type) + workflow = workflow_class(workflow_config) + + graph: Any = await workflow.build_graph() + return graph + + async def start_workflow( + self, + workflow_type: WorkflowType, + user_id: str, + initial_input: dict[str, Any], + config: WorkflowConfig | None = None, + session_id: str | None = None, + provider_id: str | None = None, + ) -> WorkflowState: + """Start a new LangGraph workflow execution. + + Args: + workflow_type: Type of workflow to start + user_id: User identifier + initial_input: Initial input data + config: Workflow configuration (optional) + session_id: Session identifier (optional) + provider_id: Specific provider to use (optional) + + Returns: + Initial workflow state + """ + if workflow_type not in self._workflow_registry: + raise ValueError(f"Workflow type not registered: {workflow_type}") + + # Create workflow configuration + if config is None: + config = WorkflowConfig(workflow_type=workflow_type) + + # Set provider configuration + if provider_id: + config.custom_config["provider_id"] = provider_id + + # Generate unique workflow ID + workflow_id = str(uuid.uuid4()) + + try: + # Create workflow instance + workflow_class = self._workflow_registry[workflow_type] + workflow = workflow_class(config) + + # Store workflow + self._active_workflows[workflow_id] = workflow + + # Create enhanced GraphState + graph_state = self._create_graph_state( + workflow_id=workflow_id, + workflow_type=workflow_type, + user_id=user_id, + session_id=session_id, + initial_input=initial_input, + provider_id=provider_id, + config=config, + ) + + logger.info( + "Starting LangGraph workflow", + workflow_id=workflow_id, + workflow_type=workflow_type.value, + user_id=user_id, + provider_id=provider_id, + ) + + # Execute workflow using LangGraph + final_state: WorkflowState = await workflow.execute(graph_state) + + # final_state is already a WorkflowState from workflow.execute() + workflow_state = final_state + self._workflow_states[workflow_id] = workflow_state + + # Persist state if configured + if config.enable_checkpoints: + await self._state_manager.save_state(workflow_id, workflow_state) + + logger.info( + "LangGraph workflow started", + workflow_id=workflow_id, + status=( + workflow_state.status.value + if hasattr(workflow_state.status, "value") + else workflow_state.status + ), + ) + return workflow_state + + except Exception as e: + logger.error("LangGraph workflow start failed", workflow_id=workflow_id, error=str(e)) + # Clean up failed workflow + self._active_workflows.pop(workflow_id, None) + raise + + async def continue_workflow( + self, + workflow_id: str, + user_input: dict[str, Any], + provider_id: str | None = None, + ) -> WorkflowState: + """Continue an existing LangGraph workflow with new user input. + + Args: + workflow_id: Workflow identifier + user_input: New user input + provider_id: Optional provider override + + Returns: + Updated workflow state + """ + if workflow_id not in self._active_workflows: + raise KeyError(f"Workflow not found: {workflow_id}") + + workflow = self._active_workflows[workflow_id] + current_state = self._workflow_states[workflow_id] + + if current_state.status not in [WorkflowStatus.WAITING_INPUT, WorkflowStatus.RUNNING]: + raise ValueError(f"Workflow cannot be continued in status: {current_state.status}") + + try: + logger.info( + "Continuing LangGraph workflow", + workflow_id=workflow_id, + current_step=current_state.current_step, + ) + + # Convert current state to GraphState + graph_state = self._workflow_state_to_graph_state(current_state) + + # Add new user input + graph_state["messages"].append( + { + "role": "user", + "content": user_input.get("content", ""), + "timestamp": datetime.utcnow().isoformat(), + } + ) + + # Update provider if specified + if provider_id: + graph_state["provider_id"] = provider_id + + # Resume workflow execution + final_state = await workflow.resume(WorkflowState(**graph_state), user_input) + + # Convert back and store + workflow_state = self._graph_state_to_workflow_state(final_state.model_dump()) + self._workflow_states[workflow_id] = workflow_state + + # Persist state + if workflow.config.enable_checkpoints: + await self._state_manager.save_state(workflow_id, workflow_state) + + logger.info( + "LangGraph workflow continued", + workflow_id=workflow_id, + status=workflow_state.status.value, + step=workflow_state.current_step, + ) + return workflow_state + + except Exception as e: + logger.error( + "LangGraph workflow continuation failed", workflow_id=workflow_id, error=str(e) + ) + # Mark workflow as failed + current_state.status = WorkflowStatus.FAILED + current_state.metadata["error"] = str(e) + self._workflow_states[workflow_id] = current_state + raise + + def _create_graph_state( + self, + workflow_id: str, + workflow_type: WorkflowType, + user_id: str, + session_id: str | None, + initial_input: dict[str, Any], + provider_id: str | None, + config: WorkflowConfig, + ) -> dict[str, Any]: + """Create initial GraphState for LangGraph execution.""" + # Extract analysis type if present (for analysis workflows) + analysis_type = initial_input.get("analysis_type") + + return { + "workflow_id": workflow_id, + "workflow_type": workflow_type.value, + "user_id": user_id, + "session_id": session_id, + "messages": [ + { + "role": "user", + "content": initial_input.get("content", ""), + "timestamp": datetime.utcnow().isoformat(), + } + ], + "analysis_type": analysis_type, # Add analysis_type for analysis workflows + "current_step": "start", + "step_data": {}, + "provider_id": provider_id, + "model_config": { + "temperature": config.temperature, + "max_tokens": config.max_tokens, + **config.custom_config, + }, + "status": WorkflowStatus.RUNNING.value, + "results": {}, + "metadata": { + "workflow_type": workflow_type.value, + "config": config.model_dump(), + }, + "created_at": datetime.utcnow().isoformat(), + "updated_at": datetime.utcnow().isoformat(), + } + + def _workflow_state_to_graph_state(self, state: WorkflowState) -> dict[str, Any]: + """Convert WorkflowState to GraphState format.""" + return { + "workflow_id": state.workflow_id, + "workflow_type": state.workflow_type.value, + "user_id": state.user_id, + "session_id": state.session_id, + "messages": state.conversation_history, + "current_step": state.current_step, + "step_data": state.step_data, + "provider_id": state.metadata.get("provider_id"), + "model_config": state.metadata.get("model_config", {}), + "status": state.status.value, + "results": state.results, + "metadata": state.metadata, + "created_at": state.created_at or datetime.utcnow().isoformat(), + "updated_at": datetime.utcnow().isoformat(), + } + + def _graph_state_to_workflow_state(self, graph_state: dict[str, Any]) -> WorkflowState: + """Convert GraphState to WorkflowState.""" + return WorkflowState( + workflow_id=graph_state["workflow_id"], + workflow_type=WorkflowType(graph_state["workflow_type"]), + status=WorkflowStatus(graph_state["status"]), + user_id=graph_state["user_id"], + session_id=graph_state.get("session_id"), + conversation_history=graph_state.get("messages", []), + current_step=graph_state["current_step"], + step_data=graph_state.get("step_data", {}), + workflow_context={ + "provider_id": graph_state.get("provider_id"), + "model_config": graph_state.get("model_config", {}), + }, + results=graph_state.get("results", {}), + metadata=graph_state.get("metadata", {}), + created_at=graph_state.get("created_at"), + updated_at=graph_state.get("updated_at"), + ) + + +class GraphUtilities: + """Utilities for LangGraph construction and management.""" + + @staticmethod + def create_standard_nodes() -> dict[str, Any]: + """Create standard node functions for common workflow patterns.""" + return { + "greeting": GraphUtilities.greeting_node, + "question_generation": GraphUtilities.question_generation_node, + "response_analysis": GraphUtilities.response_analysis_node, + "insight_extraction": GraphUtilities.insight_extraction_node, + "follow_up": GraphUtilities.follow_up_node, + "completion": GraphUtilities.completion_node, + } + + @staticmethod + async def greeting_node(state: GraphState) -> GraphState: + """Standard greeting node for conversational workflows.""" + state["current_step"] = "greeting" + state["messages"].append( + {"role": "assistant", "content": "Hello! How can I help you today?"} + ) + state["updated_at"] = datetime.utcnow().isoformat() + return state + + @staticmethod + async def question_generation_node(state: GraphState) -> GraphState: + """Generate follow-up questions based on context.""" + provider = provider_manager.get_provider(state.get("provider_id")) + + # Generate contextual question + prompt = "Based on the conversation context, generate a thoughtful follow-up question." + + response = await provider.generate_response( # type: ignore[attr-defined] + messages=state["messages"], system_prompt=prompt, **state.get("model_config", {}) + ) + + state["current_step"] = "question_generation" + state["messages"].append({"role": "assistant", "content": response.content}) + state["updated_at"] = datetime.utcnow().isoformat() + return state + + @staticmethod + async def response_analysis_node(state: GraphState) -> GraphState: + """Analyze user responses for insights.""" + provider = provider_manager.get_provider(state.get("provider_id")) + + # Get latest user message + user_messages = [msg for msg in state["messages"] if msg.get("role") == "user"] + latest_response = user_messages[-1]["content"] if user_messages else "" + + # Analyze response + analysis_prompt = """ + Analyze this user response for: + 1. Key themes and values + 2. Emotional indicators + 3. Areas for deeper exploration + + Return insights as structured analysis. + """ + + analysis = await provider.analyze_text( # type: ignore[attr-defined] + text=latest_response, analysis_prompt=analysis_prompt, **state.get("model_config", {}) + ) + + state["current_step"] = "response_analysis" + state["step_data"]["analysis"] = ( + analysis.model_dump() if hasattr(analysis, "model_dump") else str(analysis) + ) + state["updated_at"] = datetime.utcnow().isoformat() + return state + + @staticmethod + async def insight_extraction_node(state: GraphState) -> GraphState: + """Extract key insights from conversation.""" + insights = [] + + # Extract insights from conversation history + for message in state["messages"]: + if message.get("role") == "user": + content = message.get("content", "") + # Simple insight extraction logic + if any( + keyword in content.lower() + for keyword in ["value", "important", "believe", "feel"] + ): + insights.append(content[:100] + "..." if len(content) > 100 else content) + + state["current_step"] = "insight_extraction" + state["step_data"]["insights"] = insights + state["results"]["extracted_insights"] = insights + state["updated_at"] = datetime.utcnow().isoformat() + return state + + @staticmethod + async def follow_up_node(state: GraphState) -> GraphState: + """Determine if follow-up is needed.""" + # Simple logic to determine if more conversation is needed + message_count = len([msg for msg in state["messages"] if msg.get("role") == "user"]) + + if message_count < 3: + state["status"] = WorkflowStatus.WAITING_INPUT.value + else: + state["status"] = WorkflowStatus.COMPLETED.value + + state["current_step"] = "follow_up" + state["updated_at"] = datetime.utcnow().isoformat() + return state + + @staticmethod + async def completion_node(state: GraphState) -> GraphState: + """Complete the workflow.""" + state["current_step"] = "completion" + state["status"] = WorkflowStatus.COMPLETED.value + state["updated_at"] = datetime.utcnow().isoformat() + + # Add completion message + state["messages"].append( + { + "role": "assistant", + "content": "Thank you for the conversation. Here's what we discovered together.", + } + ) + + return state + + +class AdvancedStateManager: + """Advanced state management for LangGraph workflows.""" + + def __init__(self, cache_service: Any = None) -> None: + """Initialize state manager. + + Args: + cache_service: Cache service for persistence + """ + self.cache_service = cache_service + self._local_state_cache: dict[str, WorkflowState] = {} + + async def save_state(self, workflow_id: str, state: WorkflowState) -> None: + """Save workflow state with persistence. + + Args: + workflow_id: Workflow identifier + state: Workflow state to save + """ + # Store in local cache + self._local_state_cache[workflow_id] = state + + # Persist to external cache if available + if self.cache_service: + try: + await self.cache_service.save_workflow_state(workflow_id, state.model_dump()) + logger.debug("Workflow state persisted", workflow_id=workflow_id) + except Exception as e: + logger.warning( + "Failed to persist workflow state", workflow_id=workflow_id, error=str(e) + ) + + async def load_state(self, workflow_id: str) -> WorkflowState | None: + """Load workflow state from cache. + + Args: + workflow_id: Workflow identifier + + Returns: + Workflow state if found, None otherwise + """ + # Check local cache first + if workflow_id in self._local_state_cache: + return self._local_state_cache[workflow_id] + + # Try external cache + if self.cache_service: + try: + state_data = await self.cache_service.load_workflow_state(workflow_id) + if state_data: + state = WorkflowState(**state_data) + self._local_state_cache[workflow_id] = state + return state + except Exception as e: + logger.warning( + "Failed to load workflow state", workflow_id=workflow_id, error=str(e) + ) + + return None + + async def cleanup_old_states(self, max_age_hours: int = 24) -> int: + """Clean up old workflow states. + + Args: + max_age_hours: Maximum age in hours before cleanup + + Returns: + Number of states cleaned up + """ + cutoff_time = datetime.utcnow().timestamp() - (max_age_hours * 3600) + cleaned_count = 0 + + states_to_remove = [] + for workflow_id, state in self._local_state_cache.items(): + if state.completed_at: + # Handle both ISO format strings and timestamp floats + try: + # Try parsing as timestamp float first + completed_time = float(state.completed_at) + except (ValueError, TypeError): + # Fall back to ISO format parsing + try: + completed_time = datetime.fromisoformat(state.completed_at).timestamp() + except (ValueError, TypeError): + logger.warning( + "Invalid completed_at format", + workflow_id=workflow_id, + completed_at=state.completed_at, + ) + continue + + if completed_time < cutoff_time: + states_to_remove.append(workflow_id) + + for workflow_id in states_to_remove: + self._local_state_cache.pop(workflow_id, None) + cleaned_count += 1 + + logger.info("Cleaned up workflow states", count=cleaned_count) + return cleaned_count + + +# Global enhanced orchestrator instance +langgraph_orchestrator = LangGraphWorkflowOrchestrator() diff --git a/coaching/src/models/admin_topics.py b/coaching/src/models/admin_topics.py index fc5c6ada..acd8f6f2 100644 --- a/coaching/src/models/admin_topics.py +++ b/coaching/src/models/admin_topics.py @@ -3,9 +3,10 @@ from datetime import datetime from typing import Literal -from coaching.src.core.constants import PromptType, TierLevel from pydantic import BaseModel, Field, field_validator +from coaching.src.core.constants import PromptType, TierLevel + # Conversation Config (for coaching topics only) diff --git a/coaching/src/models/conversation.py b/coaching/src/models/conversation.py index 4c018c6b..789c718e 100644 --- a/coaching/src/models/conversation.py +++ b/coaching/src/models/conversation.py @@ -1,128 +1,129 @@ -"""Conversation models.""" - -from datetime import UTC, datetime -from typing import Any - -from coaching.src.core.constants import ConversationStatus, MessageRole -from coaching.src.domain.value_objects.message import Message -from pydantic import BaseModel, Field - - -class ConversationContext(BaseModel): - """Context information for a conversation.""" - - # Phase removed - no longer used - identified_values: list[str] = Field(default_factory=list) - key_insights: list[str] = Field(default_factory=list) - progress_markers: dict[str, Any] = Field(default_factory=dict) - categories_explored: list[str] = Field(default_factory=list) - response_count: int = 0 - deepening_count: int = 0 - - # Multitenant context fields - tenant_id: str | None = None - session_id: str | None = None - business_context: dict[str, Any] = Field(default_factory=dict) - user_preferences: dict[str, Any] = Field(default_factory=dict) - language: str = "en" - - def get(self, key: str, default: Any = None) -> Any: - """Dictionary-like access for backwards compatibility.""" - return getattr(self, key, default) - - -class ConversationSession(BaseModel): - """Session data for active conversation.""" - - conversation_id: str - # Phase removed - no longer used - status: str = "active" - context: dict[str, Any] - message_count: int = 0 - last_activity: datetime = Field(default_factory=datetime.utcnow) - memory_summary: str | None = None - - -class Conversation(BaseModel): - """Complete conversation model.""" - - conversation_id: str - user_id: str - topic: str - status: ConversationStatus = ConversationStatus.ACTIVE - messages: list[Message] = Field(default_factory=list) - context: ConversationContext = Field(default_factory=ConversationContext) - llm_config: dict[str, Any] = Field(default_factory=dict) - created_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) - updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) - completed_at: datetime | None = None - paused_at: datetime | None = None - ttl: int | None = None - - def add_message( - self, - role: MessageRole, - content: str, - metadata: dict[str, Any] | None = None, - tokens: dict[str, int] | None = None, - cost: float | None = None, - model_id: str | None = None, - ) -> None: - """Add a message to the conversation. - - Args: - role: Message role (USER, ASSISTANT, SYSTEM) - content: Message content - metadata: Optional metadata - tokens: Token usage dict with 'input', 'output', 'total' keys - cost: Calculated cost in USD for this message - model_id: LLM model identifier used - """ - message = Message( - role=role, - content=content, - metadata=metadata or {}, - tokens=tokens, - cost=cost, - model_id=model_id, - ) - self.messages.append(message) - self.updated_at = datetime.now(UTC) - - if role == MessageRole.USER: - self.context.response_count += 1 - - def get_conversation_history(self, max_messages: int | None = None) -> list[dict[str, str]]: - """Get conversation history for LLM context.""" - messages = self.messages[-max_messages:] if max_messages else self.messages - return [{"role": msg.role.value, "content": msg.content} for msg in messages] - - def calculate_progress(self) -> float: - """Calculate conversation progress based on message count.""" - total_messages = len(self.messages) - if total_messages == 0: - return 0.0 - # Simple heuristic: typical conversation completes around 12 messages - return min(1.0, total_messages / 12.0) - - def is_active(self) -> bool: - """Check if conversation is active.""" - return self.status == ConversationStatus.ACTIVE - - def mark_completed(self) -> None: - """Mark conversation as completed.""" - self.status = ConversationStatus.COMPLETED - self.completed_at = datetime.now(UTC) - self.updated_at = datetime.now(UTC) - - def mark_paused(self) -> None: - """Mark conversation as paused.""" - self.status = ConversationStatus.PAUSED - self.paused_at = datetime.now(UTC) - self.updated_at = datetime.now(UTC) - - def resume(self) -> None: - """Resume a paused conversation.""" - if self.status == ConversationStatus.PAUSED: - self.status = ConversationStatus.ACTIVE - self.updated_at = datetime.now(UTC) +"""Conversation models.""" + +from datetime import UTC, datetime +from typing import Any + +from pydantic import BaseModel, Field + +from coaching.src.core.constants import ConversationStatus, MessageRole +from coaching.src.domain.value_objects.message import Message + + +class ConversationContext(BaseModel): + """Context information for a conversation.""" + + # Phase removed - no longer used + identified_values: list[str] = Field(default_factory=list) + key_insights: list[str] = Field(default_factory=list) + progress_markers: dict[str, Any] = Field(default_factory=dict) + categories_explored: list[str] = Field(default_factory=list) + response_count: int = 0 + deepening_count: int = 0 + + # Multitenant context fields + tenant_id: str | None = None + session_id: str | None = None + business_context: dict[str, Any] = Field(default_factory=dict) + user_preferences: dict[str, Any] = Field(default_factory=dict) + language: str = "en" + + def get(self, key: str, default: Any = None) -> Any: + """Dictionary-like access for backwards compatibility.""" + return getattr(self, key, default) + + +class ConversationSession(BaseModel): + """Session data for active conversation.""" + + conversation_id: str + # Phase removed - no longer used + status: str = "active" + context: dict[str, Any] + message_count: int = 0 + last_activity: datetime = Field(default_factory=datetime.utcnow) + memory_summary: str | None = None + + +class Conversation(BaseModel): + """Complete conversation model.""" + + conversation_id: str + user_id: str + topic: str + status: ConversationStatus = ConversationStatus.ACTIVE + messages: list[Message] = Field(default_factory=list) + context: ConversationContext = Field(default_factory=ConversationContext) + llm_config: dict[str, Any] = Field(default_factory=dict) + created_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) + updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) + completed_at: datetime | None = None + paused_at: datetime | None = None + ttl: int | None = None + + def add_message( + self, + role: MessageRole, + content: str, + metadata: dict[str, Any] | None = None, + tokens: dict[str, int] | None = None, + cost: float | None = None, + model_id: str | None = None, + ) -> None: + """Add a message to the conversation. + + Args: + role: Message role (USER, ASSISTANT, SYSTEM) + content: Message content + metadata: Optional metadata + tokens: Token usage dict with 'input', 'output', 'total' keys + cost: Calculated cost in USD for this message + model_id: LLM model identifier used + """ + message = Message( + role=role, + content=content, + metadata=metadata or {}, + tokens=tokens, + cost=cost, + model_id=model_id, + ) + self.messages.append(message) + self.updated_at = datetime.now(UTC) + + if role == MessageRole.USER: + self.context.response_count += 1 + + def get_conversation_history(self, max_messages: int | None = None) -> list[dict[str, str]]: + """Get conversation history for LLM context.""" + messages = self.messages[-max_messages:] if max_messages else self.messages + return [{"role": msg.role.value, "content": msg.content} for msg in messages] + + def calculate_progress(self) -> float: + """Calculate conversation progress based on message count.""" + total_messages = len(self.messages) + if total_messages == 0: + return 0.0 + # Simple heuristic: typical conversation completes around 12 messages + return min(1.0, total_messages / 12.0) + + def is_active(self) -> bool: + """Check if conversation is active.""" + return self.status == ConversationStatus.ACTIVE + + def mark_completed(self) -> None: + """Mark conversation as completed.""" + self.status = ConversationStatus.COMPLETED + self.completed_at = datetime.now(UTC) + self.updated_at = datetime.now(UTC) + + def mark_paused(self) -> None: + """Mark conversation as paused.""" + self.status = ConversationStatus.PAUSED + self.paused_at = datetime.now(UTC) + self.updated_at = datetime.now(UTC) + + def resume(self) -> None: + """Resume a paused conversation.""" + if self.status == ConversationStatus.PAUSED: + self.status = ConversationStatus.ACTIVE + self.updated_at = datetime.now(UTC) diff --git a/coaching/src/models/enhanced_responses.py b/coaching/src/models/enhanced_responses.py index 857b5242..fb5d5854 100644 --- a/coaching/src/models/enhanced_responses.py +++ b/coaching/src/models/enhanced_responses.py @@ -1,169 +1,170 @@ -"""Enhanced coaching models with proper Pydantic structures.""" - -from datetime import datetime -from typing import Any - -from coaching.src.core.constants import ConversationStatus -from pydantic import BaseModel, Field -from shared.models.base import BaseResponseModel - - -# Proper message model instead of Dict[str, Any] -class ConversationMessage(BaseModel): - """Individual message in a conversation.""" - - role: str = Field(..., description="Message role (user, assistant, system)") - content: str = Field(..., description="Message content") - timestamp: datetime = Field(..., description="Message timestamp") - metadata: dict[str, Any] = Field( - default_factory=dict, description="Additional message metadata" - ) - message_id: str | None = Field(None, description="Unique message identifier") - - -# Proper context model instead of Dict[str, Any] -class ConversationContext(BaseModel): - """Conversation context data.""" - - user_background: dict[str, Any] = Field( - default_factory=dict, description="User background information" - ) - session_preferences: dict[str, Any] = Field( - default_factory=dict, description="Session preferences" - ) - previous_insights: list[str] = Field( - default_factory=list, description="Previously identified insights" - ) - coaching_style: str | None = Field(None, description="Preferred coaching style") - language: str = Field(default="en", description="Conversation language") - timezone: str = Field(default="UTC", description="User timezone") - - -# Session data model instead of Dict[str, Any] -class SessionData(BaseModel): - """Session-specific data.""" - - ai_model: str | None = Field(None, description="AI model used") - token_count: int | None = Field(None, description="Total tokens used") - conversation_flow: list[str] = Field( - default_factory=list, description="Flow of conversation topics" - ) - key_revelations: list[str] = Field(default_factory=list, description="Key user revelations") - coaching_techniques_used: list[str] = Field( - default_factory=list, description="Coaching techniques applied" - ) - - -# Enhanced response models with proper Pydantic structures -class ConversationResponse(BaseResponseModel): - """Response for conversation initiation.""" - - conversation_id: str = Field(..., description="Unique conversation identifier") - status: ConversationStatus = Field(..., description="Current conversation status") - current_question: str = Field(..., description="Current question being asked") - progress: float = Field(..., ge=0.0, le=1.0, description="Conversation progress (0-1)") - session_data: SessionData | None = Field(None, description="Session-specific data") - - -class MessageResponse(BaseResponseModel): - """Response for a message in conversation.""" - - ai_response: str = Field(..., description="AI coach response") - follow_up_question: str | None = Field(None, description="Follow-up question") - insights: list[str] | None = Field(None, description="Generated insights") - progress: float = Field(..., ge=0.0, le=1.0, description="Updated progress") - is_complete: bool = Field(default=False, description="Whether conversation is complete") - next_steps: list[str] | None = Field(None, description="Suggested next steps") - identified_values: list[str] | None = Field(None, description="Newly identified values") - - -class ConversationSummary(BaseResponseModel): - """Summary of a conversation.""" - - conversation_id: str = Field(..., description="Conversation identifier") - topic: str = Field(..., description="Conversation topic") - status: ConversationStatus = Field(..., description="Current status") - progress: float = Field(..., ge=0.0, le=1.0, description="Progress percentage") - created_at: datetime = Field(..., description="Creation timestamp") - updated_at: datetime = Field(..., description="Last update timestamp") - message_count: int = Field(..., ge=0, description="Number of messages in conversation") - - -class ConversationListResponse(BaseResponseModel): - """Response for listing conversations.""" - - conversations: list[ConversationSummary] = Field( - ..., description="List of conversation summaries" - ) - total: int = Field(..., ge=0, description="Total number of conversations") - page: int = Field(default=1, ge=1, description="Current page number") - page_size: int = Field(default=20, ge=1, le=100, description="Number of items per page") - - -class ConversationDetailResponse(BaseResponseModel): - """Detailed conversation response with proper message structure.""" - - conversation_id: str = Field(..., description="Conversation identifier") - user_id: str = Field(..., description="User identifier") - topic: str = Field(..., description="Conversation topic") - status: ConversationStatus = Field(..., description="Current status") - messages: list[ConversationMessage] = Field(..., description="Conversation messages") - context: ConversationContext = Field(..., description="Conversation context") - progress: float = Field(..., ge=0.0, le=1.0, description="Progress percentage") - created_at: datetime = Field(..., description="Creation timestamp") - updated_at: datetime = Field(..., description="Last update timestamp") - completed_at: datetime | None = Field(None, description="Completion timestamp") - - -class ErrorResponse(BaseResponseModel): - """Error response model.""" - - error: str = Field(..., description="Error message") - error_code: str | None = Field(None, description="Specific error code") - details: dict[str, Any] | None = Field(None, description="Additional error details") - timestamp: datetime = Field(default_factory=datetime.now, description="Error timestamp") - - -# Additional coaching-specific models -class InsightResponse(BaseResponseModel): - """Response for generated insights.""" - - insight_id: str = Field(..., description="Unique insight identifier") - content: str = Field(..., description="Insight content") - category: str = Field(..., description="Insight category") - confidence: float = Field(..., ge=0.0, le=1.0, description="Confidence score") - generated_at: datetime = Field(..., description="Generation timestamp") - - -class SuggestionResponse(BaseResponseModel): - """Response for coaching suggestions.""" - - suggestion_id: str = Field(..., description="Unique suggestion identifier") - title: str = Field(..., description="Suggestion title") - description: str = Field(..., description="Detailed description") - priority: str = Field(..., description="Suggestion priority") - category: str = Field(..., description="Suggestion category") - estimated_time: int | None = Field(None, description="Estimated time to complete (minutes)") - - -class CoachingMetrics(BaseResponseModel): - """Coaching session metrics.""" - - total_conversations: int = Field(..., ge=0, description="Total conversations") - completed_conversations: int = Field(..., ge=0, description="Completed conversations") - average_session_length: float | None = Field( - None, description="Average session length (minutes)" - ) - completion_rate: float = Field(..., ge=0.0, le=1.0, description="Completion rate percentage") - user_satisfaction: float | None = Field( - None, ge=0.0, le=5.0, description="Average user satisfaction (1-5)" - ) - insights_generated: int = Field(..., ge=0, description="Total insights generated") - - -class WebsiteResponse(BaseResponseModel): - """Response for website coaching integration.""" - - widget_config: dict[str, Any] = Field(..., description="Widget configuration") - api_endpoints: dict[str, str] = Field(..., description="Available API endpoints") - authentication_info: dict[str, str] = Field(..., description="Authentication information") +"""Enhanced coaching models with proper Pydantic structures.""" + +from datetime import datetime +from typing import Any + +from pydantic import BaseModel, Field + +from coaching.src.core.constants import ConversationStatus +from shared.models.base import BaseResponseModel + + +# Proper message model instead of Dict[str, Any] +class ConversationMessage(BaseModel): + """Individual message in a conversation.""" + + role: str = Field(..., description="Message role (user, assistant, system)") + content: str = Field(..., description="Message content") + timestamp: datetime = Field(..., description="Message timestamp") + metadata: dict[str, Any] = Field( + default_factory=dict, description="Additional message metadata" + ) + message_id: str | None = Field(None, description="Unique message identifier") + + +# Proper context model instead of Dict[str, Any] +class ConversationContext(BaseModel): + """Conversation context data.""" + + user_background: dict[str, Any] = Field( + default_factory=dict, description="User background information" + ) + session_preferences: dict[str, Any] = Field( + default_factory=dict, description="Session preferences" + ) + previous_insights: list[str] = Field( + default_factory=list, description="Previously identified insights" + ) + coaching_style: str | None = Field(None, description="Preferred coaching style") + language: str = Field(default="en", description="Conversation language") + timezone: str = Field(default="UTC", description="User timezone") + + +# Session data model instead of Dict[str, Any] +class SessionData(BaseModel): + """Session-specific data.""" + + ai_model: str | None = Field(None, description="AI model used") + token_count: int | None = Field(None, description="Total tokens used") + conversation_flow: list[str] = Field( + default_factory=list, description="Flow of conversation topics" + ) + key_revelations: list[str] = Field(default_factory=list, description="Key user revelations") + coaching_techniques_used: list[str] = Field( + default_factory=list, description="Coaching techniques applied" + ) + + +# Enhanced response models with proper Pydantic structures +class ConversationResponse(BaseResponseModel): + """Response for conversation initiation.""" + + conversation_id: str = Field(..., description="Unique conversation identifier") + status: ConversationStatus = Field(..., description="Current conversation status") + current_question: str = Field(..., description="Current question being asked") + progress: float = Field(..., ge=0.0, le=1.0, description="Conversation progress (0-1)") + session_data: SessionData | None = Field(None, description="Session-specific data") + + +class MessageResponse(BaseResponseModel): + """Response for a message in conversation.""" + + ai_response: str = Field(..., description="AI coach response") + follow_up_question: str | None = Field(None, description="Follow-up question") + insights: list[str] | None = Field(None, description="Generated insights") + progress: float = Field(..., ge=0.0, le=1.0, description="Updated progress") + is_complete: bool = Field(default=False, description="Whether conversation is complete") + next_steps: list[str] | None = Field(None, description="Suggested next steps") + identified_values: list[str] | None = Field(None, description="Newly identified values") + + +class ConversationSummary(BaseResponseModel): + """Summary of a conversation.""" + + conversation_id: str = Field(..., description="Conversation identifier") + topic: str = Field(..., description="Conversation topic") + status: ConversationStatus = Field(..., description="Current status") + progress: float = Field(..., ge=0.0, le=1.0, description="Progress percentage") + created_at: datetime = Field(..., description="Creation timestamp") + updated_at: datetime = Field(..., description="Last update timestamp") + message_count: int = Field(..., ge=0, description="Number of messages in conversation") + + +class ConversationListResponse(BaseResponseModel): + """Response for listing conversations.""" + + conversations: list[ConversationSummary] = Field( + ..., description="List of conversation summaries" + ) + total: int = Field(..., ge=0, description="Total number of conversations") + page: int = Field(default=1, ge=1, description="Current page number") + page_size: int = Field(default=20, ge=1, le=100, description="Number of items per page") + + +class ConversationDetailResponse(BaseResponseModel): + """Detailed conversation response with proper message structure.""" + + conversation_id: str = Field(..., description="Conversation identifier") + user_id: str = Field(..., description="User identifier") + topic: str = Field(..., description="Conversation topic") + status: ConversationStatus = Field(..., description="Current status") + messages: list[ConversationMessage] = Field(..., description="Conversation messages") + context: ConversationContext = Field(..., description="Conversation context") + progress: float = Field(..., ge=0.0, le=1.0, description="Progress percentage") + created_at: datetime = Field(..., description="Creation timestamp") + updated_at: datetime = Field(..., description="Last update timestamp") + completed_at: datetime | None = Field(None, description="Completion timestamp") + + +class ErrorResponse(BaseResponseModel): + """Error response model.""" + + error: str = Field(..., description="Error message") + error_code: str | None = Field(None, description="Specific error code") + details: dict[str, Any] | None = Field(None, description="Additional error details") + timestamp: datetime = Field(default_factory=datetime.now, description="Error timestamp") + + +# Additional coaching-specific models +class InsightResponse(BaseResponseModel): + """Response for generated insights.""" + + insight_id: str = Field(..., description="Unique insight identifier") + content: str = Field(..., description="Insight content") + category: str = Field(..., description="Insight category") + confidence: float = Field(..., ge=0.0, le=1.0, description="Confidence score") + generated_at: datetime = Field(..., description="Generation timestamp") + + +class SuggestionResponse(BaseResponseModel): + """Response for coaching suggestions.""" + + suggestion_id: str = Field(..., description="Unique suggestion identifier") + title: str = Field(..., description="Suggestion title") + description: str = Field(..., description="Detailed description") + priority: str = Field(..., description="Suggestion priority") + category: str = Field(..., description="Suggestion category") + estimated_time: int | None = Field(None, description="Estimated time to complete (minutes)") + + +class CoachingMetrics(BaseResponseModel): + """Coaching session metrics.""" + + total_conversations: int = Field(..., ge=0, description="Total conversations") + completed_conversations: int = Field(..., ge=0, description="Completed conversations") + average_session_length: float | None = Field( + None, description="Average session length (minutes)" + ) + completion_rate: float = Field(..., ge=0.0, le=1.0, description="Completion rate percentage") + user_satisfaction: float | None = Field( + None, ge=0.0, le=5.0, description="Average user satisfaction (1-5)" + ) + insights_generated: int = Field(..., ge=0, description="Total insights generated") + + +class WebsiteResponse(BaseResponseModel): + """Response for website coaching integration.""" + + widget_config: dict[str, Any] = Field(..., description="Widget configuration") + api_endpoints: dict[str, str] = Field(..., description="Available API endpoints") + authentication_info: dict[str, str] = Field(..., description="Authentication information") diff --git a/coaching/src/models/requests.py b/coaching/src/models/requests.py index a4d312cc..e30ec8ad 100644 --- a/coaching/src/models/requests.py +++ b/coaching/src/models/requests.py @@ -1,82 +1,83 @@ -"""Request models for API endpoints.""" - -from typing import Any - -from coaching.src.core.constants import CoachingTopic -from pydantic import BaseModel, Field, field_validator - - -class InitiateConversationRequest(BaseModel): - """Request to initiate a new conversation.""" - - user_id: str = Field(..., min_length=1, max_length=128) - topic: CoachingTopic - context: dict[str, Any] | None = Field(default=None) - language: str = Field(default="en", max_length=5) - - @field_validator("user_id") - @classmethod - def validate_user_id(cls, v: str) -> str: - """Validate user ID format.""" - if not v.strip(): - raise ValueError("User ID cannot be empty") - return v.strip() - - -class MessageRequest(BaseModel): - """Request to send a message in a conversation.""" - - user_message: str = Field(..., min_length=1, max_length=4000) - metadata: dict[str, Any] | None = Field(default=None) - - @field_validator("user_message") - @classmethod - def validate_message(cls, v: str) -> str: - """Validate message content.""" - if not v.strip(): - raise ValueError("Message cannot be empty") - return v.strip() - - -class PauseConversationRequest(BaseModel): - """Request to pause a conversation.""" - - reason: str | None = Field(default=None, max_length=500) - - -class ResumeConversationRequest(BaseModel): - """Request to resume a conversation.""" - - continue_from_last: bool = Field(default=True) - - -class CompleteConversationRequest(BaseModel): - """Request to mark a conversation as complete.""" - - feedback: str | None = Field(default=None, max_length=1000) - rating: int | None = Field(default=None, ge=1, le=5) - - -class OnboardingSuggestionRequest(BaseModel): - """Request for generating onboarding suggestions.""" - - kind: str = Field(..., description="Type of suggestion (niche, ica, valueProposition)") - current: str | None = Field(default="", description="Current value to improve upon") - - @field_validator("kind") - @classmethod - def validate_kind(cls, v: str) -> str: - """Validate suggestion kind.""" - if v not in {"niche", "ica", "valueProposition"}: - raise ValueError("kind must be one of: niche, ica, valueProposition") - return v - - -class CoachingRequest(BaseModel): - """Generic request for coaching endpoints.""" - - context: dict[str, Any] | None = Field( - None, description="Additional context for coaching session" - ) - focus_area: str | None = Field(None, description="Specific focus area for coaching") - goals: list[str] | None = Field(None, description="Specific goals to work on") +"""Request models for API endpoints.""" + +from typing import Any + +from pydantic import BaseModel, Field, field_validator + +from coaching.src.core.constants import CoachingTopic + + +class InitiateConversationRequest(BaseModel): + """Request to initiate a new conversation.""" + + user_id: str = Field(..., min_length=1, max_length=128) + topic: CoachingTopic + context: dict[str, Any] | None = Field(default=None) + language: str = Field(default="en", max_length=5) + + @field_validator("user_id") + @classmethod + def validate_user_id(cls, v: str) -> str: + """Validate user ID format.""" + if not v.strip(): + raise ValueError("User ID cannot be empty") + return v.strip() + + +class MessageRequest(BaseModel): + """Request to send a message in a conversation.""" + + user_message: str = Field(..., min_length=1, max_length=4000) + metadata: dict[str, Any] | None = Field(default=None) + + @field_validator("user_message") + @classmethod + def validate_message(cls, v: str) -> str: + """Validate message content.""" + if not v.strip(): + raise ValueError("Message cannot be empty") + return v.strip() + + +class PauseConversationRequest(BaseModel): + """Request to pause a conversation.""" + + reason: str | None = Field(default=None, max_length=500) + + +class ResumeConversationRequest(BaseModel): + """Request to resume a conversation.""" + + continue_from_last: bool = Field(default=True) + + +class CompleteConversationRequest(BaseModel): + """Request to mark a conversation as complete.""" + + feedback: str | None = Field(default=None, max_length=1000) + rating: int | None = Field(default=None, ge=1, le=5) + + +class OnboardingSuggestionRequest(BaseModel): + """Request for generating onboarding suggestions.""" + + kind: str = Field(..., description="Type of suggestion (niche, ica, valueProposition)") + current: str | None = Field(default="", description="Current value to improve upon") + + @field_validator("kind") + @classmethod + def validate_kind(cls, v: str) -> str: + """Validate suggestion kind.""" + if v not in {"niche", "ica", "valueProposition"}: + raise ValueError("kind must be one of: niche, ica, valueProposition") + return v + + +class CoachingRequest(BaseModel): + """Generic request for coaching endpoints.""" + + context: dict[str, Any] | None = Field( + None, description="Additional context for coaching session" + ) + focus_area: str | None = Field(None, description="Specific focus area for coaching") + goals: list[str] | None = Field(None, description="Specific goals to work on") diff --git a/coaching/src/models/responses.py b/coaching/src/models/responses.py index 6d42a135..94fad6a4 100644 --- a/coaching/src/models/responses.py +++ b/coaching/src/models/responses.py @@ -4,9 +4,10 @@ from datetime import UTC, datetime from typing import Annotated, Any, Literal -from coaching.src.core.constants import ConversationPhase, ConversationStatus from pydantic import BaseModel, ConfigDict, Field +from coaching.src.core.constants import ConversationPhase, ConversationStatus + def _default_notification_preferences() -> dict[str, bool]: return {} diff --git a/coaching/src/scripts/seed_parameter_store.py b/coaching/src/scripts/seed_parameter_store.py index 3f4b75c5..ee77f0d3 100644 --- a/coaching/src/scripts/seed_parameter_store.py +++ b/coaching/src/scripts/seed_parameter_store.py @@ -34,6 +34,7 @@ import sys import structlog + from coaching.src.services.parameter_store_service import ParameterStoreService logger = structlog.get_logger() diff --git a/coaching/src/scripts/seed_topics.py b/coaching/src/scripts/seed_topics.py index bd0eacfc..14ae04c1 100644 --- a/coaching/src/scripts/seed_topics.py +++ b/coaching/src/scripts/seed_topics.py @@ -1,403 +1,404 @@ -#!/usr/bin/env python3 -"""Seed all topics from endpoint registry into DynamoDB and S3. - -This script uses the TopicSeedingService to automatically seed all 44 topics -from the endpoint registry and topic seed data into their respective stores. - -Usage: - python -m coaching.src.scripts.seed_topics [options] - -Options: - --force-update Update existing topics with seed data - --dry-run Show what would be done without making changes - --topic-id TOPIC_ID Seed only a specific topic - --validate-only Only run validation without seeding - --deactivate-orphans Deactivate topics that no longer have endpoints - -Examples: - # Seed all new topics (skip existing) - python -m coaching.src.scripts.seed_topics - - # Force update all topics - python -m coaching.src.scripts.seed_topics --force-update - - # Dry run to see what would happen - python -m coaching.src.scripts.seed_topics --force-update --dry-run - - # Seed a specific topic - python -m coaching.src.scripts.seed_topics --topic-id alignment_check --force-update - - # Validate topics without seeding - python -m coaching.src.scripts.seed_topics --validate-only - - # Deactivate orphaned topics - python -m coaching.src.scripts.seed_topics --deactivate-orphans -""" - -import argparse -import asyncio -import sys - -import boto3 -import structlog -from coaching.src.core.config_multitenant import settings -from coaching.src.repositories.topic_repository import TopicRepository -from coaching.src.services.s3_prompt_storage import S3PromptStorage -from coaching.src.services.topic_seeding_service import TopicSeedingService - -# Configure structured logging -logger = structlog.get_logger() - - -# ANSI color codes for terminal output -class Colors: - """ANSI color codes for terminal output.""" - - HEADER = "\033[95m" - OKBLUE = "\033[94m" - OKCYAN = "\033[96m" - OKGREEN = "\033[92m" - WARNING = "\033[93m" - FAIL = "\033[91m" - ENDC = "\033[0m" - BOLD = "\033[1m" - UNDERLINE = "\033[4m" - - -def print_header(text: str) -> None: - """Print colored header text.""" - print(f"\n{Colors.HEADER}{Colors.BOLD}{text}{Colors.ENDC}") - print("=" * len(text)) - - -def print_success(text: str) -> None: - """Print success message.""" - print(f"{Colors.OKGREEN}✅ {text}{Colors.ENDC}") - - -def print_info(text: str) -> None: - """Print info message.""" - print(f"{Colors.OKCYAN}[i] {text}{Colors.ENDC}") - - -def print_warning(text: str) -> None: - """Print warning message.""" - print(f"{Colors.WARNING}⚠️ {text}{Colors.ENDC}") - - -def print_error(text: str) -> None: - """Print error message.""" - print(f"{Colors.FAIL}❌ {text}{Colors.ENDC}") - - -def print_skipped(text: str) -> None: - """Print skipped message.""" - print(f"{Colors.OKBLUE}⏭️ {text}{Colors.ENDC}") - - -async def seed_all_topics( - seeding_service: TopicSeedingService, - *, - force_update: bool = False, - dry_run: bool = False, -) -> int: - """Seed all topics from registry. - - Args: - seeding_service: Topic seeding service instance - force_update: Whether to update existing topics - dry_run: Whether to run in dry-run mode - - Returns: - Exit code (0 for success, 1 for failure) - """ - print_header("Topic Seeding Report") - print_info(f"Mode: {'DRY RUN' if dry_run else 'PRODUCTION'}") - print_info(f"Force Update: {force_update}") - - try: - result = await seeding_service.seed_all_topics( - force_update=force_update, - dry_run=dry_run, - ) - - # Display results - print("\n" + Colors.BOLD + "Results:" + Colors.ENDC) - - if result.created: - print(f"\n{Colors.OKGREEN}Created Topics ({len(result.created)}):{Colors.ENDC}") - for topic_id in sorted(result.created): - print_success(topic_id) - - if result.updated: - print(f"\n{Colors.OKCYAN}Updated Topics ({len(result.updated)}):{Colors.ENDC}") - for topic_id in sorted(result.updated): - print_info(f"{topic_id} (updated configuration)") - - if result.skipped: - print(f"\n{Colors.OKBLUE}Skipped Topics ({len(result.skipped)}):{Colors.ENDC}") - for topic_id in sorted(result.skipped): - print_skipped(f"{topic_id} (already exists, no force-update)") - - if result.deactivated: - print(f"\n{Colors.WARNING}Deactivated Topics ({len(result.deactivated)}):{Colors.ENDC}") - for topic_id in sorted(result.deactivated): - print_warning(f"{topic_id} (no endpoint)") - - if result.errors: - print(f"\n{Colors.FAIL}Errors ({len(result.errors)}):{Colors.ENDC}") - for topic_id, error in result.errors: - print_error(f"{topic_id}: {error}") - - # Summary - print_header("Summary") - print(f"Total Topics: {result.total_processed}") - print(f"Created: {len(result.created)}") - print(f"Updated: {len(result.updated)}") - print(f"Skipped: {len(result.skipped)}") - print(f"Deactivated: {len(result.deactivated)}") - print(f"Errors: {len(result.errors)}") - - if result.is_successful: - print_success("\nSeeding completed successfully") - return 0 - else: - print_error(f"\nSeeding completed with {len(result.errors)} error(s)") - return 1 - - except Exception as e: - logger.error("Fatal error during seeding", error=str(e), exc_info=True) - print_error(f"Fatal error: {e}") - return 1 - - -async def seed_single_topic( - seeding_service: TopicSeedingService, - topic_id: str, - *, - force_update: bool = False, -) -> int: - """Seed a single topic. - - Args: - seeding_service: Topic seeding service instance - topic_id: Topic ID to seed - force_update: Whether to update if exists - - Returns: - Exit code (0 for success, 1 for failure) - """ - print_header(f"Seeding Topic: {topic_id}") - print_info(f"Force Update: {force_update}") - - try: - success = await seeding_service.seed_topic( - topic_id=topic_id, - force_update=force_update, - ) - - if success: - print_success(f"Topic '{topic_id}' seeded successfully") - return 0 - else: - print_error(f"Failed to seed topic '{topic_id}'") - return 1 - - except Exception as e: - logger.error("Error seeding topic", topic_id=topic_id, error=str(e), exc_info=True) - print_error(f"Error: {e}") - return 1 - - -async def validate_topics(seeding_service: TopicSeedingService) -> int: - """Validate topics without seeding. - - Args: - seeding_service: Topic seeding service instance - - Returns: - Exit code (0 for valid, 1 for invalid) - """ - print_header("Topic Validation Report") - - try: - report = await seeding_service.validate_topics() - - if report.missing_topics: - print(f"\n{Colors.FAIL}Missing Topics ({len(report.missing_topics)}):{Colors.ENDC}") - print_error("These endpoints have no seed data:") - for topic_id in sorted(report.missing_topics): - print(f" - {topic_id}") - - if report.orphaned_topics: - print( - f"\n{Colors.WARNING}Orphaned Topics ({len(report.orphaned_topics)}):{Colors.ENDC}" - ) - print_warning("These topics have no endpoints:") - for topic_id in sorted(report.orphaned_topics): - print(f" - {topic_id}") - - if report.missing_prompts: - print( - f"\n{Colors.WARNING}Missing Prompts ({len(report.missing_prompts)}):{Colors.ENDC}" - ) - print_warning("These topics are missing S3 prompts:") - for entry in sorted(report.missing_prompts): - print(f" - {entry}") - - if report.invalid_parameters: - print( - f"\n{Colors.FAIL}Invalid Parameters ({len(report.invalid_parameters)}):{Colors.ENDC}" - ) - print_error("These topics have invalid parameter schemas:") - for entry in sorted(report.invalid_parameters): - print(f" - {entry}") - - # Summary - print_header("Validation Summary") - if report.is_valid: - print_success("All validations passed ✓") - return 0 - else: - print_error("Validation failed ✗") - print(f"Missing Topics: {len(report.missing_topics)}") - print(f"Orphaned Topics: {len(report.orphaned_topics)}") - print(f"Missing Prompts: {len(report.missing_prompts)}") - print(f"Invalid Parameters: {len(report.invalid_parameters)}") - return 1 - - except Exception as e: - logger.error("Error during validation", error=str(e), exc_info=True) - print_error(f"Validation error: {e}") - return 1 - - -async def deactivate_orphans(seeding_service: TopicSeedingService, *, dry_run: bool = False) -> int: - """Deactivate orphaned topics. - - Args: - seeding_service: Topic seeding service instance - dry_run: Whether to run in dry-run mode - - Returns: - Exit code (0 for success, 1 for failure) - """ - print_header("Deactivating Orphaned Topics") - print_info(f"Mode: {'DRY RUN' if dry_run else 'PRODUCTION'}") - - try: - deactivated = await seeding_service.deactivate_orphaned_topics(dry_run=dry_run) - - if deactivated: - print(f"\n{Colors.WARNING}Deactivated Topics ({len(deactivated)}):{Colors.ENDC}") - for topic_id in sorted(deactivated): - print_warning(f"{topic_id}") - print_info(f"\nDeactivated {len(deactivated)} orphaned topic(s)") - else: - print_success("No orphaned topics found") - - return 0 - - except Exception as e: - logger.error("Error deactivating orphans", error=str(e), exc_info=True) - print_error(f"Error: {e}") - return 1 - - -def parse_args() -> argparse.Namespace: - """Parse command line arguments.""" - parser = argparse.ArgumentParser( - description="Seed topics from endpoint registry into DynamoDB and S3", - formatter_class=argparse.RawDescriptionHelpFormatter, - epilog=__doc__, - ) - - parser.add_argument( - "--force-update", - action="store_true", - help="Update existing topics with seed data", - ) - - parser.add_argument( - "--dry-run", - action="store_true", - help="Show what would be done without making changes", - ) - - parser.add_argument( - "--topic-id", - type=str, - help="Seed only a specific topic by ID", - ) - - parser.add_argument( - "--validate-only", - action="store_true", - help="Only run validation without seeding", - ) - - parser.add_argument( - "--deactivate-orphans", - action="store_true", - help="Deactivate topics that no longer have endpoints", - ) - - return parser.parse_args() - - -async def main() -> int: - """Main entry point.""" - args = parse_args() - - # Initialize AWS clients - dynamodb_resource = boto3.resource("dynamodb", region_name=settings.aws_region) - s3_client = boto3.client("s3", region_name=settings.aws_region) - - # Initialize repositories and services - topic_repo = TopicRepository( - dynamodb_resource=dynamodb_resource, - table_name=settings.topics_table, - ) - - s3_storage = S3PromptStorage( - bucket_name=settings.prompts_bucket, - s3_client=s3_client, - ) - - seeding_service = TopicSeedingService( - topic_repo=topic_repo, - s3_storage=s3_storage, - ) - - # Execute requested operation - if args.validate_only: - return await validate_topics(seeding_service) - - elif args.deactivate_orphans: - return await deactivate_orphans(seeding_service, dry_run=args.dry_run) - - elif args.topic_id: - return await seed_single_topic( - seeding_service, - topic_id=args.topic_id, - force_update=args.force_update, - ) - - else: - return await seed_all_topics( - seeding_service, - force_update=args.force_update, - dry_run=args.dry_run, - ) - - -if __name__ == "__main__": - try: - exit_code = asyncio.run(main()) - sys.exit(exit_code) - except KeyboardInterrupt: - print_warning("\nOperation cancelled by user") - sys.exit(130) - except Exception as e: - print_error(f"Unexpected error: {e}") - sys.exit(1) +#!/usr/bin/env python3 +"""Seed all topics from endpoint registry into DynamoDB and S3. + +This script uses the TopicSeedingService to automatically seed all 44 topics +from the endpoint registry and topic seed data into their respective stores. + +Usage: + python -m coaching.src.scripts.seed_topics [options] + +Options: + --force-update Update existing topics with seed data + --dry-run Show what would be done without making changes + --topic-id TOPIC_ID Seed only a specific topic + --validate-only Only run validation without seeding + --deactivate-orphans Deactivate topics that no longer have endpoints + +Examples: + # Seed all new topics (skip existing) + python -m coaching.src.scripts.seed_topics + + # Force update all topics + python -m coaching.src.scripts.seed_topics --force-update + + # Dry run to see what would happen + python -m coaching.src.scripts.seed_topics --force-update --dry-run + + # Seed a specific topic + python -m coaching.src.scripts.seed_topics --topic-id alignment_check --force-update + + # Validate topics without seeding + python -m coaching.src.scripts.seed_topics --validate-only + + # Deactivate orphaned topics + python -m coaching.src.scripts.seed_topics --deactivate-orphans +""" + +import argparse +import asyncio +import sys + +import boto3 +import structlog + +from coaching.src.core.config_multitenant import settings +from coaching.src.repositories.topic_repository import TopicRepository +from coaching.src.services.s3_prompt_storage import S3PromptStorage +from coaching.src.services.topic_seeding_service import TopicSeedingService + +# Configure structured logging +logger = structlog.get_logger() + + +# ANSI color codes for terminal output +class Colors: + """ANSI color codes for terminal output.""" + + HEADER = "\033[95m" + OKBLUE = "\033[94m" + OKCYAN = "\033[96m" + OKGREEN = "\033[92m" + WARNING = "\033[93m" + FAIL = "\033[91m" + ENDC = "\033[0m" + BOLD = "\033[1m" + UNDERLINE = "\033[4m" + + +def print_header(text: str) -> None: + """Print colored header text.""" + print(f"\n{Colors.HEADER}{Colors.BOLD}{text}{Colors.ENDC}") + print("=" * len(text)) + + +def print_success(text: str) -> None: + """Print success message.""" + print(f"{Colors.OKGREEN}✅ {text}{Colors.ENDC}") + + +def print_info(text: str) -> None: + """Print info message.""" + print(f"{Colors.OKCYAN}[i] {text}{Colors.ENDC}") + + +def print_warning(text: str) -> None: + """Print warning message.""" + print(f"{Colors.WARNING}⚠️ {text}{Colors.ENDC}") + + +def print_error(text: str) -> None: + """Print error message.""" + print(f"{Colors.FAIL}❌ {text}{Colors.ENDC}") + + +def print_skipped(text: str) -> None: + """Print skipped message.""" + print(f"{Colors.OKBLUE}⏭️ {text}{Colors.ENDC}") + + +async def seed_all_topics( + seeding_service: TopicSeedingService, + *, + force_update: bool = False, + dry_run: bool = False, +) -> int: + """Seed all topics from registry. + + Args: + seeding_service: Topic seeding service instance + force_update: Whether to update existing topics + dry_run: Whether to run in dry-run mode + + Returns: + Exit code (0 for success, 1 for failure) + """ + print_header("Topic Seeding Report") + print_info(f"Mode: {'DRY RUN' if dry_run else 'PRODUCTION'}") + print_info(f"Force Update: {force_update}") + + try: + result = await seeding_service.seed_all_topics( + force_update=force_update, + dry_run=dry_run, + ) + + # Display results + print("\n" + Colors.BOLD + "Results:" + Colors.ENDC) + + if result.created: + print(f"\n{Colors.OKGREEN}Created Topics ({len(result.created)}):{Colors.ENDC}") + for topic_id in sorted(result.created): + print_success(topic_id) + + if result.updated: + print(f"\n{Colors.OKCYAN}Updated Topics ({len(result.updated)}):{Colors.ENDC}") + for topic_id in sorted(result.updated): + print_info(f"{topic_id} (updated configuration)") + + if result.skipped: + print(f"\n{Colors.OKBLUE}Skipped Topics ({len(result.skipped)}):{Colors.ENDC}") + for topic_id in sorted(result.skipped): + print_skipped(f"{topic_id} (already exists, no force-update)") + + if result.deactivated: + print(f"\n{Colors.WARNING}Deactivated Topics ({len(result.deactivated)}):{Colors.ENDC}") + for topic_id in sorted(result.deactivated): + print_warning(f"{topic_id} (no endpoint)") + + if result.errors: + print(f"\n{Colors.FAIL}Errors ({len(result.errors)}):{Colors.ENDC}") + for topic_id, error in result.errors: + print_error(f"{topic_id}: {error}") + + # Summary + print_header("Summary") + print(f"Total Topics: {result.total_processed}") + print(f"Created: {len(result.created)}") + print(f"Updated: {len(result.updated)}") + print(f"Skipped: {len(result.skipped)}") + print(f"Deactivated: {len(result.deactivated)}") + print(f"Errors: {len(result.errors)}") + + if result.is_successful: + print_success("\nSeeding completed successfully") + return 0 + else: + print_error(f"\nSeeding completed with {len(result.errors)} error(s)") + return 1 + + except Exception as e: + logger.error("Fatal error during seeding", error=str(e), exc_info=True) + print_error(f"Fatal error: {e}") + return 1 + + +async def seed_single_topic( + seeding_service: TopicSeedingService, + topic_id: str, + *, + force_update: bool = False, +) -> int: + """Seed a single topic. + + Args: + seeding_service: Topic seeding service instance + topic_id: Topic ID to seed + force_update: Whether to update if exists + + Returns: + Exit code (0 for success, 1 for failure) + """ + print_header(f"Seeding Topic: {topic_id}") + print_info(f"Force Update: {force_update}") + + try: + success = await seeding_service.seed_topic( + topic_id=topic_id, + force_update=force_update, + ) + + if success: + print_success(f"Topic '{topic_id}' seeded successfully") + return 0 + else: + print_error(f"Failed to seed topic '{topic_id}'") + return 1 + + except Exception as e: + logger.error("Error seeding topic", topic_id=topic_id, error=str(e), exc_info=True) + print_error(f"Error: {e}") + return 1 + + +async def validate_topics(seeding_service: TopicSeedingService) -> int: + """Validate topics without seeding. + + Args: + seeding_service: Topic seeding service instance + + Returns: + Exit code (0 for valid, 1 for invalid) + """ + print_header("Topic Validation Report") + + try: + report = await seeding_service.validate_topics() + + if report.missing_topics: + print(f"\n{Colors.FAIL}Missing Topics ({len(report.missing_topics)}):{Colors.ENDC}") + print_error("These endpoints have no seed data:") + for topic_id in sorted(report.missing_topics): + print(f" - {topic_id}") + + if report.orphaned_topics: + print( + f"\n{Colors.WARNING}Orphaned Topics ({len(report.orphaned_topics)}):{Colors.ENDC}" + ) + print_warning("These topics have no endpoints:") + for topic_id in sorted(report.orphaned_topics): + print(f" - {topic_id}") + + if report.missing_prompts: + print( + f"\n{Colors.WARNING}Missing Prompts ({len(report.missing_prompts)}):{Colors.ENDC}" + ) + print_warning("These topics are missing S3 prompts:") + for entry in sorted(report.missing_prompts): + print(f" - {entry}") + + if report.invalid_parameters: + print( + f"\n{Colors.FAIL}Invalid Parameters ({len(report.invalid_parameters)}):{Colors.ENDC}" + ) + print_error("These topics have invalid parameter schemas:") + for entry in sorted(report.invalid_parameters): + print(f" - {entry}") + + # Summary + print_header("Validation Summary") + if report.is_valid: + print_success("All validations passed ✓") + return 0 + else: + print_error("Validation failed ✗") + print(f"Missing Topics: {len(report.missing_topics)}") + print(f"Orphaned Topics: {len(report.orphaned_topics)}") + print(f"Missing Prompts: {len(report.missing_prompts)}") + print(f"Invalid Parameters: {len(report.invalid_parameters)}") + return 1 + + except Exception as e: + logger.error("Error during validation", error=str(e), exc_info=True) + print_error(f"Validation error: {e}") + return 1 + + +async def deactivate_orphans(seeding_service: TopicSeedingService, *, dry_run: bool = False) -> int: + """Deactivate orphaned topics. + + Args: + seeding_service: Topic seeding service instance + dry_run: Whether to run in dry-run mode + + Returns: + Exit code (0 for success, 1 for failure) + """ + print_header("Deactivating Orphaned Topics") + print_info(f"Mode: {'DRY RUN' if dry_run else 'PRODUCTION'}") + + try: + deactivated = await seeding_service.deactivate_orphaned_topics(dry_run=dry_run) + + if deactivated: + print(f"\n{Colors.WARNING}Deactivated Topics ({len(deactivated)}):{Colors.ENDC}") + for topic_id in sorted(deactivated): + print_warning(f"{topic_id}") + print_info(f"\nDeactivated {len(deactivated)} orphaned topic(s)") + else: + print_success("No orphaned topics found") + + return 0 + + except Exception as e: + logger.error("Error deactivating orphans", error=str(e), exc_info=True) + print_error(f"Error: {e}") + return 1 + + +def parse_args() -> argparse.Namespace: + """Parse command line arguments.""" + parser = argparse.ArgumentParser( + description="Seed topics from endpoint registry into DynamoDB and S3", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=__doc__, + ) + + parser.add_argument( + "--force-update", + action="store_true", + help="Update existing topics with seed data", + ) + + parser.add_argument( + "--dry-run", + action="store_true", + help="Show what would be done without making changes", + ) + + parser.add_argument( + "--topic-id", + type=str, + help="Seed only a specific topic by ID", + ) + + parser.add_argument( + "--validate-only", + action="store_true", + help="Only run validation without seeding", + ) + + parser.add_argument( + "--deactivate-orphans", + action="store_true", + help="Deactivate topics that no longer have endpoints", + ) + + return parser.parse_args() + + +async def main() -> int: + """Main entry point.""" + args = parse_args() + + # Initialize AWS clients + dynamodb_resource = boto3.resource("dynamodb", region_name=settings.aws_region) + s3_client = boto3.client("s3", region_name=settings.aws_region) + + # Initialize repositories and services + topic_repo = TopicRepository( + dynamodb_resource=dynamodb_resource, + table_name=settings.topics_table, + ) + + s3_storage = S3PromptStorage( + bucket_name=settings.prompts_bucket, + s3_client=s3_client, + ) + + seeding_service = TopicSeedingService( + topic_repo=topic_repo, + s3_storage=s3_storage, + ) + + # Execute requested operation + if args.validate_only: + return await validate_topics(seeding_service) + + elif args.deactivate_orphans: + return await deactivate_orphans(seeding_service, dry_run=args.dry_run) + + elif args.topic_id: + return await seed_single_topic( + seeding_service, + topic_id=args.topic_id, + force_update=args.force_update, + ) + + else: + return await seed_all_topics( + seeding_service, + force_update=args.force_update, + dry_run=args.dry_run, + ) + + +if __name__ == "__main__": + try: + exit_code = asyncio.run(main()) + sys.exit(exit_code) + except KeyboardInterrupt: + print_warning("\nOperation cancelled by user") + sys.exit(130) + except Exception as e: + print_error(f"Unexpected error: {e}") + sys.exit(1) diff --git a/coaching/src/services/async_execution_service.py b/coaching/src/services/async_execution_service.py index 5cd837f4..18598b3e 100644 --- a/coaching/src/services/async_execution_service.py +++ b/coaching/src/services/async_execution_service.py @@ -13,6 +13,7 @@ from uuid import uuid4 import structlog + from coaching.src.api.models.ai_job_kickoff import ApiAiJobRequestedDetail from coaching.src.application.ai_engine.unified_ai_engine import ( ParameterValidationError, diff --git a/coaching/src/services/cache_service.py b/coaching/src/services/cache_service.py index de825aa3..2a288766 100644 --- a/coaching/src/services/cache_service.py +++ b/coaching/src/services/cache_service.py @@ -1,132 +1,133 @@ -"""Cache service for managing Redis operations.""" - -import json -from datetime import timedelta -from typing import Any - -import structlog -from coaching.src.core.config import settings - -logger = structlog.get_logger() - - -class CacheService: - """Service for Redis caching operations.""" - - def __init__(self, redis_client: Any, key_prefix: str = ""): - """Initialize cache service. - - Args: - redis_client: Redis client instance - key_prefix: Optional prefix to namespace keys (e.g., per-tenant) - """ - self.redis = redis_client - self.key_prefix = key_prefix or "" - self.default_ttl = timedelta(hours=settings.session_ttl_hours) - - def _k(self, key: str) -> str: - return f"{self.key_prefix}{key}" if self.key_prefix else key - - async def get(self, key: str) -> Any | None: - """Get value from cache. - - Args: - key: Cache key - - Returns: - Cached value or None - """ - try: - value = self.redis.get(self._k(key)) - if value: - return json.loads(value) - return None - except Exception as e: - logger.error("Cache get error", key=key, error=str(e)) - return None - - async def set(self, key: str, value: Any, ttl: timedelta | None = None) -> bool: - """Set value in cache. - - Args: - key: Cache key - value: Value to cache - ttl: Time to live - - Returns: - True if successful - """ - try: - ttl = ttl or self.default_ttl - serialized = json.dumps(value, default=str) - return bool(self.redis.setex(self._k(key), ttl, serialized)) - except Exception as e: - logger.error("Cache set error", key=key, error=str(e)) - return False - - async def delete(self, key: str) -> bool: - """Delete key from cache. - - Args: - key: Cache key - - Returns: - True if successful - """ - try: - return bool(self.redis.delete(self._k(key))) - except Exception as e: - logger.error("Cache delete error", key=key, error=str(e)) - return False - - async def get_conversation_memory(self, conversation_id: str) -> dict[str, Any] | None: - """Get conversation memory from cache. - - Args: - conversation_id: Conversation identifier - - Returns: - Memory data or None - """ - key = f"memory:{conversation_id}" - return await self.get(key) - - async def save_conversation_memory( - self, conversation_id: str, memory_data: dict[str, Any] - ) -> bool: - """Save conversation memory to cache. - - Args: - conversation_id: Conversation identifier - memory_data: Memory data - - Returns: - True if successful - """ - key = f"memory:{conversation_id}" - return await self.set(key, memory_data) - - async def get_session_data(self, conversation_id: str) -> dict[str, Any] | None: - """Get session data from cache. - - Args: - conversation_id: Conversation identifier - - Returns: - Session data or None - """ - key = f"session:{conversation_id}" - return await self.get(key) - - async def save_session_data(self, conversation_id: str, session_data: dict[str, Any]) -> bool: - """Save session data to cache. - - Args: - conversation_id: Conversation identifier - session_data: Session data - - Returns: - True if successful - """ - key = f"session:{conversation_id}" - return await self.set(key, session_data) +"""Cache service for managing Redis operations.""" + +import json +from datetime import timedelta +from typing import Any + +import structlog + +from coaching.src.core.config import settings + +logger = structlog.get_logger() + + +class CacheService: + """Service for Redis caching operations.""" + + def __init__(self, redis_client: Any, key_prefix: str = ""): + """Initialize cache service. + + Args: + redis_client: Redis client instance + key_prefix: Optional prefix to namespace keys (e.g., per-tenant) + """ + self.redis = redis_client + self.key_prefix = key_prefix or "" + self.default_ttl = timedelta(hours=settings.session_ttl_hours) + + def _k(self, key: str) -> str: + return f"{self.key_prefix}{key}" if self.key_prefix else key + + async def get(self, key: str) -> Any | None: + """Get value from cache. + + Args: + key: Cache key + + Returns: + Cached value or None + """ + try: + value = self.redis.get(self._k(key)) + if value: + return json.loads(value) + return None + except Exception as e: + logger.error("Cache get error", key=key, error=str(e)) + return None + + async def set(self, key: str, value: Any, ttl: timedelta | None = None) -> bool: + """Set value in cache. + + Args: + key: Cache key + value: Value to cache + ttl: Time to live + + Returns: + True if successful + """ + try: + ttl = ttl or self.default_ttl + serialized = json.dumps(value, default=str) + return bool(self.redis.setex(self._k(key), ttl, serialized)) + except Exception as e: + logger.error("Cache set error", key=key, error=str(e)) + return False + + async def delete(self, key: str) -> bool: + """Delete key from cache. + + Args: + key: Cache key + + Returns: + True if successful + """ + try: + return bool(self.redis.delete(self._k(key))) + except Exception as e: + logger.error("Cache delete error", key=key, error=str(e)) + return False + + async def get_conversation_memory(self, conversation_id: str) -> dict[str, Any] | None: + """Get conversation memory from cache. + + Args: + conversation_id: Conversation identifier + + Returns: + Memory data or None + """ + key = f"memory:{conversation_id}" + return await self.get(key) + + async def save_conversation_memory( + self, conversation_id: str, memory_data: dict[str, Any] + ) -> bool: + """Save conversation memory to cache. + + Args: + conversation_id: Conversation identifier + memory_data: Memory data + + Returns: + True if successful + """ + key = f"memory:{conversation_id}" + return await self.set(key, memory_data) + + async def get_session_data(self, conversation_id: str) -> dict[str, Any] | None: + """Get session data from cache. + + Args: + conversation_id: Conversation identifier + + Returns: + Session data or None + """ + key = f"session:{conversation_id}" + return await self.get(key) + + async def save_session_data(self, conversation_id: str, session_data: dict[str, Any]) -> bool: + """Save session data to cache. + + Args: + conversation_id: Conversation identifier + session_data: Session data + + Returns: + True if successful + """ + key = f"session:{conversation_id}" + return await self.set(key, session_data) diff --git a/coaching/src/services/coaching_message_job_service.py b/coaching/src/services/coaching_message_job_service.py index 6c48ee73..a8390d2b 100644 --- a/coaching/src/services/coaching_message_job_service.py +++ b/coaching/src/services/coaching_message_job_service.py @@ -10,6 +10,7 @@ from typing import Any import structlog + from coaching.src.core.types import ConversationId, TenantId, UserId from coaching.src.domain.entities.ai_job import AIJob, AIJobErrorCode, AIJobStatus, AIJobType from coaching.src.domain.exceptions.session_exceptions import ( diff --git a/coaching/src/services/coaching_session_service.py b/coaching/src/services/coaching_session_service.py index 167897c1..26110f01 100644 --- a/coaching/src/services/coaching_session_service.py +++ b/coaching/src/services/coaching_session_service.py @@ -19,6 +19,8 @@ from typing import TYPE_CHECKING, Any import structlog +from pydantic import BaseModel, Field + from coaching.src.application.llm_usage.llm_invocation_context import LlmInvocationContext from coaching.src.application.llm_usage.llm_usage_recording_service import LlmUsageRecordingService from coaching.src.core.constants import ConversationStatus, MessageRole, TierLevel, TopicType @@ -42,7 +44,6 @@ SessionNotFoundError, ) from coaching.src.models.coaching_results import get_coaching_result_model -from pydantic import BaseModel, Field if TYPE_CHECKING: from coaching.src.domain.entities.llm_topic import LLMTopic diff --git a/coaching/src/services/conversation_service.py b/coaching/src/services/conversation_service.py index 5e454abd..cc913748 100644 --- a/coaching/src/services/conversation_service.py +++ b/coaching/src/services/conversation_service.py @@ -1,289 +1,290 @@ -"""Main conversation service orchestrating the coaching flow.""" - -from typing import Any - -import structlog -from coaching.src.core.constants import CoachingTopic -from coaching.src.core.exceptions import ConversationNotFoundCompatError, ConversationNotFoundError -from coaching.src.infrastructure.llm.model_pricing import calculate_cost -from coaching.src.models.conversation import Conversation -from coaching.src.models.responses import ( - ConversationListResponse, - ConversationResponse, - ConversationSummary, - MessageResponse, -) -from coaching.src.repositories.conversation_repository import ConversationRepository -from coaching.src.services.cache_service import CacheService -from coaching.src.services.llm_service import LLMService -from coaching.src.services.prompt_service import PromptService - -logger = structlog.get_logger() - - -class ConversationService: - """Service for managing coaching conversations.""" - - def __init__( - self, - conversation_repository: ConversationRepository, - llm_service: LLMService, - cache_service: CacheService, - prompt_service: PromptService, - ): - """Initialize conversation service. - - Args: - conversation_repository: Repository for conversation data - llm_service: LLM service for AI interactions - cache_service: Cache service for session management - prompt_service: Service for prompt templates - """ - self.conversation_repo = conversation_repository - self.llm_service = llm_service - self.cache_service = cache_service - self.prompt_service = prompt_service - - async def initiate_conversation( - self, - user_id: str, - topic: CoachingTopic, - context: dict[str, Any] | None = None, - _language: str = "en", - ) -> ConversationResponse: - """Initiate a new coaching conversation. - - Args: - user_id: User identifier - topic: Coaching topic - context: Optional context data - language: Language code - - Returns: - Conversation response - """ - # Load prompt template - template = await self.prompt_service.get_template(topic.value) - - # Create conversation - conversation = await self.conversation_repo.create( - user_id=user_id, - topic=topic.value, - initial_message=template.initial_message, - llm_config=template.llm_config.model_dump(), - ) - - # Initialize session data - session_data: dict[str, Any] = { - "phase": "introduction", - "context": context or {}, - "message_count": 1, - "template_version": template.version, - } - - await self.cache_service.save_session_data(conversation.conversation_id, session_data) - - return ConversationResponse( - conversation_id=conversation.conversation_id, - status=conversation.status, - current_question=template.initial_message, - progress=conversation.calculate_progress(), - phase=conversation.context.get("current_phase"), - ) - - async def process_message( - self, - conversation_id: str, - user_message: str, - metadata: dict[str, Any] | None = None, - ) -> MessageResponse: - """Process a user message in a conversation. - - Args: - conversation_id: Conversation identifier - user_message: User's message - metadata: Optional metadata - - Returns: - Message response - """ - # Get conversation - conversation = await self.conversation_repo.get(conversation_id) - if not conversation: - raise ConversationNotFoundError(conversation_id) - - # Add user message - await self.conversation_repo.add_message(conversation_id, "user", user_message, metadata) - - # Get AI response - ai_response = await self.llm_service.generate_coaching_response( - conversation_id=conversation_id, - topic=conversation.topic, - user_message=user_message, - conversation_history=conversation.get_conversation_history(), - ) - - # Extract token usage and calculate cost - tokens_dict: dict[str, int] | None = None - cost: float | None = None - - if isinstance(ai_response.token_usage, dict): - tokens_dict = ai_response.token_usage - # Calculate cost from detailed token breakdown - input_tokens = tokens_dict.get("input", tokens_dict.get("prompt_tokens", 0)) - output_tokens = tokens_dict.get("output", tokens_dict.get("completion_tokens", 0)) - cost = calculate_cost(input_tokens, output_tokens, ai_response.model_id) - elif isinstance(ai_response.token_usage, int) and ai_response.token_usage > 0: - # Backward compatibility: if just a total count, estimate 60/40 split - total = ai_response.token_usage - input_tokens = int(total * 0.6) - output_tokens = int(total * 0.4) - tokens_dict = { - "input": input_tokens, - "output": output_tokens, - "total": total, - } - cost = calculate_cost(input_tokens, output_tokens, ai_response.model_id) - - # Add AI response with token tracking - await self.conversation_repo.add_message( - conversation_id, - "assistant", - ai_response.response, - tokens=tokens_dict, - cost=cost, - model_id=ai_response.model_id, - ) - - # Update conversation context - conversation = await self.conversation_repo.get(conversation_id) - if not conversation: - raise ConversationNotFoundCompatError(conversation_id) - - return MessageResponse( - ai_response=ai_response.response, - follow_up_question=ai_response.follow_up_question, - insights=ai_response.insights, - progress=conversation.calculate_progress(), - phase=conversation.context.get("current_phase"), - is_complete=ai_response.is_complete, - ) - - async def get_conversation(self, conversation_id: str) -> Conversation | None: - """Get a conversation by ID. - - Args: - conversation_id: Conversation identifier - - Returns: - Conversation if found - """ - return await self.conversation_repo.get(conversation_id) - - async def pause_conversation(self, conversation_id: str, _reason: str | None = None) -> None: - """Pause a conversation. - - Args: - conversation_id: Conversation identifier - reason: Optional pause reason - """ - conversation = await self.conversation_repo.get(conversation_id) - if not conversation: - raise ConversationNotFoundError(conversation_id) - - conversation.mark_paused() - await self.conversation_repo.update(conversation) - - async def resume_conversation(self, conversation_id: str) -> ConversationResponse: - """Resume a paused conversation. - - Args: - conversation_id: Conversation identifier - - Returns: - Conversation response - """ - conversation = await self.conversation_repo.get(conversation_id) - if not conversation: - raise ConversationNotFoundError(conversation_id) - - conversation.resume() - await self.conversation_repo.update(conversation) - - # Generate resume message - # TODO: Use template for personalized resume message - _ = await self.prompt_service.get_template(conversation.topic) - resume_message = "Welcome back! Let's continue where we left off." - - return ConversationResponse( - conversation_id=conversation.conversation_id, - status=conversation.status, - current_question=resume_message, - progress=conversation.calculate_progress(), - phase=conversation.context.get("current_phase"), - ) - - async def complete_conversation( - self, - conversation_id: str, - _feedback: str | None = None, - _rating: int | None = None, - ) -> None: - """Mark a conversation as complete. - - Args: - conversation_id: Conversation identifier - feedback: Optional user feedback - rating: Optional rating - """ - conversation = await self.conversation_repo.get(conversation_id) - if not conversation: - raise ConversationNotFoundError(conversation_id) - - conversation.mark_completed() - await self.conversation_repo.update(conversation) - - async def abandon_conversation(self, conversation_id: str) -> None: - """Abandon (delete) a conversation. - - Args: - conversation_id: Conversation identifier - """ - await self.conversation_repo.delete(conversation_id) - - async def list_user_conversations( - self, - user_id: str, - page: int = 1, - page_size: int = 20, - status: str | None = None, - ) -> ConversationListResponse: - """List conversations for a user. - - Args: - user_id: User identifier - page: Page number - page_size: Items per page - status: Optional status filter - - Returns: - List of conversations - """ - conversations = await self.conversation_repo.list_by_user( - user_id=user_id, limit=page_size, status=status - ) - - summaries = [ - ConversationSummary( - conversation_id=conv.conversation_id, - topic=conv.topic, - status=conv.status, - progress=conv.calculate_progress(), - created_at=conv.created_at, - updated_at=conv.updated_at, - message_count=len(conv.messages), - ) - for conv in conversations - ] - - return ConversationListResponse(conversations=summaries, total=len(summaries), page=page) +"""Main conversation service orchestrating the coaching flow.""" + +from typing import Any + +import structlog + +from coaching.src.core.constants import CoachingTopic +from coaching.src.core.exceptions import ConversationNotFoundCompatError, ConversationNotFoundError +from coaching.src.infrastructure.llm.model_pricing import calculate_cost +from coaching.src.models.conversation import Conversation +from coaching.src.models.responses import ( + ConversationListResponse, + ConversationResponse, + ConversationSummary, + MessageResponse, +) +from coaching.src.repositories.conversation_repository import ConversationRepository +from coaching.src.services.cache_service import CacheService +from coaching.src.services.llm_service import LLMService +from coaching.src.services.prompt_service import PromptService + +logger = structlog.get_logger() + + +class ConversationService: + """Service for managing coaching conversations.""" + + def __init__( + self, + conversation_repository: ConversationRepository, + llm_service: LLMService, + cache_service: CacheService, + prompt_service: PromptService, + ): + """Initialize conversation service. + + Args: + conversation_repository: Repository for conversation data + llm_service: LLM service for AI interactions + cache_service: Cache service for session management + prompt_service: Service for prompt templates + """ + self.conversation_repo = conversation_repository + self.llm_service = llm_service + self.cache_service = cache_service + self.prompt_service = prompt_service + + async def initiate_conversation( + self, + user_id: str, + topic: CoachingTopic, + context: dict[str, Any] | None = None, + _language: str = "en", + ) -> ConversationResponse: + """Initiate a new coaching conversation. + + Args: + user_id: User identifier + topic: Coaching topic + context: Optional context data + language: Language code + + Returns: + Conversation response + """ + # Load prompt template + template = await self.prompt_service.get_template(topic.value) + + # Create conversation + conversation = await self.conversation_repo.create( + user_id=user_id, + topic=topic.value, + initial_message=template.initial_message, + llm_config=template.llm_config.model_dump(), + ) + + # Initialize session data + session_data: dict[str, Any] = { + "phase": "introduction", + "context": context or {}, + "message_count": 1, + "template_version": template.version, + } + + await self.cache_service.save_session_data(conversation.conversation_id, session_data) + + return ConversationResponse( + conversation_id=conversation.conversation_id, + status=conversation.status, + current_question=template.initial_message, + progress=conversation.calculate_progress(), + phase=conversation.context.get("current_phase"), + ) + + async def process_message( + self, + conversation_id: str, + user_message: str, + metadata: dict[str, Any] | None = None, + ) -> MessageResponse: + """Process a user message in a conversation. + + Args: + conversation_id: Conversation identifier + user_message: User's message + metadata: Optional metadata + + Returns: + Message response + """ + # Get conversation + conversation = await self.conversation_repo.get(conversation_id) + if not conversation: + raise ConversationNotFoundError(conversation_id) + + # Add user message + await self.conversation_repo.add_message(conversation_id, "user", user_message, metadata) + + # Get AI response + ai_response = await self.llm_service.generate_coaching_response( + conversation_id=conversation_id, + topic=conversation.topic, + user_message=user_message, + conversation_history=conversation.get_conversation_history(), + ) + + # Extract token usage and calculate cost + tokens_dict: dict[str, int] | None = None + cost: float | None = None + + if isinstance(ai_response.token_usage, dict): + tokens_dict = ai_response.token_usage + # Calculate cost from detailed token breakdown + input_tokens = tokens_dict.get("input", tokens_dict.get("prompt_tokens", 0)) + output_tokens = tokens_dict.get("output", tokens_dict.get("completion_tokens", 0)) + cost = calculate_cost(input_tokens, output_tokens, ai_response.model_id) + elif isinstance(ai_response.token_usage, int) and ai_response.token_usage > 0: + # Backward compatibility: if just a total count, estimate 60/40 split + total = ai_response.token_usage + input_tokens = int(total * 0.6) + output_tokens = int(total * 0.4) + tokens_dict = { + "input": input_tokens, + "output": output_tokens, + "total": total, + } + cost = calculate_cost(input_tokens, output_tokens, ai_response.model_id) + + # Add AI response with token tracking + await self.conversation_repo.add_message( + conversation_id, + "assistant", + ai_response.response, + tokens=tokens_dict, + cost=cost, + model_id=ai_response.model_id, + ) + + # Update conversation context + conversation = await self.conversation_repo.get(conversation_id) + if not conversation: + raise ConversationNotFoundCompatError(conversation_id) + + return MessageResponse( + ai_response=ai_response.response, + follow_up_question=ai_response.follow_up_question, + insights=ai_response.insights, + progress=conversation.calculate_progress(), + phase=conversation.context.get("current_phase"), + is_complete=ai_response.is_complete, + ) + + async def get_conversation(self, conversation_id: str) -> Conversation | None: + """Get a conversation by ID. + + Args: + conversation_id: Conversation identifier + + Returns: + Conversation if found + """ + return await self.conversation_repo.get(conversation_id) + + async def pause_conversation(self, conversation_id: str, _reason: str | None = None) -> None: + """Pause a conversation. + + Args: + conversation_id: Conversation identifier + reason: Optional pause reason + """ + conversation = await self.conversation_repo.get(conversation_id) + if not conversation: + raise ConversationNotFoundError(conversation_id) + + conversation.mark_paused() + await self.conversation_repo.update(conversation) + + async def resume_conversation(self, conversation_id: str) -> ConversationResponse: + """Resume a paused conversation. + + Args: + conversation_id: Conversation identifier + + Returns: + Conversation response + """ + conversation = await self.conversation_repo.get(conversation_id) + if not conversation: + raise ConversationNotFoundError(conversation_id) + + conversation.resume() + await self.conversation_repo.update(conversation) + + # Generate resume message + # TODO: Use template for personalized resume message + _ = await self.prompt_service.get_template(conversation.topic) + resume_message = "Welcome back! Let's continue where we left off." + + return ConversationResponse( + conversation_id=conversation.conversation_id, + status=conversation.status, + current_question=resume_message, + progress=conversation.calculate_progress(), + phase=conversation.context.get("current_phase"), + ) + + async def complete_conversation( + self, + conversation_id: str, + _feedback: str | None = None, + _rating: int | None = None, + ) -> None: + """Mark a conversation as complete. + + Args: + conversation_id: Conversation identifier + feedback: Optional user feedback + rating: Optional rating + """ + conversation = await self.conversation_repo.get(conversation_id) + if not conversation: + raise ConversationNotFoundError(conversation_id) + + conversation.mark_completed() + await self.conversation_repo.update(conversation) + + async def abandon_conversation(self, conversation_id: str) -> None: + """Abandon (delete) a conversation. + + Args: + conversation_id: Conversation identifier + """ + await self.conversation_repo.delete(conversation_id) + + async def list_user_conversations( + self, + user_id: str, + page: int = 1, + page_size: int = 20, + status: str | None = None, + ) -> ConversationListResponse: + """List conversations for a user. + + Args: + user_id: User identifier + page: Page number + page_size: Items per page + status: Optional status filter + + Returns: + List of conversations + """ + conversations = await self.conversation_repo.list_by_user( + user_id=user_id, limit=page_size, status=status + ) + + summaries = [ + ConversationSummary( + conversation_id=conv.conversation_id, + topic=conv.topic, + status=conv.status, + progress=conv.calculate_progress(), + created_at=conv.created_at, + updated_at=conv.updated_at, + message_count=len(conv.messages), + ) + for conv in conversations + ] + + return ConversationListResponse(conversations=summaries, total=len(summaries), page=page) diff --git a/coaching/src/services/insights_service.py b/coaching/src/services/insights_service.py index 540cc0de..b4f6104b 100644 --- a/coaching/src/services/insights_service.py +++ b/coaching/src/services/insights_service.py @@ -7,6 +7,7 @@ from typing import Any import structlog + from coaching.src.infrastructure.external.business_api_client import BusinessApiClient from coaching.src.infrastructure.repositories.dynamodb_conversation_repository import ( DynamoDBConversationRepository, diff --git a/coaching/src/services/llm_service.py b/coaching/src/services/llm_service.py index 5eb04a63..4229d070 100644 --- a/coaching/src/services/llm_service.py +++ b/coaching/src/services/llm_service.py @@ -4,6 +4,7 @@ from typing import Any import structlog + from coaching.src.core.llm_models import DEFAULT_MODEL_ID from coaching.src.llm.providers.manager import ProviderManager from coaching.src.services.llm_service_adapter import LLMServiceAdapter diff --git a/coaching/src/services/llm_service_adapter.py b/coaching/src/services/llm_service_adapter.py index 3108e8f2..41891f21 100644 --- a/coaching/src/services/llm_service_adapter.py +++ b/coaching/src/services/llm_service_adapter.py @@ -9,6 +9,7 @@ from typing import Any, cast import structlog + from coaching.src.core.llm_models import DEFAULT_MODEL_ID from coaching.src.llm.providers.manager import ProviderManager from coaching.src.workflows.base import WorkflowState, WorkflowType diff --git a/coaching/src/services/llm_template_service.py b/coaching/src/services/llm_template_service.py index cc7ddec4..2a588669 100644 --- a/coaching/src/services/llm_template_service.py +++ b/coaching/src/services/llm_template_service.py @@ -1,382 +1,383 @@ -"""Service for managing LLM prompt templates with S3 integration. - -This service handles template retrieval, rendering, and caching, integrating -template metadata from DynamoDB with actual template content from S3. -""" - -from datetime import timedelta -from typing import Any, cast - -import structlog -from botocore.exceptions import ClientError -from coaching.src.domain.entities.llm_config.template_metadata import TemplateMetadata -from coaching.src.infrastructure.repositories.llm_config.template_metadata_repository import ( - TemplateMetadataRepository, -) -from coaching.src.services.cache_service import CacheService -from jinja2 import Template as Jinja2Template -from jinja2 import TemplateSyntaxError as Jinja2SyntaxError - -logger = structlog.get_logger() - - -class TemplateNotFoundError(Exception): - """Raised when template cannot be found.""" - - def __init__(self, template_id: str): - """Initialize error with details.""" - self.template_id = template_id - super().__init__(f"Template not found: {template_id}") - - -class TemplateRenderError(Exception): - """Raised when template rendering fails.""" - - def __init__(self, template_id: str, error: str): - """Initialize error with details.""" - self.template_id = template_id - super().__init__(f"Template rendering failed for {template_id}: {error}") - - -class InvalidTemplateSyntaxError(Exception): - """Raised when template has invalid syntax.""" - - def __init__(self, template_id: str, error: str): - """Initialize error with details.""" - self.template_id = template_id - super().__init__(f"Template syntax error in {template_id}: {error}") - - -class LLMTemplateService: - """ - Service for managing LLM prompt templates. - - Integrates template metadata (DynamoDB) with template content (S3): - 1. Retrieve metadata from TemplateMetadataRepository - 2. Fetch actual template content from S3 - 3. Render templates with Jinja2 - 4. Cache rendered templates for performance - 5. Validate template syntax - - Design: - - Application Service Layer (Clean Architecture) - - Orchestrates metadata repository and S3 access - - Implements template rendering logic - - Caches templates (10-minute TTL for content, 5-minute for rendered) - """ - - CONTENT_CACHE_TTL = timedelta(minutes=10) # 10 minutes for raw content - RENDERED_CACHE_TTL = timedelta(minutes=5) # 5 minutes for rendered templates - - def __init__( - self, - template_repository: TemplateMetadataRepository, - s3_client: Any, - cache_service: CacheService | None = None, - ): - """ - Initialize template service. - - Args: - template_repository: Repository for template metadata - s3_client: Boto3 S3 client for fetching template content - cache_service: Optional cache service for performance - """ - self.repository = template_repository - self.s3_client = s3_client - self.cache = cache_service - logger.info("LLM template service initialized") - - async def get_template_by_id(self, template_id: str) -> tuple[TemplateMetadata, str]: - """ - Get template metadata and content by ID. - - Args: - template_id: Template identifier - - Returns: - Tuple of (metadata, content) - - Raises: - TemplateNotFoundError: If template not found - """ - logger.debug("Getting template by ID", template_id=template_id) - - # Get metadata - metadata = await self.repository.get_by_id(template_id) - if not metadata: - raise TemplateNotFoundError(template_id) - - # Get content from S3 - content = await self._fetch_template_content(metadata) - - logger.info( - "Template retrieved", - template_id=template_id, - interaction_code=metadata.interaction_code, - ) - - return metadata, content - - async def get_active_template_for_interaction( - self, interaction_code: str - ) -> tuple[TemplateMetadata, str]: - """ - Get active template for an interaction. - - Args: - interaction_code: Interaction code - - Returns: - Tuple of (metadata, content) - - Raises: - TemplateNotFoundError: If no active template found - """ - logger.debug( - "Getting active template for interaction", - interaction_code=interaction_code, - ) - - metadata = await self.repository.get_active_for_interaction(interaction_code) - if not metadata: - raise TemplateNotFoundError(f"interaction:{interaction_code}") - - content = await self._fetch_template_content(metadata) - - logger.info( - "Active template retrieved", - interaction_code=interaction_code, - template_id=metadata.template_id, - ) - - return metadata, content - - async def render_template( - self, - template_id: str, - parameters: dict[str, Any], - ) -> str: - """ - Render template with provided parameters. - - Args: - template_id: Template identifier - parameters: Parameters to inject into template - - Returns: - Rendered template string - - Raises: - TemplateNotFoundError: If template not found - TemplateRenderError: If rendering fails - """ - # Check cache for rendered template - if self.cache: - cache_key = self._get_rendered_cache_key(template_id, parameters) - cached = await self.cache.get(cache_key) - if cached and isinstance(cached, str): - logger.debug("Rendered template from cache", template_id=template_id) - assert isinstance(cached, str) # Type narrowing for mypy - return cached - - logger.debug("Rendering template", template_id=template_id) - - # Get template - metadata, content = await self.get_template_by_id(template_id) - - # Validate parameters match interaction requirements - expected_params = metadata.get_parameters() - provided_params = set(parameters.keys()) - required_params = set(expected_params["required"]) - - # Check missing required parameters - missing = required_params - provided_params - if missing: - raise TemplateRenderError( - template_id, - f"Missing required parameters: {sorted(missing)}", - ) - - try: - # Render with Jinja2 - jinja_template = Jinja2Template(content) - rendered = jinja_template.render(**parameters) - - # Cache rendered result - if self.cache: - cache_key = self._get_rendered_cache_key(template_id, parameters) - await self.cache.set( - cache_key, - rendered, - ttl=self.RENDERED_CACHE_TTL, - ) - - logger.info( - "Template rendered successfully", - template_id=template_id, - param_count=len(parameters), - ) - - return rendered - - except Jinja2SyntaxError as e: - logger.error( - "Template syntax error", - template_id=template_id, - error=str(e), - ) - raise InvalidTemplateSyntaxError(template_id, str(e)) from e - except Exception as e: - logger.error( - "Template rendering failed", - template_id=template_id, - error=str(e), - exc_info=True, - ) - raise TemplateRenderError(template_id, str(e)) from e - - async def validate_template_syntax(self, template_id: str) -> bool: - """ - Validate template has valid Jinja2 syntax. - - Args: - template_id: Template identifier - - Returns: - True if syntax valid - - Raises: - TemplateNotFoundError: If template not found - InvalidTemplateSyntaxError: If syntax invalid - """ - logger.debug("Validating template syntax", template_id=template_id) - - _, content = await self.get_template_by_id(template_id) - - try: - # Try to parse template - Jinja2Template(content) - logger.debug("Template syntax valid", template_id=template_id) - return True - except Jinja2SyntaxError as e: - logger.error( - "Template syntax validation failed", - template_id=template_id, - error=str(e), - ) - raise InvalidTemplateSyntaxError(template_id, str(e)) from e - - async def invalidate_cache(self, template_id: str) -> None: - """ - Invalidate all cached data for a template. - - Args: - template_id: Template identifier - """ - if not self.cache: - return - - # Invalidate content cache - content_key = self._get_content_cache_key(template_id) - await self.cache.delete(content_key) - - logger.debug("Template cache invalidated", template_id=template_id) - - async def _fetch_template_content(self, metadata: TemplateMetadata) -> str: - """ - Fetch template content from S3. - - Args: - metadata: Template metadata with S3 location - - Returns: - Template content string - - Raises: - TemplateNotFoundError: If S3 object not found - """ - template_id = metadata.template_id - - # Check cache first - if self.cache: - cache_key = self._get_content_cache_key(template_id) - cached = await self.cache.get(cache_key) - if cached and isinstance(cached, str): - logger.debug("Template content from cache", template_id=template_id) - assert isinstance(cached, str) # Type narrowing for mypy - return cached - - logger.debug( - "Fetching template from S3", - template_id=template_id, - s3_location=metadata.get_s3_location(), - ) - - try: - response = self.s3_client.get_object( - Bucket=metadata.s3_bucket, - Key=metadata.s3_key, - ) - content = cast(str, response["Body"].read().decode("utf-8")) - - # Cache content - if self.cache: - cache_key = self._get_content_cache_key(template_id) - await self.cache.set( - cache_key, - content, - ttl=self.CONTENT_CACHE_TTL, - ) - - logger.debug( - "Template content fetched", - template_id=template_id, - content_length=len(content), - ) - - return content - - except ClientError as e: - error_code = e.response.get("Error", {}).get("Code") - if error_code == "NoSuchKey": - logger.error( - "Template content not found in S3", - template_id=template_id, - s3_location=metadata.get_s3_location(), - ) - raise TemplateNotFoundError(template_id) from e - - logger.error( - "S3 error fetching template", - template_id=template_id, - error=str(e), - exc_info=True, - ) - raise - except Exception as e: - logger.error( - "Failed to fetch template content", - template_id=template_id, - error=str(e), - exc_info=True, - ) - raise - - def _get_content_cache_key(self, template_id: str) -> str: - """Generate cache key for template content.""" - return f"template_content:{template_id}" - - def _get_rendered_cache_key(self, template_id: str, parameters: dict[str, Any]) -> str: - """Generate cache key for rendered template.""" - # Create deterministic hash of parameters - param_str = ":".join(f"{k}={v}" for k, v in sorted(parameters.items())) - return f"template_rendered:{template_id}:{hash(param_str)}" - - -__all__ = [ - "InvalidTemplateSyntaxError", - "LLMTemplateService", - "TemplateNotFoundError", - "TemplateRenderError", -] +"""Service for managing LLM prompt templates with S3 integration. + +This service handles template retrieval, rendering, and caching, integrating +template metadata from DynamoDB with actual template content from S3. +""" + +from datetime import timedelta +from typing import Any, cast + +import structlog +from botocore.exceptions import ClientError +from jinja2 import Template as Jinja2Template +from jinja2 import TemplateSyntaxError as Jinja2SyntaxError + +from coaching.src.domain.entities.llm_config.template_metadata import TemplateMetadata +from coaching.src.infrastructure.repositories.llm_config.template_metadata_repository import ( + TemplateMetadataRepository, +) +from coaching.src.services.cache_service import CacheService + +logger = structlog.get_logger() + + +class TemplateNotFoundError(Exception): + """Raised when template cannot be found.""" + + def __init__(self, template_id: str): + """Initialize error with details.""" + self.template_id = template_id + super().__init__(f"Template not found: {template_id}") + + +class TemplateRenderError(Exception): + """Raised when template rendering fails.""" + + def __init__(self, template_id: str, error: str): + """Initialize error with details.""" + self.template_id = template_id + super().__init__(f"Template rendering failed for {template_id}: {error}") + + +class InvalidTemplateSyntaxError(Exception): + """Raised when template has invalid syntax.""" + + def __init__(self, template_id: str, error: str): + """Initialize error with details.""" + self.template_id = template_id + super().__init__(f"Template syntax error in {template_id}: {error}") + + +class LLMTemplateService: + """ + Service for managing LLM prompt templates. + + Integrates template metadata (DynamoDB) with template content (S3): + 1. Retrieve metadata from TemplateMetadataRepository + 2. Fetch actual template content from S3 + 3. Render templates with Jinja2 + 4. Cache rendered templates for performance + 5. Validate template syntax + + Design: + - Application Service Layer (Clean Architecture) + - Orchestrates metadata repository and S3 access + - Implements template rendering logic + - Caches templates (10-minute TTL for content, 5-minute for rendered) + """ + + CONTENT_CACHE_TTL = timedelta(minutes=10) # 10 minutes for raw content + RENDERED_CACHE_TTL = timedelta(minutes=5) # 5 minutes for rendered templates + + def __init__( + self, + template_repository: TemplateMetadataRepository, + s3_client: Any, + cache_service: CacheService | None = None, + ): + """ + Initialize template service. + + Args: + template_repository: Repository for template metadata + s3_client: Boto3 S3 client for fetching template content + cache_service: Optional cache service for performance + """ + self.repository = template_repository + self.s3_client = s3_client + self.cache = cache_service + logger.info("LLM template service initialized") + + async def get_template_by_id(self, template_id: str) -> tuple[TemplateMetadata, str]: + """ + Get template metadata and content by ID. + + Args: + template_id: Template identifier + + Returns: + Tuple of (metadata, content) + + Raises: + TemplateNotFoundError: If template not found + """ + logger.debug("Getting template by ID", template_id=template_id) + + # Get metadata + metadata = await self.repository.get_by_id(template_id) + if not metadata: + raise TemplateNotFoundError(template_id) + + # Get content from S3 + content = await self._fetch_template_content(metadata) + + logger.info( + "Template retrieved", + template_id=template_id, + interaction_code=metadata.interaction_code, + ) + + return metadata, content + + async def get_active_template_for_interaction( + self, interaction_code: str + ) -> tuple[TemplateMetadata, str]: + """ + Get active template for an interaction. + + Args: + interaction_code: Interaction code + + Returns: + Tuple of (metadata, content) + + Raises: + TemplateNotFoundError: If no active template found + """ + logger.debug( + "Getting active template for interaction", + interaction_code=interaction_code, + ) + + metadata = await self.repository.get_active_for_interaction(interaction_code) + if not metadata: + raise TemplateNotFoundError(f"interaction:{interaction_code}") + + content = await self._fetch_template_content(metadata) + + logger.info( + "Active template retrieved", + interaction_code=interaction_code, + template_id=metadata.template_id, + ) + + return metadata, content + + async def render_template( + self, + template_id: str, + parameters: dict[str, Any], + ) -> str: + """ + Render template with provided parameters. + + Args: + template_id: Template identifier + parameters: Parameters to inject into template + + Returns: + Rendered template string + + Raises: + TemplateNotFoundError: If template not found + TemplateRenderError: If rendering fails + """ + # Check cache for rendered template + if self.cache: + cache_key = self._get_rendered_cache_key(template_id, parameters) + cached = await self.cache.get(cache_key) + if cached and isinstance(cached, str): + logger.debug("Rendered template from cache", template_id=template_id) + assert isinstance(cached, str) # Type narrowing for mypy + return cached + + logger.debug("Rendering template", template_id=template_id) + + # Get template + metadata, content = await self.get_template_by_id(template_id) + + # Validate parameters match interaction requirements + expected_params = metadata.get_parameters() + provided_params = set(parameters.keys()) + required_params = set(expected_params["required"]) + + # Check missing required parameters + missing = required_params - provided_params + if missing: + raise TemplateRenderError( + template_id, + f"Missing required parameters: {sorted(missing)}", + ) + + try: + # Render with Jinja2 + jinja_template = Jinja2Template(content) + rendered = jinja_template.render(**parameters) + + # Cache rendered result + if self.cache: + cache_key = self._get_rendered_cache_key(template_id, parameters) + await self.cache.set( + cache_key, + rendered, + ttl=self.RENDERED_CACHE_TTL, + ) + + logger.info( + "Template rendered successfully", + template_id=template_id, + param_count=len(parameters), + ) + + return rendered + + except Jinja2SyntaxError as e: + logger.error( + "Template syntax error", + template_id=template_id, + error=str(e), + ) + raise InvalidTemplateSyntaxError(template_id, str(e)) from e + except Exception as e: + logger.error( + "Template rendering failed", + template_id=template_id, + error=str(e), + exc_info=True, + ) + raise TemplateRenderError(template_id, str(e)) from e + + async def validate_template_syntax(self, template_id: str) -> bool: + """ + Validate template has valid Jinja2 syntax. + + Args: + template_id: Template identifier + + Returns: + True if syntax valid + + Raises: + TemplateNotFoundError: If template not found + InvalidTemplateSyntaxError: If syntax invalid + """ + logger.debug("Validating template syntax", template_id=template_id) + + _, content = await self.get_template_by_id(template_id) + + try: + # Try to parse template + Jinja2Template(content) + logger.debug("Template syntax valid", template_id=template_id) + return True + except Jinja2SyntaxError as e: + logger.error( + "Template syntax validation failed", + template_id=template_id, + error=str(e), + ) + raise InvalidTemplateSyntaxError(template_id, str(e)) from e + + async def invalidate_cache(self, template_id: str) -> None: + """ + Invalidate all cached data for a template. + + Args: + template_id: Template identifier + """ + if not self.cache: + return + + # Invalidate content cache + content_key = self._get_content_cache_key(template_id) + await self.cache.delete(content_key) + + logger.debug("Template cache invalidated", template_id=template_id) + + async def _fetch_template_content(self, metadata: TemplateMetadata) -> str: + """ + Fetch template content from S3. + + Args: + metadata: Template metadata with S3 location + + Returns: + Template content string + + Raises: + TemplateNotFoundError: If S3 object not found + """ + template_id = metadata.template_id + + # Check cache first + if self.cache: + cache_key = self._get_content_cache_key(template_id) + cached = await self.cache.get(cache_key) + if cached and isinstance(cached, str): + logger.debug("Template content from cache", template_id=template_id) + assert isinstance(cached, str) # Type narrowing for mypy + return cached + + logger.debug( + "Fetching template from S3", + template_id=template_id, + s3_location=metadata.get_s3_location(), + ) + + try: + response = self.s3_client.get_object( + Bucket=metadata.s3_bucket, + Key=metadata.s3_key, + ) + content = cast(str, response["Body"].read().decode("utf-8")) + + # Cache content + if self.cache: + cache_key = self._get_content_cache_key(template_id) + await self.cache.set( + cache_key, + content, + ttl=self.CONTENT_CACHE_TTL, + ) + + logger.debug( + "Template content fetched", + template_id=template_id, + content_length=len(content), + ) + + return content + + except ClientError as e: + error_code = e.response.get("Error", {}).get("Code") + if error_code == "NoSuchKey": + logger.error( + "Template content not found in S3", + template_id=template_id, + s3_location=metadata.get_s3_location(), + ) + raise TemplateNotFoundError(template_id) from e + + logger.error( + "S3 error fetching template", + template_id=template_id, + error=str(e), + exc_info=True, + ) + raise + except Exception as e: + logger.error( + "Failed to fetch template content", + template_id=template_id, + error=str(e), + exc_info=True, + ) + raise + + def _get_content_cache_key(self, template_id: str) -> str: + """Generate cache key for template content.""" + return f"template_content:{template_id}" + + def _get_rendered_cache_key(self, template_id: str, parameters: dict[str, Any]) -> str: + """Generate cache key for rendered template.""" + # Create deterministic hash of parameters + param_str = ":".join(f"{k}={v}" for k, v in sorted(parameters.items())) + return f"template_rendered:{template_id}:{hash(param_str)}" + + +__all__ = [ + "InvalidTemplateSyntaxError", + "LLMTemplateService", + "TemplateNotFoundError", + "TemplateRenderError", +] diff --git a/coaching/src/services/model_config_service.py b/coaching/src/services/model_config_service.py index 5bac97ae..4bfd8eb7 100644 --- a/coaching/src/services/model_config_service.py +++ b/coaching/src/services/model_config_service.py @@ -1,238 +1,239 @@ -"""Service for managing AI model configurations.""" - -from typing import Any - -import structlog -import yaml -from botocore.exceptions import ClientError -from coaching.src.domain.entities.model_config import ModelConfig - -logger = structlog.get_logger() - - -class ModelConfigService: - """ - Service for managing AI model configurations. - - Handles CRUD operations for model configurations stored in S3. - Configurations include pricing, limits, and operational status. - - Storage: S3 bucket under models/{model_id}/config.yaml - """ - - def __init__(self, s3_client: Any, bucket_name: str): - """ - Initialize model configuration service. - - Args: - s3_client: Boto3 S3 client - bucket_name: S3 bucket name for model configurations - """ - self.s3_client = s3_client - self.bucket_name = bucket_name - logger.info("Model configuration service initialized", bucket_name=bucket_name) - - async def get_config(self, model_id: str) -> ModelConfig | None: - """ - Retrieve configuration for a specific model. - - Args: - model_id: Unique model identifier - - Returns: - ModelConfig if found, None otherwise - """ - try: - key = self._get_config_key(model_id) - - # Fetch from S3 - response = self.s3_client.get_object(Bucket=self.bucket_name, Key=key) - content = response["Body"].read().decode("utf-8") - - # Parse YAML - yaml_data = yaml.safe_load(content) - - if not isinstance(yaml_data, dict): - logger.error("Invalid config YAML format", model_id=model_id) - return None - - # Create ModelConfig from YAML data - config = ModelConfig(**yaml_data) - - logger.info("Model config retrieved", model_id=model_id) - return config - - except ClientError as e: - error_code = e.response.get("Error", {}).get("Code") - if error_code == "NoSuchKey": - logger.debug("Model config not found", model_id=model_id) - return None - logger.error( - "S3 error retrieving model config", - model_id=model_id, - error=str(e), - ) - raise - except Exception as e: - logger.error( - "Failed to retrieve model config", - model_id=model_id, - error=str(e), - ) - raise - - async def save_config(self, config: ModelConfig) -> None: - """ - Save a model configuration. - - Args: - config: The model configuration to save - - Raises: - Exception: If save operation fails - """ - try: - key = self._get_config_key(config.model_id) - - # Convert config to YAML - yaml_data = config.model_dump(mode="json") - yaml_content = yaml.dump(yaml_data, default_flow_style=False, sort_keys=False) - - # Upload to S3 - self.s3_client.put_object( - Bucket=self.bucket_name, - Key=key, - Body=yaml_content.encode("utf-8"), - ContentType="application/x-yaml", - Metadata={ - "model_id": config.model_id, - "provider": config.provider, - }, - ) - - logger.info("Model config saved", model_id=config.model_id) - - except Exception as e: - logger.error( - "Failed to save model config", - model_id=config.model_id, - error=str(e), - ) - raise - - async def update_config( - self, - model_id: str, - updates: dict[str, Any], - ) -> ModelConfig: - """ - Update a model configuration. - - Args: - model_id: Unique model identifier - updates: Dictionary of fields to update - - Returns: - Updated ModelConfig - - Raises: - ValueError: If model config doesn't exist - Exception: If update fails - """ - try: - # Get existing config - existing_config = await self.get_config(model_id) - if not existing_config: - raise ValueError(f"Model config not found: {model_id}") - - # Apply updates - config_dict = existing_config.model_dump() - - # Handle pricing updates - if "input_cost_per_1k_tokens" in updates or "output_cost_per_1k_tokens" in updates: - pricing_dict = config_dict["pricing"] - if "input_cost_per_1k_tokens" in updates: - pricing_dict["input_cost_per_1k_tokens"] = updates["input_cost_per_1k_tokens"] - if "output_cost_per_1k_tokens" in updates: - pricing_dict["output_cost_per_1k_tokens"] = updates["output_cost_per_1k_tokens"] - config_dict["pricing"] = pricing_dict - - # Apply other updates - for key, value in updates.items(): - if ( - key not in ["input_cost_per_1k_tokens", "output_cost_per_1k_tokens"] - and key in config_dict - ): - config_dict[key] = value - - # Create updated config - updated_config = ModelConfig(**config_dict) - - # Save - await self.save_config(updated_config) - - logger.info( - "Model config updated", - model_id=model_id, - updated_fields=list(updates.keys()), - ) - - return updated_config - - except ValueError: - raise - except Exception as e: - logger.error( - "Failed to update model config", - model_id=model_id, - error=str(e), - ) - raise - - async def list_configs(self) -> list[ModelConfig]: - """ - List all model configurations. - - Returns: - List of ModelConfig objects - """ - try: - prefix = "models/" - response = self.s3_client.list_objects_v2(Bucket=self.bucket_name, Prefix=prefix) - - if "Contents" not in response: - return [] - - configs = [] - for obj in response["Contents"]: - key = obj["Key"] - if key.endswith("/config.yaml"): - # Extract model_id from "models/{model_id}/config.yaml" - model_id = key.replace(prefix, "").replace("/config.yaml", "") - config = await self.get_config(model_id) - if config: - configs.append(config) - - logger.debug("Model configs listed", count=len(configs)) - return configs - - except Exception as e: - logger.error("Failed to list model configs", error=str(e)) - raise - - def _get_config_key(self, model_id: str) -> str: - """ - Get S3 key for a model configuration. - - Args: - model_id: Unique model identifier - - Returns: - S3 key path - """ - # Sanitize model_id for S3 key (replace colons with underscores) - safe_model_id = model_id.replace(":", "_") - return f"models/{safe_model_id}/config.yaml" - - -__all__ = ["ModelConfigService"] +"""Service for managing AI model configurations.""" + +from typing import Any + +import structlog +import yaml +from botocore.exceptions import ClientError + +from coaching.src.domain.entities.model_config import ModelConfig + +logger = structlog.get_logger() + + +class ModelConfigService: + """ + Service for managing AI model configurations. + + Handles CRUD operations for model configurations stored in S3. + Configurations include pricing, limits, and operational status. + + Storage: S3 bucket under models/{model_id}/config.yaml + """ + + def __init__(self, s3_client: Any, bucket_name: str): + """ + Initialize model configuration service. + + Args: + s3_client: Boto3 S3 client + bucket_name: S3 bucket name for model configurations + """ + self.s3_client = s3_client + self.bucket_name = bucket_name + logger.info("Model configuration service initialized", bucket_name=bucket_name) + + async def get_config(self, model_id: str) -> ModelConfig | None: + """ + Retrieve configuration for a specific model. + + Args: + model_id: Unique model identifier + + Returns: + ModelConfig if found, None otherwise + """ + try: + key = self._get_config_key(model_id) + + # Fetch from S3 + response = self.s3_client.get_object(Bucket=self.bucket_name, Key=key) + content = response["Body"].read().decode("utf-8") + + # Parse YAML + yaml_data = yaml.safe_load(content) + + if not isinstance(yaml_data, dict): + logger.error("Invalid config YAML format", model_id=model_id) + return None + + # Create ModelConfig from YAML data + config = ModelConfig(**yaml_data) + + logger.info("Model config retrieved", model_id=model_id) + return config + + except ClientError as e: + error_code = e.response.get("Error", {}).get("Code") + if error_code == "NoSuchKey": + logger.debug("Model config not found", model_id=model_id) + return None + logger.error( + "S3 error retrieving model config", + model_id=model_id, + error=str(e), + ) + raise + except Exception as e: + logger.error( + "Failed to retrieve model config", + model_id=model_id, + error=str(e), + ) + raise + + async def save_config(self, config: ModelConfig) -> None: + """ + Save a model configuration. + + Args: + config: The model configuration to save + + Raises: + Exception: If save operation fails + """ + try: + key = self._get_config_key(config.model_id) + + # Convert config to YAML + yaml_data = config.model_dump(mode="json") + yaml_content = yaml.dump(yaml_data, default_flow_style=False, sort_keys=False) + + # Upload to S3 + self.s3_client.put_object( + Bucket=self.bucket_name, + Key=key, + Body=yaml_content.encode("utf-8"), + ContentType="application/x-yaml", + Metadata={ + "model_id": config.model_id, + "provider": config.provider, + }, + ) + + logger.info("Model config saved", model_id=config.model_id) + + except Exception as e: + logger.error( + "Failed to save model config", + model_id=config.model_id, + error=str(e), + ) + raise + + async def update_config( + self, + model_id: str, + updates: dict[str, Any], + ) -> ModelConfig: + """ + Update a model configuration. + + Args: + model_id: Unique model identifier + updates: Dictionary of fields to update + + Returns: + Updated ModelConfig + + Raises: + ValueError: If model config doesn't exist + Exception: If update fails + """ + try: + # Get existing config + existing_config = await self.get_config(model_id) + if not existing_config: + raise ValueError(f"Model config not found: {model_id}") + + # Apply updates + config_dict = existing_config.model_dump() + + # Handle pricing updates + if "input_cost_per_1k_tokens" in updates or "output_cost_per_1k_tokens" in updates: + pricing_dict = config_dict["pricing"] + if "input_cost_per_1k_tokens" in updates: + pricing_dict["input_cost_per_1k_tokens"] = updates["input_cost_per_1k_tokens"] + if "output_cost_per_1k_tokens" in updates: + pricing_dict["output_cost_per_1k_tokens"] = updates["output_cost_per_1k_tokens"] + config_dict["pricing"] = pricing_dict + + # Apply other updates + for key, value in updates.items(): + if ( + key not in ["input_cost_per_1k_tokens", "output_cost_per_1k_tokens"] + and key in config_dict + ): + config_dict[key] = value + + # Create updated config + updated_config = ModelConfig(**config_dict) + + # Save + await self.save_config(updated_config) + + logger.info( + "Model config updated", + model_id=model_id, + updated_fields=list(updates.keys()), + ) + + return updated_config + + except ValueError: + raise + except Exception as e: + logger.error( + "Failed to update model config", + model_id=model_id, + error=str(e), + ) + raise + + async def list_configs(self) -> list[ModelConfig]: + """ + List all model configurations. + + Returns: + List of ModelConfig objects + """ + try: + prefix = "models/" + response = self.s3_client.list_objects_v2(Bucket=self.bucket_name, Prefix=prefix) + + if "Contents" not in response: + return [] + + configs = [] + for obj in response["Contents"]: + key = obj["Key"] + if key.endswith("/config.yaml"): + # Extract model_id from "models/{model_id}/config.yaml" + model_id = key.replace(prefix, "").replace("/config.yaml", "") + config = await self.get_config(model_id) + if config: + configs.append(config) + + logger.debug("Model configs listed", count=len(configs)) + return configs + + except Exception as e: + logger.error("Failed to list model configs", error=str(e)) + raise + + def _get_config_key(self, model_id: str) -> str: + """ + Get S3 key for a model configuration. + + Args: + model_id: Unique model identifier + + Returns: + S3 key path + """ + # Sanitize model_id for S3 key (replace colons with underscores) + safe_model_id = model_id.replace(":", "_") + return f"models/{safe_model_id}/config.yaml" + + +__all__ = ["ModelConfigService"] diff --git a/coaching/src/services/onboarding_service.py b/coaching/src/services/onboarding_service.py index 963491c1..0562db57 100644 --- a/coaching/src/services/onboarding_service.py +++ b/coaching/src/services/onboarding_service.py @@ -1,315 +1,316 @@ -"""Onboarding service for AI-powered onboarding assistance.""" - -import structlog -from coaching.src.services.llm_service import LLMService -from coaching.src.services.website_analysis_service import WebsiteAnalysisService - -logger = structlog.get_logger() - - -class OnboardingService: - """Service for AI-powered onboarding assistance. - - Provides intelligent suggestions and coaching during the onboarding process. - """ - - def __init__( - self, - llm_service: LLMService, - website_analysis_service: WebsiteAnalysisService | None = None, - ): - """Initialize onboarding service. - - Args: - llm_service: LLM service for AI generation - website_analysis_service: Optional service for website analysis - """ - self.llm_service = llm_service - self.website_analysis_service = website_analysis_service or WebsiteAnalysisService( - llm_service=llm_service - ) - logger.info("Onboarding service initialized") - - async def get_suggestions( - self, - kind: str, - current: str | None = None, - context: dict[str, str | list[str]] | None = None, - ) -> dict[str, list[str] | str]: - """Get AI suggestions for onboarding fields. - - Args: - kind: Type of suggestion (niche, ica, valueProposition) - current: Current draft text (optional) - context: Business context - - Returns: - Dictionary with suggestions and reasoning - """ - logger.info("Generating onboarding suggestions", kind=kind) - - context = context or {} - business_name = context.get("businessName", "") - industry = context.get("industry", "") - products = context.get("products", []) - - # Build prompt based on kind - prompts = { - "niche": f"""Generate 3-5 professional niche descriptions for a business. - -Business Name: {business_name or "Not provided"} -Industry: {industry or "Not provided"} -Products/Services: {", ".join(products) if products else "Not provided"} -Current Draft: {current or "None"} - -Provide clear, specific niche descriptions that define the target market and unique positioning. -Each should be 1-2 sentences.""", - "ica": f"""Generate 3-5 Ideal Customer Avatar (ICA) descriptions for a business. - -Business Name: {business_name or "Not provided"} -Industry: {industry or "Not provided"} -Products/Services: {", ".join(products) if products else "Not provided"} -Current Draft: {current or "None"} - -Describe the perfect customer in detail: demographics, psychographics, pain points, goals. -Each should be specific and actionable.""", - "valueProposition": f"""Generate 3-5 value proposition statements for a business. - -Business Name: {business_name or "Not provided"} -Industry: {industry or "Not provided"} -Products/Services: {", ".join(products) if products else "Not provided"} -Current Draft: {current or "None"} - -Create compelling value propositions that clearly state what makes this business unique. -Each should be concise and customer-focused.""", - } - - prompt = prompts.get(kind, prompts["niche"]) - - # Generate suggestions using LLM - response_data = await self.llm_service.generate_single_shot_analysis( - topic="onboarding", - user_input=prompt, - analysis_type="suggestion", - ) - response = response_data.get("response", "") - - # Parse response into list of suggestions - suggestions = self._parse_suggestions(response) - - reasoning = ( - f"Based on your {industry or 'business'} " - + f"{'and products (' + ', '.join(products[:2]) + ')' if products else 'information'}, " - + "these suggestions align with market positioning best practices." - ) - - return { - "suggestions": suggestions, - "reasoning": reasoning, - } - - async def scan_website(self, url: str) -> dict[str, str | list[str]]: - """Scan website to extract business information using AI analysis. - - Args: - url: Website URL to scan - - Returns: - Dictionary with extracted business information: - - businessName: Extracted business name - - industry: Identified industry - - description: Business description - - products: List of product/service names - - targetMarket: Target market description - - suggestedNiche: Suggested niche positioning - - Raises: - ValueError: If URL is invalid or website cannot be accessed - RuntimeError: If analysis fails - """ - logger.info("Scanning website", url=url) - - try: - # Use website analysis service to extract information - analysis = await self.website_analysis_service.analyze_website(url) - - # Map analysis results to onboarding format - products_list = [ - product.get("name", "Product/Service") for product in analysis.get("products", []) - ] - - # Extract business name from URL or products - from urllib.parse import urlparse - - parsed_url = urlparse(url) - business_name = parsed_url.netloc.replace("www.", "").split(".")[0].title() - - result = { - "businessName": business_name, - "industry": "Professional Services", # Could be enhanced with industry detection - "description": analysis.get("value_proposition", ""), - "products": products_list, - "targetMarket": analysis.get("ica", ""), - "suggestedNiche": analysis.get("niche", ""), - } - - logger.info("Website scanned successfully", url=url, products_count=len(products_list)) - return result - - except (ValueError, RuntimeError) as e: - logger.error("Website scan failed", url=url, error=str(e)) - raise - - async def get_coaching( - self, - topic: str, - message: str, - context: dict[str, str] | None = None, - ) -> dict[str, str | list[str]]: - """Get coaching assistance for onboarding topic. - - Args: - topic: Onboarding topic (coreValues, purpose, vision) - message: User's question - context: Business context - - Returns: - Dictionary with coach response and suggestions - """ - logger.info("Providing onboarding coaching", topic=topic) - - context = context or {} - business_name = context.get("businessName", "your business") - industry = context.get("industry", "your industry") - current_draft = context.get("currentDraft", "") - - # Topic-specific prompts - topic_prompts = { - "coreValues": f"""You are a business coach helping define core values. - -Business: {business_name} -Industry: {industry} -Current Draft: {current_draft or "None yet"} - -User Question: {message} - -Provide helpful, actionable guidance. Explain what core values are, give examples relevant to their industry, -and help them think through what principles should guide their business decisions. - -Also suggest 4-6 potential core values they might consider.""", - "purpose": f"""You are a business coach helping define company purpose. - -Business: {business_name} -Industry: {industry} -Current Draft: {current_draft or "None yet"} - -User Question: {message} - -Provide helpful guidance on crafting a purpose statement. Explain the difference between purpose and goals, -give examples of strong purpose statements, and help them articulate why their business exists beyond profit. - -Suggest 2-3 purpose statement examples they could refine.""", - "vision": f"""You are a business coach helping create a vision statement. - -Business: {business_name} -Industry: {industry} -Current Draft: {current_draft or "None yet"} - -User Question: {message} - -Provide guidance on creating an inspiring vision statement. Explain what makes a good vision, -give examples, and help them envision their ideal future state. - -Suggest 2-3 vision statement examples they could adapt.""", - } - - prompt = topic_prompts.get(topic, topic_prompts["coreValues"]) - - # Generate coaching response - response_data = await self.llm_service.generate_single_shot_analysis( - topic=topic, - user_input=prompt, - analysis_type="coaching", - ) - response_text = response_data.get("response", "") - - # Extract suggestions from response - suggestions = self._extract_suggestions_from_coaching(response_text, topic) - - return { - "response": response_text, - "suggestions": suggestions, - } - - def _parse_suggestions(self, response: str) -> list[str]: - """Parse AI response into list of suggestions. - - Args: - response: Raw AI response - - Returns: - List of suggestions - """ - # Split by newlines and filter out empty/short lines - lines = [ - line.strip().lstrip("0123456789.-•* ") - for line in response.split("\n") - if line.strip() and len(line.strip()) > 20 - ] - - # Return up to 5 suggestions - return ( - lines[:5] - if lines - else ["Unable to generate suggestions. Please try with more context."] - ) - - def _extract_suggestions_from_coaching( - self, - response: str, - topic: str, - ) -> list[str]: - """Extract specific suggestions from coaching response. - - Args: - response: Coaching response text - topic: Topic being coached - - Returns: - List of suggestions - """ - # Simple extraction: look for quoted phrases or bullet points - suggestions = [] - - # Look for quoted text - import re - - quoted = re.findall(r'"([^"]+)"', response) - suggestions.extend(quoted[:6]) - - # If we have suggestions, return them - if suggestions: - return suggestions[:6] - - # Default suggestions by topic - defaults = { - "coreValues": [ - "Integrity", - "Innovation", - "Customer Success", - "Excellence", - ], - "purpose": [ - f"To help our customers achieve their goals through {topic}", - "To make a positive impact in our industry", - ], - "vision": [ - "To be the leading provider in our market", - "To transform how our industry approaches challenges", - ], - } - - return defaults.get(topic, []) - - -__all__ = ["OnboardingService"] +"""Onboarding service for AI-powered onboarding assistance.""" + +import structlog + +from coaching.src.services.llm_service import LLMService +from coaching.src.services.website_analysis_service import WebsiteAnalysisService + +logger = structlog.get_logger() + + +class OnboardingService: + """Service for AI-powered onboarding assistance. + + Provides intelligent suggestions and coaching during the onboarding process. + """ + + def __init__( + self, + llm_service: LLMService, + website_analysis_service: WebsiteAnalysisService | None = None, + ): + """Initialize onboarding service. + + Args: + llm_service: LLM service for AI generation + website_analysis_service: Optional service for website analysis + """ + self.llm_service = llm_service + self.website_analysis_service = website_analysis_service or WebsiteAnalysisService( + llm_service=llm_service + ) + logger.info("Onboarding service initialized") + + async def get_suggestions( + self, + kind: str, + current: str | None = None, + context: dict[str, str | list[str]] | None = None, + ) -> dict[str, list[str] | str]: + """Get AI suggestions for onboarding fields. + + Args: + kind: Type of suggestion (niche, ica, valueProposition) + current: Current draft text (optional) + context: Business context + + Returns: + Dictionary with suggestions and reasoning + """ + logger.info("Generating onboarding suggestions", kind=kind) + + context = context or {} + business_name = context.get("businessName", "") + industry = context.get("industry", "") + products = context.get("products", []) + + # Build prompt based on kind + prompts = { + "niche": f"""Generate 3-5 professional niche descriptions for a business. + +Business Name: {business_name or "Not provided"} +Industry: {industry or "Not provided"} +Products/Services: {", ".join(products) if products else "Not provided"} +Current Draft: {current or "None"} + +Provide clear, specific niche descriptions that define the target market and unique positioning. +Each should be 1-2 sentences.""", + "ica": f"""Generate 3-5 Ideal Customer Avatar (ICA) descriptions for a business. + +Business Name: {business_name or "Not provided"} +Industry: {industry or "Not provided"} +Products/Services: {", ".join(products) if products else "Not provided"} +Current Draft: {current or "None"} + +Describe the perfect customer in detail: demographics, psychographics, pain points, goals. +Each should be specific and actionable.""", + "valueProposition": f"""Generate 3-5 value proposition statements for a business. + +Business Name: {business_name or "Not provided"} +Industry: {industry or "Not provided"} +Products/Services: {", ".join(products) if products else "Not provided"} +Current Draft: {current or "None"} + +Create compelling value propositions that clearly state what makes this business unique. +Each should be concise and customer-focused.""", + } + + prompt = prompts.get(kind, prompts["niche"]) + + # Generate suggestions using LLM + response_data = await self.llm_service.generate_single_shot_analysis( + topic="onboarding", + user_input=prompt, + analysis_type="suggestion", + ) + response = response_data.get("response", "") + + # Parse response into list of suggestions + suggestions = self._parse_suggestions(response) + + reasoning = ( + f"Based on your {industry or 'business'} " + + f"{'and products (' + ', '.join(products[:2]) + ')' if products else 'information'}, " + + "these suggestions align with market positioning best practices." + ) + + return { + "suggestions": suggestions, + "reasoning": reasoning, + } + + async def scan_website(self, url: str) -> dict[str, str | list[str]]: + """Scan website to extract business information using AI analysis. + + Args: + url: Website URL to scan + + Returns: + Dictionary with extracted business information: + - businessName: Extracted business name + - industry: Identified industry + - description: Business description + - products: List of product/service names + - targetMarket: Target market description + - suggestedNiche: Suggested niche positioning + + Raises: + ValueError: If URL is invalid or website cannot be accessed + RuntimeError: If analysis fails + """ + logger.info("Scanning website", url=url) + + try: + # Use website analysis service to extract information + analysis = await self.website_analysis_service.analyze_website(url) + + # Map analysis results to onboarding format + products_list = [ + product.get("name", "Product/Service") for product in analysis.get("products", []) + ] + + # Extract business name from URL or products + from urllib.parse import urlparse + + parsed_url = urlparse(url) + business_name = parsed_url.netloc.replace("www.", "").split(".")[0].title() + + result = { + "businessName": business_name, + "industry": "Professional Services", # Could be enhanced with industry detection + "description": analysis.get("value_proposition", ""), + "products": products_list, + "targetMarket": analysis.get("ica", ""), + "suggestedNiche": analysis.get("niche", ""), + } + + logger.info("Website scanned successfully", url=url, products_count=len(products_list)) + return result + + except (ValueError, RuntimeError) as e: + logger.error("Website scan failed", url=url, error=str(e)) + raise + + async def get_coaching( + self, + topic: str, + message: str, + context: dict[str, str] | None = None, + ) -> dict[str, str | list[str]]: + """Get coaching assistance for onboarding topic. + + Args: + topic: Onboarding topic (coreValues, purpose, vision) + message: User's question + context: Business context + + Returns: + Dictionary with coach response and suggestions + """ + logger.info("Providing onboarding coaching", topic=topic) + + context = context or {} + business_name = context.get("businessName", "your business") + industry = context.get("industry", "your industry") + current_draft = context.get("currentDraft", "") + + # Topic-specific prompts + topic_prompts = { + "coreValues": f"""You are a business coach helping define core values. + +Business: {business_name} +Industry: {industry} +Current Draft: {current_draft or "None yet"} + +User Question: {message} + +Provide helpful, actionable guidance. Explain what core values are, give examples relevant to their industry, +and help them think through what principles should guide their business decisions. + +Also suggest 4-6 potential core values they might consider.""", + "purpose": f"""You are a business coach helping define company purpose. + +Business: {business_name} +Industry: {industry} +Current Draft: {current_draft or "None yet"} + +User Question: {message} + +Provide helpful guidance on crafting a purpose statement. Explain the difference between purpose and goals, +give examples of strong purpose statements, and help them articulate why their business exists beyond profit. + +Suggest 2-3 purpose statement examples they could refine.""", + "vision": f"""You are a business coach helping create a vision statement. + +Business: {business_name} +Industry: {industry} +Current Draft: {current_draft or "None yet"} + +User Question: {message} + +Provide guidance on creating an inspiring vision statement. Explain what makes a good vision, +give examples, and help them envision their ideal future state. + +Suggest 2-3 vision statement examples they could adapt.""", + } + + prompt = topic_prompts.get(topic, topic_prompts["coreValues"]) + + # Generate coaching response + response_data = await self.llm_service.generate_single_shot_analysis( + topic=topic, + user_input=prompt, + analysis_type="coaching", + ) + response_text = response_data.get("response", "") + + # Extract suggestions from response + suggestions = self._extract_suggestions_from_coaching(response_text, topic) + + return { + "response": response_text, + "suggestions": suggestions, + } + + def _parse_suggestions(self, response: str) -> list[str]: + """Parse AI response into list of suggestions. + + Args: + response: Raw AI response + + Returns: + List of suggestions + """ + # Split by newlines and filter out empty/short lines + lines = [ + line.strip().lstrip("0123456789.-•* ") + for line in response.split("\n") + if line.strip() and len(line.strip()) > 20 + ] + + # Return up to 5 suggestions + return ( + lines[:5] + if lines + else ["Unable to generate suggestions. Please try with more context."] + ) + + def _extract_suggestions_from_coaching( + self, + response: str, + topic: str, + ) -> list[str]: + """Extract specific suggestions from coaching response. + + Args: + response: Coaching response text + topic: Topic being coached + + Returns: + List of suggestions + """ + # Simple extraction: look for quoted phrases or bullet points + suggestions = [] + + # Look for quoted text + import re + + quoted = re.findall(r'"([^"]+)"', response) + suggestions.extend(quoted[:6]) + + # If we have suggestions, return them + if suggestions: + return suggestions[:6] + + # Default suggestions by topic + defaults = { + "coreValues": [ + "Integrity", + "Innovation", + "Customer Success", + "Excellence", + ], + "purpose": [ + f"To help our customers achieve their goals through {topic}", + "To make a positive impact in our industry", + ], + "vision": [ + "To be the leading provider in our market", + "To transform how our industry approaches challenges", + ], + } + + return defaults.get(topic, []) + + +__all__ = ["OnboardingService"] diff --git a/coaching/src/services/parameter_gathering_service.py b/coaching/src/services/parameter_gathering_service.py index 97b9bed5..19cbadc4 100644 --- a/coaching/src/services/parameter_gathering_service.py +++ b/coaching/src/services/parameter_gathering_service.py @@ -7,6 +7,7 @@ from typing import Any import structlog + from coaching.src.core.constants import ParameterSource from coaching.src.core.parameter_registry import PARAMETER_REGISTRY from coaching.src.core.topic_registry import ( diff --git a/coaching/src/services/prompt_service.py b/coaching/src/services/prompt_service.py index 7a591abc..f8162020 100644 --- a/coaching/src/services/prompt_service.py +++ b/coaching/src/services/prompt_service.py @@ -8,6 +8,7 @@ from typing import Any import structlog + from coaching.src.domain.entities.llm_topic import LLMTopic, ParameterDefinition from coaching.src.domain.exceptions.topic_exceptions import TopicNotFoundError from coaching.src.models.prompt import ( diff --git a/coaching/src/services/s3_prompt_storage.py b/coaching/src/services/s3_prompt_storage.py index 171d834a..c02b6af5 100644 --- a/coaching/src/services/s3_prompt_storage.py +++ b/coaching/src/services/s3_prompt_storage.py @@ -1,365 +1,366 @@ -"""S3-based storage service for LLM prompt content. - -This service handles storing and retrieving prompt markdown files from S3, -following the path structure: prompts/{topic_id}/{prompt_type}.md -""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -import boto3 -import structlog -from botocore.exceptions import ClientError -from coaching.src.domain.exceptions.topic_exceptions import S3StorageError - -if TYPE_CHECKING: - from mypy_boto3_s3 import S3Client - -logger = structlog.get_logger() - - -class S3PromptStorage: - """Service for storing and retrieving prompt content in S3. - - Manages prompt markdown files with proper error handling and logging. - All prompts are stored as UTF-8 encoded markdown files. - - Path Structure: - prompts/{topic_id}/{prompt_type}.md - - Example: - prompts/core_values/system.md - prompts/revenue_analysis/user.md - """ - - def __init__(self, *, bucket_name: str, s3_client: S3Client | None = None) -> None: - """Initialize S3 prompt storage. - - Args: - bucket_name: S3 bucket name for prompt storage - s3_client: Optional S3 client (for testing), creates new client if None - """ - self.bucket_name = bucket_name - self.s3_client: S3Client = s3_client or boto3.client("s3") - - def _build_key(self, *, topic_id: str, prompt_type: str) -> str: - """Build S3 key for prompt. - - Args: - topic_id: Topic identifier - prompt_type: Prompt type (system, user, assistant, function) - - Returns: - S3 key path - """ - return f"prompts/{topic_id}/{prompt_type}.md" - - async def save_prompt( - self, - *, - topic_id: str, - prompt_type: str, - content: str, - ) -> str: - """Save prompt content to S3. - - Args: - topic_id: Topic identifier - prompt_type: Prompt type (system, user, assistant, function) - content: Markdown content to store - - Returns: - S3 key where content was saved - - Raises: - S3StorageError: If save operation fails - """ - key = self._build_key(topic_id=topic_id, prompt_type=prompt_type) - - try: - self.s3_client.put_object( - Bucket=self.bucket_name, - Key=key, - Body=content.encode("utf-8"), - ContentType="text/markdown", - Metadata={ - "topic_id": topic_id, - "prompt_type": prompt_type, - }, - ) - - logger.info( - "Prompt saved to S3", - topic_id=topic_id, - prompt_type=prompt_type, - key=key, - size_bytes=len(content.encode("utf-8")), - ) - return key - - except ClientError as e: - error_code = e.response["Error"]["Code"] - logger.error( - "Failed to save prompt to S3", - topic_id=topic_id, - prompt_type=prompt_type, - error_code=error_code, - error=str(e), - ) - raise S3StorageError( - operation="put", - key=key, - reason=f"{error_code}: {e}", - bucket=self.bucket_name, - ) from e - except Exception as e: - logger.error( - "Unexpected error saving prompt to S3", - topic_id=topic_id, - prompt_type=prompt_type, - error=str(e), - ) - raise S3StorageError( - operation="put", - key=key, - reason=str(e), - bucket=self.bucket_name, - ) from e - - async def get_prompt( - self, - *, - topic_id: str, - prompt_type: str, - ) -> str | None: - """Get prompt content from S3. - - Args: - topic_id: Topic identifier - prompt_type: Prompt type (system, user, assistant, function) - - Returns: - Prompt content if found, None if not found - - Raises: - S3StorageError: If retrieval operation fails (excluding not found) - """ - key = self._build_key(topic_id=topic_id, prompt_type=prompt_type) - - try: - response = self.s3_client.get_object(Bucket=self.bucket_name, Key=key) - content: str = response["Body"].read().decode("utf-8") - - logger.debug( - "Prompt retrieved from S3", - topic_id=topic_id, - prompt_type=prompt_type, - key=key, - ) - return content - - except ClientError as e: - error_code = e.response["Error"]["Code"] - if error_code == "NoSuchKey": - logger.debug( - "Prompt not found in S3", - topic_id=topic_id, - prompt_type=prompt_type, - key=key, - ) - return None - - logger.error( - "Failed to get prompt from S3", - topic_id=topic_id, - prompt_type=prompt_type, - error_code=error_code, - error=str(e), - ) - raise S3StorageError( - operation="get", - key=key, - reason=f"{error_code}: {e}", - bucket=self.bucket_name, - ) from e - except Exception as e: - logger.error( - "Unexpected error getting prompt from S3", - topic_id=topic_id, - prompt_type=prompt_type, - error=str(e), - ) - raise S3StorageError( - operation="get", - key=key, - reason=str(e), - bucket=self.bucket_name, - ) from e - - async def delete_prompt( - self, - *, - topic_id: str, - prompt_type: str, - ) -> bool: - """Delete prompt from S3. - - Args: - topic_id: Topic identifier - prompt_type: Prompt type (system, user, assistant, function) - - Returns: - True if deleted successfully - - Raises: - S3StorageError: If delete operation fails - """ - key = self._build_key(topic_id=topic_id, prompt_type=prompt_type) - - try: - self.s3_client.delete_object(Bucket=self.bucket_name, Key=key) - - logger.info( - "Prompt deleted from S3", - topic_id=topic_id, - prompt_type=prompt_type, - key=key, - ) - return True - - except ClientError as e: - error_code = e.response["Error"]["Code"] - logger.error( - "Failed to delete prompt from S3", - topic_id=topic_id, - prompt_type=prompt_type, - error_code=error_code, - error=str(e), - ) - raise S3StorageError( - operation="delete", - key=key, - reason=f"{error_code}: {e}", - bucket=self.bucket_name, - ) from e - except Exception as e: - logger.error( - "Unexpected error deleting prompt from S3", - topic_id=topic_id, - prompt_type=prompt_type, - error=str(e), - ) - raise S3StorageError( - operation="delete", - key=key, - reason=str(e), - bucket=self.bucket_name, - ) from e - - async def list_prompts(self, *, topic_id: str) -> list[str]: - """List all prompt types for a topic. - - Args: - topic_id: Topic identifier - - Returns: - List of prompt types (e.g., ['system', 'user', 'assistant']) - - Raises: - S3StorageError: If list operation fails - """ - prefix = f"prompts/{topic_id}/" - - try: - response = self.s3_client.list_objects_v2( - Bucket=self.bucket_name, - Prefix=prefix, - ) - - prompt_types: list[str] = [] - for obj in response.get("Contents", []): - key = obj["Key"] - # Extract prompt_type from key: prompts/topic_id/type.md - if key.endswith(".md"): - filename = key.split("/")[-1] - prompt_type = filename.replace(".md", "") - prompt_types.append(prompt_type) - - logger.debug( - "Prompts listed from S3", - topic_id=topic_id, - count=len(prompt_types), - ) - return prompt_types - - except ClientError as e: - error_code = e.response["Error"]["Code"] - logger.error( - "Failed to list prompts from S3", - topic_id=topic_id, - error_code=error_code, - error=str(e), - ) - raise S3StorageError( - operation="list", - key=prefix, - reason=f"{error_code}: {e}", - bucket=self.bucket_name, - ) from e - except Exception as e: - logger.error( - "Unexpected error listing prompts from S3", - topic_id=topic_id, - error=str(e), - ) - raise S3StorageError( - operation="list", - key=prefix, - reason=str(e), - bucket=self.bucket_name, - ) from e - - async def prompt_exists( - self, - *, - topic_id: str, - prompt_type: str, - ) -> bool: - """Check if prompt exists in S3. - - Args: - topic_id: Topic identifier - prompt_type: Prompt type - - Returns: - True if prompt exists - - Raises: - S3StorageError: If check operation fails - """ - key = self._build_key(topic_id=topic_id, prompt_type=prompt_type) - - try: - self.s3_client.head_object(Bucket=self.bucket_name, Key=key) - return True - except ClientError as e: - error_code = e.response["Error"]["Code"] - if error_code == "404": - return False - logger.error( - "Failed to check prompt existence in S3", - topic_id=topic_id, - prompt_type=prompt_type, - error_code=error_code, - error=str(e), - ) - raise S3StorageError( - operation="head", - key=key, - reason=f"{error_code}: {e}", - bucket=self.bucket_name, - ) from e - - -__all__ = ["S3PromptStorage"] +"""S3-based storage service for LLM prompt content. + +This service handles storing and retrieving prompt markdown files from S3, +following the path structure: prompts/{topic_id}/{prompt_type}.md +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import boto3 +import structlog +from botocore.exceptions import ClientError + +from coaching.src.domain.exceptions.topic_exceptions import S3StorageError + +if TYPE_CHECKING: + from mypy_boto3_s3 import S3Client + +logger = structlog.get_logger() + + +class S3PromptStorage: + """Service for storing and retrieving prompt content in S3. + + Manages prompt markdown files with proper error handling and logging. + All prompts are stored as UTF-8 encoded markdown files. + + Path Structure: + prompts/{topic_id}/{prompt_type}.md + + Example: + prompts/core_values/system.md + prompts/revenue_analysis/user.md + """ + + def __init__(self, *, bucket_name: str, s3_client: S3Client | None = None) -> None: + """Initialize S3 prompt storage. + + Args: + bucket_name: S3 bucket name for prompt storage + s3_client: Optional S3 client (for testing), creates new client if None + """ + self.bucket_name = bucket_name + self.s3_client: S3Client = s3_client or boto3.client("s3") + + def _build_key(self, *, topic_id: str, prompt_type: str) -> str: + """Build S3 key for prompt. + + Args: + topic_id: Topic identifier + prompt_type: Prompt type (system, user, assistant, function) + + Returns: + S3 key path + """ + return f"prompts/{topic_id}/{prompt_type}.md" + + async def save_prompt( + self, + *, + topic_id: str, + prompt_type: str, + content: str, + ) -> str: + """Save prompt content to S3. + + Args: + topic_id: Topic identifier + prompt_type: Prompt type (system, user, assistant, function) + content: Markdown content to store + + Returns: + S3 key where content was saved + + Raises: + S3StorageError: If save operation fails + """ + key = self._build_key(topic_id=topic_id, prompt_type=prompt_type) + + try: + self.s3_client.put_object( + Bucket=self.bucket_name, + Key=key, + Body=content.encode("utf-8"), + ContentType="text/markdown", + Metadata={ + "topic_id": topic_id, + "prompt_type": prompt_type, + }, + ) + + logger.info( + "Prompt saved to S3", + topic_id=topic_id, + prompt_type=prompt_type, + key=key, + size_bytes=len(content.encode("utf-8")), + ) + return key + + except ClientError as e: + error_code = e.response["Error"]["Code"] + logger.error( + "Failed to save prompt to S3", + topic_id=topic_id, + prompt_type=prompt_type, + error_code=error_code, + error=str(e), + ) + raise S3StorageError( + operation="put", + key=key, + reason=f"{error_code}: {e}", + bucket=self.bucket_name, + ) from e + except Exception as e: + logger.error( + "Unexpected error saving prompt to S3", + topic_id=topic_id, + prompt_type=prompt_type, + error=str(e), + ) + raise S3StorageError( + operation="put", + key=key, + reason=str(e), + bucket=self.bucket_name, + ) from e + + async def get_prompt( + self, + *, + topic_id: str, + prompt_type: str, + ) -> str | None: + """Get prompt content from S3. + + Args: + topic_id: Topic identifier + prompt_type: Prompt type (system, user, assistant, function) + + Returns: + Prompt content if found, None if not found + + Raises: + S3StorageError: If retrieval operation fails (excluding not found) + """ + key = self._build_key(topic_id=topic_id, prompt_type=prompt_type) + + try: + response = self.s3_client.get_object(Bucket=self.bucket_name, Key=key) + content: str = response["Body"].read().decode("utf-8") + + logger.debug( + "Prompt retrieved from S3", + topic_id=topic_id, + prompt_type=prompt_type, + key=key, + ) + return content + + except ClientError as e: + error_code = e.response["Error"]["Code"] + if error_code == "NoSuchKey": + logger.debug( + "Prompt not found in S3", + topic_id=topic_id, + prompt_type=prompt_type, + key=key, + ) + return None + + logger.error( + "Failed to get prompt from S3", + topic_id=topic_id, + prompt_type=prompt_type, + error_code=error_code, + error=str(e), + ) + raise S3StorageError( + operation="get", + key=key, + reason=f"{error_code}: {e}", + bucket=self.bucket_name, + ) from e + except Exception as e: + logger.error( + "Unexpected error getting prompt from S3", + topic_id=topic_id, + prompt_type=prompt_type, + error=str(e), + ) + raise S3StorageError( + operation="get", + key=key, + reason=str(e), + bucket=self.bucket_name, + ) from e + + async def delete_prompt( + self, + *, + topic_id: str, + prompt_type: str, + ) -> bool: + """Delete prompt from S3. + + Args: + topic_id: Topic identifier + prompt_type: Prompt type (system, user, assistant, function) + + Returns: + True if deleted successfully + + Raises: + S3StorageError: If delete operation fails + """ + key = self._build_key(topic_id=topic_id, prompt_type=prompt_type) + + try: + self.s3_client.delete_object(Bucket=self.bucket_name, Key=key) + + logger.info( + "Prompt deleted from S3", + topic_id=topic_id, + prompt_type=prompt_type, + key=key, + ) + return True + + except ClientError as e: + error_code = e.response["Error"]["Code"] + logger.error( + "Failed to delete prompt from S3", + topic_id=topic_id, + prompt_type=prompt_type, + error_code=error_code, + error=str(e), + ) + raise S3StorageError( + operation="delete", + key=key, + reason=f"{error_code}: {e}", + bucket=self.bucket_name, + ) from e + except Exception as e: + logger.error( + "Unexpected error deleting prompt from S3", + topic_id=topic_id, + prompt_type=prompt_type, + error=str(e), + ) + raise S3StorageError( + operation="delete", + key=key, + reason=str(e), + bucket=self.bucket_name, + ) from e + + async def list_prompts(self, *, topic_id: str) -> list[str]: + """List all prompt types for a topic. + + Args: + topic_id: Topic identifier + + Returns: + List of prompt types (e.g., ['system', 'user', 'assistant']) + + Raises: + S3StorageError: If list operation fails + """ + prefix = f"prompts/{topic_id}/" + + try: + response = self.s3_client.list_objects_v2( + Bucket=self.bucket_name, + Prefix=prefix, + ) + + prompt_types: list[str] = [] + for obj in response.get("Contents", []): + key = obj["Key"] + # Extract prompt_type from key: prompts/topic_id/type.md + if key.endswith(".md"): + filename = key.split("/")[-1] + prompt_type = filename.replace(".md", "") + prompt_types.append(prompt_type) + + logger.debug( + "Prompts listed from S3", + topic_id=topic_id, + count=len(prompt_types), + ) + return prompt_types + + except ClientError as e: + error_code = e.response["Error"]["Code"] + logger.error( + "Failed to list prompts from S3", + topic_id=topic_id, + error_code=error_code, + error=str(e), + ) + raise S3StorageError( + operation="list", + key=prefix, + reason=f"{error_code}: {e}", + bucket=self.bucket_name, + ) from e + except Exception as e: + logger.error( + "Unexpected error listing prompts from S3", + topic_id=topic_id, + error=str(e), + ) + raise S3StorageError( + operation="list", + key=prefix, + reason=str(e), + bucket=self.bucket_name, + ) from e + + async def prompt_exists( + self, + *, + topic_id: str, + prompt_type: str, + ) -> bool: + """Check if prompt exists in S3. + + Args: + topic_id: Topic identifier + prompt_type: Prompt type + + Returns: + True if prompt exists + + Raises: + S3StorageError: If check operation fails + """ + key = self._build_key(topic_id=topic_id, prompt_type=prompt_type) + + try: + self.s3_client.head_object(Bucket=self.bucket_name, Key=key) + return True + except ClientError as e: + error_code = e.response["Error"]["Code"] + if error_code == "404": + return False + logger.error( + "Failed to check prompt existence in S3", + topic_id=topic_id, + prompt_type=prompt_type, + error_code=error_code, + error=str(e), + ) + raise S3StorageError( + operation="head", + key=key, + reason=f"{error_code}: {e}", + bucket=self.bucket_name, + ) from e + + +__all__ = ["S3PromptStorage"] diff --git a/coaching/src/services/template_parameter_processor.py b/coaching/src/services/template_parameter_processor.py index d3ea9f03..71882409 100644 --- a/coaching/src/services/template_parameter_processor.py +++ b/coaching/src/services/template_parameter_processor.py @@ -21,6 +21,7 @@ from typing import Any import structlog + from coaching.src.core.parameter_registry import ( ParameterDefinition, get_parameter_definition, diff --git a/coaching/src/services/topic_seeding_service.py b/coaching/src/services/topic_seeding_service.py index 6c1b0143..07492801 100644 --- a/coaching/src/services/topic_seeding_service.py +++ b/coaching/src/services/topic_seeding_service.py @@ -9,6 +9,7 @@ from typing import Any import structlog + from coaching.src.core.topic_registry import list_all_topics from coaching.src.core.topic_seed_data import TopicSeedData, get_seed_data_for_topic from coaching.src.domain.entities.llm_topic import LLMTopic, PromptInfo diff --git a/coaching/src/services/user_limits_service.py b/coaching/src/services/user_limits_service.py index 1b5b4052..c4f59060 100644 --- a/coaching/src/services/user_limits_service.py +++ b/coaching/src/services/user_limits_service.py @@ -1,203 +1,204 @@ -"""Service for fetching and caching user limits from Account API.""" - -import time -from typing import Any - -import httpx -import structlog -from coaching.src.core.config_multitenant import get_settings - -logger = structlog.get_logger(__name__) - - -class UserLimitsCache: - """Cache for user limits with token-aware invalidation.""" - - def __init__(self, ttl_seconds: int = 300): # 5 minutes default - """Initialize cache. - - Args: - ttl_seconds: Time-to-live for cache entries in seconds - """ - self._cache: dict[ - str, tuple[dict[str, Any], float, int] - ] = {} # user_id -> (limits, timestamp, token_hash) - self._ttl = ttl_seconds - - def get(self, user_id: str, token: str) -> dict[str, Any] | None: - """Get cached limits for user if valid. - - Args: - user_id: User identifier - token: Current JWT token - - Returns: - Cached limits or None if expired or token changed - """ - if user_id not in self._cache: - return None - - limits, timestamp, cached_token_hash = self._cache[user_id] - current_time = time.time() - token_hash = hash(token) - - # Invalidate if token changed or expired - if token_hash != cached_token_hash or (current_time - timestamp) > self._ttl: - del self._cache[user_id] - return None - - return limits - - def set(self, user_id: str, token: str, limits: dict[str, Any]) -> None: - """Cache limits for user. - - Args: - user_id: User identifier - token: Current JWT token - limits: User limits data - """ - self._cache[user_id] = (limits, time.time(), hash(token)) - - def clear(self, user_id: str | None = None) -> None: - """Clear cache for specific user or all users. - - Args: - user_id: Optional user to clear, or None to clear all - """ - if user_id: - self._cache.pop(user_id, None) - else: - self._cache.clear() - - -class UserLimitsService: - """Service for fetching user limits from Account API.""" - - def __init__(self, account_api_base_url: str | None = None, cache_ttl: int = 300): - """Initialize service. - - Args: - account_api_base_url: Base URL for Account API - cache_ttl: Cache time-to-live in seconds - """ - settings = get_settings() - self._base_url = ( - account_api_base_url or settings.account_api_url or "https://api.dev.purposepath.app" - ) - self._cache = UserLimitsCache(ttl_seconds=cache_ttl) - self._client: httpx.AsyncClient | None = None - - async def _get_client(self) -> httpx.AsyncClient: - """Get or create HTTP client.""" - if self._client is None: - self._client = httpx.AsyncClient(timeout=10.0) - return self._client - - async def get_user_limits(self, user_id: str, token: str) -> dict[str, Any]: - """Fetch user limits from Account API with caching. - - Args: - user_id: User identifier - token: JWT token for authorization - - Returns: - User limits data - - Raises: - HTTPException: If API call fails - """ - # Check cache first - cached = self._cache.get(user_id, token) - if cached is not None: - logger.debug("User limits cache hit", user_id=user_id) - return cached - - # Fetch from Account API - try: - client = await self._get_client() - url = f"{self._base_url}/account/api/v1/users/me/limits" - - logger.info("Fetching user limits from Account API", url=url, user_id=user_id) - - response = await client.get( - url, - headers={"Authorization": f"Bearer {token}"}, - ) - - if response.status_code == 200: - response_data = response.json() - # Account API returns {"success": true, "data": {...}} - if response_data.get("success") and "data" in response_data: - limits: dict[str, Any] = response_data["data"] - self._cache.set(user_id, token, limits) - logger.info("User limits fetched successfully", user_id=user_id, limits=limits) - return limits - else: - logger.warning( - "Unexpected response format from Account API", response=response_data - ) - return self._get_default_limits() - else: - logger.warning( - "Failed to fetch user limits", - status_code=response.status_code, - response=response.text[:200], - user_id=user_id, - ) - # Return default limits on failure - return self._get_default_limits() - - except Exception as e: - logger.error("Error fetching user limits", error=str(e), user_id=user_id) - # Return default limits on error - return self._get_default_limits() - - def _get_default_limits(self) -> dict[str, Any]: - """Get default limits for users when API is unavailable. - - Returns: - Default limits dictionary matching Account API structure - """ - return { - "goals": 10, - "users": 100, - "projects": 5, - "api_calls_per_month": 10000, - "storage_mb": 1000, - } - - def check_limit(self, limits: dict[str, Any], limit_name: str, current_usage: int) -> bool: - """Check if current usage is within limit. - - Args: - limits: User limits dictionary - limit_name: Name of the limit to check (e.g., "goals", "projects") - current_usage: Current usage count - - Returns: - True if within limit, False if exceeded - """ - limit_value = int(limits.get(limit_name, 0)) - return current_usage < limit_value - - async def close(self) -> None: - """Close HTTP client.""" - if self._client: - await self._client.aclose() - self._client = None - - -# Global instance -_user_limits_service: UserLimitsService | None = None - - -def get_user_limits_service() -> UserLimitsService: - """Get singleton user limits service. - - Returns: - UserLimitsService instance - """ - global _user_limits_service - if _user_limits_service is None: - _user_limits_service = UserLimitsService() - return _user_limits_service +"""Service for fetching and caching user limits from Account API.""" + +import time +from typing import Any + +import httpx +import structlog + +from coaching.src.core.config_multitenant import get_settings + +logger = structlog.get_logger(__name__) + + +class UserLimitsCache: + """Cache for user limits with token-aware invalidation.""" + + def __init__(self, ttl_seconds: int = 300): # 5 minutes default + """Initialize cache. + + Args: + ttl_seconds: Time-to-live for cache entries in seconds + """ + self._cache: dict[ + str, tuple[dict[str, Any], float, int] + ] = {} # user_id -> (limits, timestamp, token_hash) + self._ttl = ttl_seconds + + def get(self, user_id: str, token: str) -> dict[str, Any] | None: + """Get cached limits for user if valid. + + Args: + user_id: User identifier + token: Current JWT token + + Returns: + Cached limits or None if expired or token changed + """ + if user_id not in self._cache: + return None + + limits, timestamp, cached_token_hash = self._cache[user_id] + current_time = time.time() + token_hash = hash(token) + + # Invalidate if token changed or expired + if token_hash != cached_token_hash or (current_time - timestamp) > self._ttl: + del self._cache[user_id] + return None + + return limits + + def set(self, user_id: str, token: str, limits: dict[str, Any]) -> None: + """Cache limits for user. + + Args: + user_id: User identifier + token: Current JWT token + limits: User limits data + """ + self._cache[user_id] = (limits, time.time(), hash(token)) + + def clear(self, user_id: str | None = None) -> None: + """Clear cache for specific user or all users. + + Args: + user_id: Optional user to clear, or None to clear all + """ + if user_id: + self._cache.pop(user_id, None) + else: + self._cache.clear() + + +class UserLimitsService: + """Service for fetching user limits from Account API.""" + + def __init__(self, account_api_base_url: str | None = None, cache_ttl: int = 300): + """Initialize service. + + Args: + account_api_base_url: Base URL for Account API + cache_ttl: Cache time-to-live in seconds + """ + settings = get_settings() + self._base_url = ( + account_api_base_url or settings.account_api_url or "https://api.dev.purposepath.app" + ) + self._cache = UserLimitsCache(ttl_seconds=cache_ttl) + self._client: httpx.AsyncClient | None = None + + async def _get_client(self) -> httpx.AsyncClient: + """Get or create HTTP client.""" + if self._client is None: + self._client = httpx.AsyncClient(timeout=10.0) + return self._client + + async def get_user_limits(self, user_id: str, token: str) -> dict[str, Any]: + """Fetch user limits from Account API with caching. + + Args: + user_id: User identifier + token: JWT token for authorization + + Returns: + User limits data + + Raises: + HTTPException: If API call fails + """ + # Check cache first + cached = self._cache.get(user_id, token) + if cached is not None: + logger.debug("User limits cache hit", user_id=user_id) + return cached + + # Fetch from Account API + try: + client = await self._get_client() + url = f"{self._base_url}/account/api/v1/users/me/limits" + + logger.info("Fetching user limits from Account API", url=url, user_id=user_id) + + response = await client.get( + url, + headers={"Authorization": f"Bearer {token}"}, + ) + + if response.status_code == 200: + response_data = response.json() + # Account API returns {"success": true, "data": {...}} + if response_data.get("success") and "data" in response_data: + limits: dict[str, Any] = response_data["data"] + self._cache.set(user_id, token, limits) + logger.info("User limits fetched successfully", user_id=user_id, limits=limits) + return limits + else: + logger.warning( + "Unexpected response format from Account API", response=response_data + ) + return self._get_default_limits() + else: + logger.warning( + "Failed to fetch user limits", + status_code=response.status_code, + response=response.text[:200], + user_id=user_id, + ) + # Return default limits on failure + return self._get_default_limits() + + except Exception as e: + logger.error("Error fetching user limits", error=str(e), user_id=user_id) + # Return default limits on error + return self._get_default_limits() + + def _get_default_limits(self) -> dict[str, Any]: + """Get default limits for users when API is unavailable. + + Returns: + Default limits dictionary matching Account API structure + """ + return { + "goals": 10, + "users": 100, + "projects": 5, + "api_calls_per_month": 10000, + "storage_mb": 1000, + } + + def check_limit(self, limits: dict[str, Any], limit_name: str, current_usage: int) -> bool: + """Check if current usage is within limit. + + Args: + limits: User limits dictionary + limit_name: Name of the limit to check (e.g., "goals", "projects") + current_usage: Current usage count + + Returns: + True if within limit, False if exceeded + """ + limit_value = int(limits.get(limit_name, 0)) + return current_usage < limit_value + + async def close(self) -> None: + """Close HTTP client.""" + if self._client: + await self._client.aclose() + self._client = None + + +# Global instance +_user_limits_service: UserLimitsService | None = None + + +def get_user_limits_service() -> UserLimitsService: + """Get singleton user limits service. + + Returns: + UserLimitsService instance + """ + global _user_limits_service + if _user_limits_service is None: + _user_limits_service = UserLimitsService() + return _user_limits_service diff --git a/coaching/src/services/website_analysis_service.py b/coaching/src/services/website_analysis_service.py index 2d807d79..40c30514 100644 --- a/coaching/src/services/website_analysis_service.py +++ b/coaching/src/services/website_analysis_service.py @@ -1,352 +1,353 @@ -"""Website analysis service for extracting business information from websites.""" - -from __future__ import annotations - -import json -import re -from typing import Any -from urllib.parse import urlparse - -import html2text -import requests -import structlog -from bs4 import BeautifulSoup -from coaching.src.llm.providers.manager import ProviderManager - -logger = structlog.get_logger() - -# Timeout for HTTP requests (seconds) -REQUEST_TIMEOUT = 15 - -# Maximum content length to analyze (characters) -MAX_CONTENT_LENGTH = 50000 - -# User agent to identify ourselves -USER_AGENT = "PurposePathBot/1.0 (Business Analysis; +https://purposepath.app)" - - -class WebsiteAnalysisService: - """Service for analyzing websites and extracting business information using AI. - - This service: - 1. Fetches and parses website content - 2. Extracts relevant text from HTML - 3. Uses LLM to analyze and structure business information - """ - - def __init__( - self, provider_manager: ProviderManager | None = None, llm_service: Any | None = None - ): - """Initialize website analysis service. - - Args: - provider_manager: Provider manager for direct LLM access (preferred for simple usage) - llm_service: Full LLM service for advanced usage (optional) - """ - self.provider_manager = provider_manager - self.llm_service = llm_service - self.html_converter = html2text.HTML2Text() - self.html_converter.ignore_links = False - self.html_converter.ignore_images = True - self.html_converter.ignore_emphasis = False - logger.info("Website analysis service initialized") - - async def analyze_website(self, url: str) -> dict[str, Any]: - """Analyze website to extract business information. - - Args: - url: Website URL to analyze - - Returns: - Dictionary with extracted business information: - - products: List of products/services - - niche: Target market/niche description - - ica: Ideal customer avatar description - - value_proposition: Value proposition statement - - Raises: - ValueError: If URL is invalid or unreachable - RuntimeError: If analysis fails - """ - logger.info("Starting website analysis", url=url) - - # Validate URL - self._validate_url(url) - - # Fetch website content - try: - html_content, page_title, meta_description = await self._fetch_website_content(url) - except Exception as e: - logger.error("Failed to fetch website content", url=url, error=str(e)) - raise ValueError(f"Could not fetch website content: {e!s}") from e - - # Extract and clean text content - text_content = self._extract_text_content(html_content) - - if not text_content or len(text_content.strip()) < 100: - raise ValueError( - "Could not extract meaningful content from website. " - "The website might be blocking automated access or have minimal content." - ) - - logger.info( - "Website content extracted", - url=url, - title=page_title, - content_length=len(text_content), - ) - - # Analyze content with LLM - try: - analysis_result = await self._analyze_with_llm( - url=url, - title=page_title, - description=meta_description, - content=text_content, - ) - except Exception as e: - logger.error("LLM analysis failed", url=url, error=str(e)) - raise RuntimeError(f"AI analysis failed: {e!s}") from e - - logger.info("Website analysis completed", url=url) - return analysis_result - - def _validate_url(self, url: str) -> None: - """Validate URL format and scheme. - - Args: - url: URL to validate - - Raises: - ValueError: If URL is invalid - """ - try: - parsed = urlparse(url) - if parsed.scheme not in ("http", "https"): - raise ValueError("URL must use http or https scheme") - if not parsed.netloc: - raise ValueError("URL must include a domain name") - - # Security: Block localhost and internal IPs - if any( - host in parsed.netloc.lower() - for host in ["localhost", "127.0.0.1", "0.0.0.0", "[::]", "169.254"] - ): - raise ValueError("Cannot analyze local or internal URLs") - - except Exception as e: - raise ValueError(f"Invalid URL: {e!s}") from e - - async def _fetch_website_content(self, url: str) -> tuple[str, str, str]: - """Fetch HTML content from URL. - - Args: - url: Website URL - - Returns: - Tuple of (html_content, page_title, meta_description) - - Raises: - requests.RequestException: If request fails - """ - headers = { - "User-Agent": USER_AGENT, - "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", - "Accept-Language": "en-US,en;q=0.9", - "Accept-Encoding": "gzip, deflate", - "DNT": "1", - "Connection": "close", - } - - try: - response = requests.get( - url, - headers=headers, - timeout=REQUEST_TIMEOUT, - allow_redirects=True, - verify=True, - ) - response.raise_for_status() - - # Parse HTML to extract metadata - soup = BeautifulSoup(response.text, "lxml") - - # Get page title - title_tag = soup.find("title") - page_title = title_tag.get_text().strip() if title_tag else "" - - # Get meta description - meta_desc = soup.find("meta", {"name": "description"}) - if not meta_desc: - meta_desc = soup.find("meta", {"property": "og:description"}) - meta_description = meta_desc.get("content", "").strip() if meta_desc else "" - - return response.text, page_title, meta_description - - except requests.Timeout as e: - raise RuntimeError(f"Request timed out after {REQUEST_TIMEOUT}s") from e - except requests.RequestException as e: - raise RuntimeError(f"Failed to fetch website: {e!s}") from e - - def _extract_text_content(self, html: str) -> str: - """Extract meaningful text content from HTML. - - Args: - html: Raw HTML content - - Returns: - Cleaned text content - """ - soup = BeautifulSoup(html, "lxml") - - # Remove script, style, and other non-content elements - for element in soup(["script", "style", "nav", "footer", "header", "aside"]): - element.decompose() - - # Convert to markdown-like text - text = self.html_converter.handle(str(soup)) - - # Clean up whitespace - text = re.sub(r"\n{3,}", "\n\n", text) # Max 2 consecutive newlines - text = re.sub(r" +", " ", text) # Collapse multiple spaces - text = text.strip() - - # Truncate if too long - if len(text) > MAX_CONTENT_LENGTH: - text = text[:MAX_CONTENT_LENGTH] + "\n\n[Content truncated...]" - - return text - - async def _analyze_with_llm( - self, - url: str, - title: str, - description: str, - content: str, - ) -> dict[str, Any]: - """Analyze website content using LLM. - - Args: - url: Website URL - title: Page title - description: Meta description - content: Extracted text content - - Returns: - Structured analysis results - """ - prompt = f"""Analyze this website and extract business information in JSON format. - -Website URL: {url} -Page Title: {title} -Meta Description: {description} - -Website Content: -{content} - -Extract and structure the following information: - -1. **products**: List of products/services offered. For each product, provide: - - id: Generate a unique identifier (lowercase, hyphenated) - - name: Product/service name - - problem: What problem it solves - -2. **niche**: Describe the target market and business niche (2-3 sentences) - -3. **ica**: Describe the Ideal Customer Avatar - who is this business serving? Include: - - Demographics (company size, industry, role) - - Pain points and challenges - - Goals and aspirations - -4. **value_proposition**: The main value proposition - what makes this business unique? (1-2 sentences) - -Return ONLY valid JSON with this exact structure: -{{ - "products": [ - {{"id": "product-1", "name": "Product Name", "problem": "Problem it solves"}} - ], - "niche": "Target market description", - "ica": "Ideal customer avatar description", - "value_proposition": "Unique value proposition" -}} - -Important: -- Be specific and based only on content found on the website -- If information is unclear, make reasonable inferences -- Keep descriptions concise but informative -- Ensure valid JSON output""" - - # Generate analysis using LLM - if self.llm_service: - # Use full LLM service if available - response_data = await self.llm_service.generate_single_shot_analysis( - topic="website_analysis", - user_input=prompt, - analysis_type="business_extraction", - ) - response_text = response_data.get("response", "") - elif self.provider_manager: - # Use provider manager directly for simpler usage - # Get the first available provider (usually Bedrock) - if not self.provider_manager._providers: - raise RuntimeError("No providers available in ProviderManager") - - provider = next(iter(self.provider_manager._providers.values())) - # Use invoke with LangChain messages - from langchain_core.messages import HumanMessage, SystemMessage - - messages = [ - SystemMessage( - content="You are a business analyst AI that extracts structured information from websites." - ), - HumanMessage(content=prompt), - ] - response_text = await provider.invoke(messages) - else: - raise RuntimeError("No LLM service or provider manager configured") - - # Parse JSON response - try: - # Extract JSON from response (handle markdown code blocks) - json_match = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", response_text, re.DOTALL) - if json_match: - json_text = json_match.group(1) - else: - # Try to find JSON object directly - json_match = re.search(r"\{.*\}", response_text, re.DOTALL) - if json_match: - json_text = json_match.group(0) - else: - raise ValueError("No JSON found in LLM response") - - analysis: dict[str, Any] = json.loads(json_text) - - # Validate required fields - required_fields = ["products", "niche", "ica", "value_proposition"] - for field in required_fields: - if field not in analysis: - logger.warning(f"Missing field in analysis: {field}") - analysis[field] = [] if field == "products" else "Not determined" - - return analysis - - except (json.JSONDecodeError, ValueError) as e: - logger.error( - "Failed to parse LLM response as JSON", error=str(e), response=response_text[:500] - ) - # Return fallback structure - return { - "products": [ - { - "id": "product-placeholder", - "name": "Primary Service/Product", - "problem": "Business challenge (details in website content)", - } - ], - "niche": f"Business serving customers in the {title} space", - "ica": "Professional organizations seeking business solutions", - "value_proposition": description or "Unique business value proposition", - } - - -__all__ = ["WebsiteAnalysisService"] +"""Website analysis service for extracting business information from websites.""" + +from __future__ import annotations + +import json +import re +from typing import Any +from urllib.parse import urlparse + +import html2text +import requests +import structlog +from bs4 import BeautifulSoup + +from coaching.src.llm.providers.manager import ProviderManager + +logger = structlog.get_logger() + +# Timeout for HTTP requests (seconds) +REQUEST_TIMEOUT = 15 + +# Maximum content length to analyze (characters) +MAX_CONTENT_LENGTH = 50000 + +# User agent to identify ourselves +USER_AGENT = "PurposePathBot/1.0 (Business Analysis; +https://purposepath.app)" + + +class WebsiteAnalysisService: + """Service for analyzing websites and extracting business information using AI. + + This service: + 1. Fetches and parses website content + 2. Extracts relevant text from HTML + 3. Uses LLM to analyze and structure business information + """ + + def __init__( + self, provider_manager: ProviderManager | None = None, llm_service: Any | None = None + ): + """Initialize website analysis service. + + Args: + provider_manager: Provider manager for direct LLM access (preferred for simple usage) + llm_service: Full LLM service for advanced usage (optional) + """ + self.provider_manager = provider_manager + self.llm_service = llm_service + self.html_converter = html2text.HTML2Text() + self.html_converter.ignore_links = False + self.html_converter.ignore_images = True + self.html_converter.ignore_emphasis = False + logger.info("Website analysis service initialized") + + async def analyze_website(self, url: str) -> dict[str, Any]: + """Analyze website to extract business information. + + Args: + url: Website URL to analyze + + Returns: + Dictionary with extracted business information: + - products: List of products/services + - niche: Target market/niche description + - ica: Ideal customer avatar description + - value_proposition: Value proposition statement + + Raises: + ValueError: If URL is invalid or unreachable + RuntimeError: If analysis fails + """ + logger.info("Starting website analysis", url=url) + + # Validate URL + self._validate_url(url) + + # Fetch website content + try: + html_content, page_title, meta_description = await self._fetch_website_content(url) + except Exception as e: + logger.error("Failed to fetch website content", url=url, error=str(e)) + raise ValueError(f"Could not fetch website content: {e!s}") from e + + # Extract and clean text content + text_content = self._extract_text_content(html_content) + + if not text_content or len(text_content.strip()) < 100: + raise ValueError( + "Could not extract meaningful content from website. " + "The website might be blocking automated access or have minimal content." + ) + + logger.info( + "Website content extracted", + url=url, + title=page_title, + content_length=len(text_content), + ) + + # Analyze content with LLM + try: + analysis_result = await self._analyze_with_llm( + url=url, + title=page_title, + description=meta_description, + content=text_content, + ) + except Exception as e: + logger.error("LLM analysis failed", url=url, error=str(e)) + raise RuntimeError(f"AI analysis failed: {e!s}") from e + + logger.info("Website analysis completed", url=url) + return analysis_result + + def _validate_url(self, url: str) -> None: + """Validate URL format and scheme. + + Args: + url: URL to validate + + Raises: + ValueError: If URL is invalid + """ + try: + parsed = urlparse(url) + if parsed.scheme not in ("http", "https"): + raise ValueError("URL must use http or https scheme") + if not parsed.netloc: + raise ValueError("URL must include a domain name") + + # Security: Block localhost and internal IPs + if any( + host in parsed.netloc.lower() + for host in ["localhost", "127.0.0.1", "0.0.0.0", "[::]", "169.254"] + ): + raise ValueError("Cannot analyze local or internal URLs") + + except Exception as e: + raise ValueError(f"Invalid URL: {e!s}") from e + + async def _fetch_website_content(self, url: str) -> tuple[str, str, str]: + """Fetch HTML content from URL. + + Args: + url: Website URL + + Returns: + Tuple of (html_content, page_title, meta_description) + + Raises: + requests.RequestException: If request fails + """ + headers = { + "User-Agent": USER_AGENT, + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-US,en;q=0.9", + "Accept-Encoding": "gzip, deflate", + "DNT": "1", + "Connection": "close", + } + + try: + response = requests.get( + url, + headers=headers, + timeout=REQUEST_TIMEOUT, + allow_redirects=True, + verify=True, + ) + response.raise_for_status() + + # Parse HTML to extract metadata + soup = BeautifulSoup(response.text, "lxml") + + # Get page title + title_tag = soup.find("title") + page_title = title_tag.get_text().strip() if title_tag else "" + + # Get meta description + meta_desc = soup.find("meta", {"name": "description"}) + if not meta_desc: + meta_desc = soup.find("meta", {"property": "og:description"}) + meta_description = meta_desc.get("content", "").strip() if meta_desc else "" + + return response.text, page_title, meta_description + + except requests.Timeout as e: + raise RuntimeError(f"Request timed out after {REQUEST_TIMEOUT}s") from e + except requests.RequestException as e: + raise RuntimeError(f"Failed to fetch website: {e!s}") from e + + def _extract_text_content(self, html: str) -> str: + """Extract meaningful text content from HTML. + + Args: + html: Raw HTML content + + Returns: + Cleaned text content + """ + soup = BeautifulSoup(html, "lxml") + + # Remove script, style, and other non-content elements + for element in soup(["script", "style", "nav", "footer", "header", "aside"]): + element.decompose() + + # Convert to markdown-like text + text = self.html_converter.handle(str(soup)) + + # Clean up whitespace + text = re.sub(r"\n{3,}", "\n\n", text) # Max 2 consecutive newlines + text = re.sub(r" +", " ", text) # Collapse multiple spaces + text = text.strip() + + # Truncate if too long + if len(text) > MAX_CONTENT_LENGTH: + text = text[:MAX_CONTENT_LENGTH] + "\n\n[Content truncated...]" + + return text + + async def _analyze_with_llm( + self, + url: str, + title: str, + description: str, + content: str, + ) -> dict[str, Any]: + """Analyze website content using LLM. + + Args: + url: Website URL + title: Page title + description: Meta description + content: Extracted text content + + Returns: + Structured analysis results + """ + prompt = f"""Analyze this website and extract business information in JSON format. + +Website URL: {url} +Page Title: {title} +Meta Description: {description} + +Website Content: +{content} + +Extract and structure the following information: + +1. **products**: List of products/services offered. For each product, provide: + - id: Generate a unique identifier (lowercase, hyphenated) + - name: Product/service name + - problem: What problem it solves + +2. **niche**: Describe the target market and business niche (2-3 sentences) + +3. **ica**: Describe the Ideal Customer Avatar - who is this business serving? Include: + - Demographics (company size, industry, role) + - Pain points and challenges + - Goals and aspirations + +4. **value_proposition**: The main value proposition - what makes this business unique? (1-2 sentences) + +Return ONLY valid JSON with this exact structure: +{{ + "products": [ + {{"id": "product-1", "name": "Product Name", "problem": "Problem it solves"}} + ], + "niche": "Target market description", + "ica": "Ideal customer avatar description", + "value_proposition": "Unique value proposition" +}} + +Important: +- Be specific and based only on content found on the website +- If information is unclear, make reasonable inferences +- Keep descriptions concise but informative +- Ensure valid JSON output""" + + # Generate analysis using LLM + if self.llm_service: + # Use full LLM service if available + response_data = await self.llm_service.generate_single_shot_analysis( + topic="website_analysis", + user_input=prompt, + analysis_type="business_extraction", + ) + response_text = response_data.get("response", "") + elif self.provider_manager: + # Use provider manager directly for simpler usage + # Get the first available provider (usually Bedrock) + if not self.provider_manager._providers: + raise RuntimeError("No providers available in ProviderManager") + + provider = next(iter(self.provider_manager._providers.values())) + # Use invoke with LangChain messages + from langchain_core.messages import HumanMessage, SystemMessage + + messages = [ + SystemMessage( + content="You are a business analyst AI that extracts structured information from websites." + ), + HumanMessage(content=prompt), + ] + response_text = await provider.invoke(messages) + else: + raise RuntimeError("No LLM service or provider manager configured") + + # Parse JSON response + try: + # Extract JSON from response (handle markdown code blocks) + json_match = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", response_text, re.DOTALL) + if json_match: + json_text = json_match.group(1) + else: + # Try to find JSON object directly + json_match = re.search(r"\{.*\}", response_text, re.DOTALL) + if json_match: + json_text = json_match.group(0) + else: + raise ValueError("No JSON found in LLM response") + + analysis: dict[str, Any] = json.loads(json_text) + + # Validate required fields + required_fields = ["products", "niche", "ica", "value_proposition"] + for field in required_fields: + if field not in analysis: + logger.warning(f"Missing field in analysis: {field}") + analysis[field] = [] if field == "products" else "Not determined" + + return analysis + + except (json.JSONDecodeError, ValueError) as e: + logger.error( + "Failed to parse LLM response as JSON", error=str(e), response=response_text[:500] + ) + # Return fallback structure + return { + "products": [ + { + "id": "product-placeholder", + "name": "Primary Service/Product", + "problem": "Business challenge (details in website content)", + } + ], + "niche": f"Business serving customers in the {title} space", + "ica": "Professional organizations seeking business solutions", + "value_proposition": description or "Unique business value proposition", + } + + +__all__ = ["WebsiteAnalysisService"] diff --git a/coaching/src/workflows/analysis_workflow.py b/coaching/src/workflows/analysis_workflow.py index 09743dcf..2baca092 100644 --- a/coaching/src/workflows/analysis_workflow.py +++ b/coaching/src/workflows/analysis_workflow.py @@ -1,185 +1,186 @@ -""" -Single-shot analysis workflow implementation. - -Implements a single-step analysis using LangGraph, -integrated with analysis services and domain models. -""" - -from __future__ import annotations - -from datetime import datetime -from typing import Any - -import structlog -from coaching.src.application.analysis.base_analysis_service import BaseAnalysisService -from coaching.src.core.constants import AnalysisType -from langgraph.graph import StateGraph -from pydantic import BaseModel, Field - -from .base import BaseWorkflow, WorkflowState, WorkflowStatus, WorkflowType - -logger = structlog.get_logger(__name__) - - -class AnalysisWorkflowInput(BaseModel): - """Input model for analysis workflow.""" - - workflow_id: str = Field(..., description="Workflow identifier") - user_id: str = Field(..., description="User identifier") - tenant_id: str = Field(..., description="Tenant identifier") - analysis_type: AnalysisType = Field(..., description="Type of analysis") - session_id: str | None = Field(default=None, description="Session identifier") - text_to_analyze: str = Field(..., description="Text/content to analyze") - context: dict[str, Any] = Field( - default_factory=dict, description="Additional context for analysis" - ) - - -class AnalysisWorkflowConfig(BaseModel): - """Configuration for analysis workflow.""" - - analysis_service: BaseAnalysisService - - model_config = {"arbitrary_types_allowed": True} - - -class AnalysisWorkflow(BaseWorkflow): - """Single-shot analysis workflow using LangGraph and analysis services.""" - - def __init__(self, config: Any, workflow_config: AnalysisWorkflowConfig): - """Initialize analysis workflow. - - Args: - config: Base workflow config - workflow_config: Analysis-specific configuration - """ - super().__init__(config) - self.workflow_config = workflow_config - - @property - def workflow_type(self) -> WorkflowType: - """Get the workflow type.""" - return WorkflowType.SINGLE_SHOT_ANALYSIS - - @property - def workflow_steps(self) -> list[str]: - """Get list of workflow step names.""" - return ["start", "analysis", "completion"] - - async def build_graph(self) -> StateGraph: - """Build the LangGraph workflow graph.""" - from langgraph.graph import END, START, StateGraph - - # Create graph with our state schema - # LangGraph prefers TypedDict but supports dict at runtime - graph = StateGraph(dict[str, Any]) - - # Add nodes (workflow steps) - graph.add_node("start", self._start_node) - graph.add_node("analysis", self._analysis_node) - graph.add_node("completion", self._completion_node) - - # Add edges (workflow flow) - graph.add_edge(START, "start") - graph.add_edge("start", "analysis") - graph.add_edge("analysis", "completion") - graph.add_edge("completion", END) - - return graph - - async def create_initial_state(self, user_input: dict[str, Any]) -> WorkflowState: - """Create initial workflow state from user input.""" - # Validate and parse input - workflow_input = AnalysisWorkflowInput(**user_input) - - return WorkflowState( - workflow_id=workflow_input.workflow_id, - workflow_type=self.workflow_type, - status=WorkflowStatus.RUNNING, - user_id=workflow_input.user_id, - session_id=workflow_input.session_id, - conversation_history=[], - current_step="start", - created_at=datetime.utcnow().isoformat(), - updated_at=datetime.utcnow().isoformat(), - workflow_context={ - "analysis_type": workflow_input.analysis_type.value, - "text_to_analyze": workflow_input.text_to_analyze, - "context": workflow_input.context, - }, - ) - - async def validate_state(self, state: WorkflowState) -> bool: - """Validate workflow state is consistent.""" - required_fields = ["workflow_id", "user_id", "workflow_type"] - for field in required_fields: - if not getattr(state, field, None): - return False - - if state.current_step not in self.workflow_steps: - return False - - # Validate required context fields - required_context = ["analysis_type", "text_to_analyze"] - return all(field in state.workflow_context for field in required_context) - - async def _start_node(self, state: dict[str, Any]) -> dict[str, Any]: - """Start node - begin analysis.""" - logger.info("Starting analysis workflow", workflow_id=state["workflow_id"]) - - state["current_step"] = "analysis" - state["updated_at"] = datetime.utcnow().isoformat() - - return state - - async def _analysis_node(self, state: dict[str, Any]) -> dict[str, Any]: - """Analysis node - perform the analysis using analysis service.""" - logger.info("Processing analysis", workflow_id=state["workflow_id"]) - - try: - # Get analysis context from workflow state - text_to_analyze = state["workflow_context"]["text_to_analyze"] - analysis_context = state["workflow_context"].get("context", {}) - - if not text_to_analyze: - raise ValueError("No text provided for analysis") - - # Add text to analysis context - analysis_context["current_actions"] = text_to_analyze - - # Perform analysis using the configured analysis service - result = await self.workflow_config.analysis_service.analyze(analysis_context) - - # Store results - state["results"] = { - "analysis": result, - "analysis_type": state["workflow_context"]["analysis_type"], - "input_text": text_to_analyze, - "timestamp": datetime.utcnow().isoformat(), - } - - state["current_step"] = "completion" - state["updated_at"] = datetime.utcnow().isoformat() - - logger.info( - "Analysis completed", - workflow_id=state["workflow_id"], - analysis_type=state["workflow_context"]["analysis_type"], - ) - - except Exception as e: - logger.error("Error in analysis", error=str(e), workflow_id=state["workflow_id"]) - state["status"] = WorkflowStatus.FAILED.value - state["metadata"]["error"] = str(e) - - return state - - async def _completion_node(self, state: dict[str, Any]) -> dict[str, Any]: - """Completion node - finalize analysis.""" - logger.info("Completing analysis workflow", workflow_id=state["workflow_id"]) - - state["status"] = WorkflowStatus.COMPLETED.value - state["completed_at"] = datetime.utcnow().isoformat() - state["updated_at"] = datetime.utcnow().isoformat() - - return state +""" +Single-shot analysis workflow implementation. + +Implements a single-step analysis using LangGraph, +integrated with analysis services and domain models. +""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any + +import structlog +from langgraph.graph import StateGraph +from pydantic import BaseModel, Field + +from coaching.src.application.analysis.base_analysis_service import BaseAnalysisService +from coaching.src.core.constants import AnalysisType + +from .base import BaseWorkflow, WorkflowState, WorkflowStatus, WorkflowType + +logger = structlog.get_logger(__name__) + + +class AnalysisWorkflowInput(BaseModel): + """Input model for analysis workflow.""" + + workflow_id: str = Field(..., description="Workflow identifier") + user_id: str = Field(..., description="User identifier") + tenant_id: str = Field(..., description="Tenant identifier") + analysis_type: AnalysisType = Field(..., description="Type of analysis") + session_id: str | None = Field(default=None, description="Session identifier") + text_to_analyze: str = Field(..., description="Text/content to analyze") + context: dict[str, Any] = Field( + default_factory=dict, description="Additional context for analysis" + ) + + +class AnalysisWorkflowConfig(BaseModel): + """Configuration for analysis workflow.""" + + analysis_service: BaseAnalysisService + + model_config = {"arbitrary_types_allowed": True} + + +class AnalysisWorkflow(BaseWorkflow): + """Single-shot analysis workflow using LangGraph and analysis services.""" + + def __init__(self, config: Any, workflow_config: AnalysisWorkflowConfig): + """Initialize analysis workflow. + + Args: + config: Base workflow config + workflow_config: Analysis-specific configuration + """ + super().__init__(config) + self.workflow_config = workflow_config + + @property + def workflow_type(self) -> WorkflowType: + """Get the workflow type.""" + return WorkflowType.SINGLE_SHOT_ANALYSIS + + @property + def workflow_steps(self) -> list[str]: + """Get list of workflow step names.""" + return ["start", "analysis", "completion"] + + async def build_graph(self) -> StateGraph: + """Build the LangGraph workflow graph.""" + from langgraph.graph import END, START, StateGraph + + # Create graph with our state schema + # LangGraph prefers TypedDict but supports dict at runtime + graph = StateGraph(dict[str, Any]) + + # Add nodes (workflow steps) + graph.add_node("start", self._start_node) + graph.add_node("analysis", self._analysis_node) + graph.add_node("completion", self._completion_node) + + # Add edges (workflow flow) + graph.add_edge(START, "start") + graph.add_edge("start", "analysis") + graph.add_edge("analysis", "completion") + graph.add_edge("completion", END) + + return graph + + async def create_initial_state(self, user_input: dict[str, Any]) -> WorkflowState: + """Create initial workflow state from user input.""" + # Validate and parse input + workflow_input = AnalysisWorkflowInput(**user_input) + + return WorkflowState( + workflow_id=workflow_input.workflow_id, + workflow_type=self.workflow_type, + status=WorkflowStatus.RUNNING, + user_id=workflow_input.user_id, + session_id=workflow_input.session_id, + conversation_history=[], + current_step="start", + created_at=datetime.utcnow().isoformat(), + updated_at=datetime.utcnow().isoformat(), + workflow_context={ + "analysis_type": workflow_input.analysis_type.value, + "text_to_analyze": workflow_input.text_to_analyze, + "context": workflow_input.context, + }, + ) + + async def validate_state(self, state: WorkflowState) -> bool: + """Validate workflow state is consistent.""" + required_fields = ["workflow_id", "user_id", "workflow_type"] + for field in required_fields: + if not getattr(state, field, None): + return False + + if state.current_step not in self.workflow_steps: + return False + + # Validate required context fields + required_context = ["analysis_type", "text_to_analyze"] + return all(field in state.workflow_context for field in required_context) + + async def _start_node(self, state: dict[str, Any]) -> dict[str, Any]: + """Start node - begin analysis.""" + logger.info("Starting analysis workflow", workflow_id=state["workflow_id"]) + + state["current_step"] = "analysis" + state["updated_at"] = datetime.utcnow().isoformat() + + return state + + async def _analysis_node(self, state: dict[str, Any]) -> dict[str, Any]: + """Analysis node - perform the analysis using analysis service.""" + logger.info("Processing analysis", workflow_id=state["workflow_id"]) + + try: + # Get analysis context from workflow state + text_to_analyze = state["workflow_context"]["text_to_analyze"] + analysis_context = state["workflow_context"].get("context", {}) + + if not text_to_analyze: + raise ValueError("No text provided for analysis") + + # Add text to analysis context + analysis_context["current_actions"] = text_to_analyze + + # Perform analysis using the configured analysis service + result = await self.workflow_config.analysis_service.analyze(analysis_context) + + # Store results + state["results"] = { + "analysis": result, + "analysis_type": state["workflow_context"]["analysis_type"], + "input_text": text_to_analyze, + "timestamp": datetime.utcnow().isoformat(), + } + + state["current_step"] = "completion" + state["updated_at"] = datetime.utcnow().isoformat() + + logger.info( + "Analysis completed", + workflow_id=state["workflow_id"], + analysis_type=state["workflow_context"]["analysis_type"], + ) + + except Exception as e: + logger.error("Error in analysis", error=str(e), workflow_id=state["workflow_id"]) + state["status"] = WorkflowStatus.FAILED.value + state["metadata"]["error"] = str(e) + + return state + + async def _completion_node(self, state: dict[str, Any]) -> dict[str, Any]: + """Completion node - finalize analysis.""" + logger.info("Completing analysis workflow", workflow_id=state["workflow_id"]) + + state["status"] = WorkflowStatus.COMPLETED.value + state["completed_at"] = datetime.utcnow().isoformat() + state["updated_at"] = datetime.utcnow().isoformat() + + return state diff --git a/coaching/src/workflows/coaching_workflow.py b/coaching/src/workflows/coaching_workflow.py index 4685302f..291f6b5b 100644 --- a/coaching/src/workflows/coaching_workflow.py +++ b/coaching/src/workflows/coaching_workflow.py @@ -1,322 +1,323 @@ -"""Conversational coaching workflow implementation. - -Implements a multi-step coaching conversation using LangGraph, -integrated with domain entities and application services. -""" - -from __future__ import annotations - -from datetime import datetime -from typing import Any - -import structlog -from coaching.src.application.conversation.conversation_service import ( - ConversationApplicationService, -) -from coaching.src.application.llm.llm_service import LLMApplicationService -from coaching.src.core.constants import CoachingTopic, MessageRole -from coaching.src.core.types import ConversationId, TenantId, UserId -from coaching.src.domain.ports.llm_provider_port import LLMMessage -from langgraph.graph import StateGraph -from pydantic import BaseModel, Field - -from .base import BaseWorkflow, WorkflowState, WorkflowStatus, WorkflowType - -logger = structlog.get_logger(__name__) - - -class CoachingWorkflowInput(BaseModel): - """Input model for coaching workflow.""" - - workflow_id: str = Field(..., description="Workflow identifier") - user_id: str = Field(..., description="User identifier") - tenant_id: str = Field(..., description="Tenant identifier") - topic: CoachingTopic = Field(..., description="Coaching topic") - session_id: str | None = Field(default=None, description="Session identifier") - initial_message: str | None = Field(default=None, description="Initial user message (optional)") - - -class CoachingWorkflowConfig(BaseModel): - """Configuration for coaching workflow.""" - - conversation_service: ConversationApplicationService - llm_service: LLMApplicationService - temperature: float = Field(default=0.7, ge=0.0, le=2.0) - max_tokens: int | None = Field(default=None, gt=0) - - model_config = {"arbitrary_types_allowed": True} - - -class CoachingWorkflow(BaseWorkflow): - """Conversational coaching workflow using LangGraph and domain services.""" - - def __init__(self, config: Any, workflow_config: CoachingWorkflowConfig): - """Initialize coaching workflow. - - Args: - config: Base workflow config - workflow_config: Coaching-specific configuration - """ - super().__init__(config) - self.workflow_config = workflow_config - - @property - def workflow_type(self) -> WorkflowType: - """Get the workflow type.""" - return WorkflowType.CONVERSATIONAL_COACHING - - @property - def workflow_steps(self) -> list[str]: - """Get list of workflow step names.""" - return [ - "start", - "initial_assessment", - "goal_exploration", - "action_planning", - "reflection", - "next_steps", - "completion", - ] - - async def build_graph(self) -> StateGraph: - """Build the LangGraph workflow graph.""" - from langgraph.graph import END, START, StateGraph - - # Create graph with our state schema - # LangGraph prefers TypedDict but supports dict at runtime - graph = StateGraph(dict[str, Any]) - - # Add nodes (workflow steps) - graph.add_node("start", self._start_node) - graph.add_node("initial_assessment", self._initial_assessment_node) - graph.add_node("goal_exploration", self._goal_exploration_node) - graph.add_node("action_planning", self._action_planning_node) - graph.add_node("reflection", self._reflection_node) - graph.add_node("next_steps", self._next_steps_node) - graph.add_node("completion", self._completion_node) - - # Add edges (workflow flow) - graph.add_edge(START, "start") - graph.add_edge("start", "initial_assessment") - graph.add_edge("initial_assessment", "goal_exploration") - graph.add_edge("goal_exploration", "action_planning") - graph.add_edge("action_planning", "reflection") - graph.add_edge("reflection", "next_steps") - graph.add_edge("next_steps", "completion") - graph.add_edge("completion", END) - - return graph - - async def create_initial_state(self, user_input: dict[str, Any]) -> WorkflowState: - """Create initial workflow state from user input.""" - # Validate and parse input - workflow_input = CoachingWorkflowInput(**user_input) - - # Create conversation using domain service - initial_greeting = ( - f"Welcome to your {workflow_input.topic.value} coaching session! " - "I'm here to help you explore your thoughts and create actionable insights. " - "What would you like to focus on today?" - ) - - conversation = await self.workflow_config.conversation_service.start_conversation( - user_id=UserId(workflow_input.user_id), - tenant_id=TenantId(workflow_input.tenant_id), - topic=workflow_input.topic, - initial_message_content=initial_greeting, - metadata={ - "workflow_id": workflow_input.workflow_id, - "session_id": workflow_input.session_id, - }, - ) - - # Add initial user message if provided - if workflow_input.initial_message: - conversation = await self.workflow_config.conversation_service.add_message( - conversation_id=conversation.conversation_id, - tenant_id=TenantId(workflow_input.tenant_id), - role=MessageRole.USER, - content=workflow_input.initial_message, - ) - - return WorkflowState( - workflow_id=workflow_input.workflow_id, - workflow_type=self.workflow_type, - status=WorkflowStatus.RUNNING, - user_id=workflow_input.user_id, - session_id=workflow_input.session_id, - conversation_history=[], # Managed by conversation entity - current_step="start", - created_at=datetime.utcnow().isoformat(), - updated_at=datetime.utcnow().isoformat(), - workflow_context={ - "conversation_id": conversation.conversation_id, - "topic": workflow_input.topic.value, - }, - ) - - async def validate_state(self, state: WorkflowState) -> bool: - """Validate workflow state is consistent.""" - required_fields = ["workflow_id", "user_id", "workflow_type"] - for field in required_fields: - if not getattr(state, field, None): - return False - - if state.current_step not in self.workflow_steps: - return False - - # Validate conversation_id exists in context - return "conversation_id" in state.workflow_context - - def _get_conversation_id(self, state: dict[str, Any]) -> ConversationId: - """Extract conversation ID from state.""" - return ConversationId(state["workflow_context"]["conversation_id"]) - - def _get_tenant_id(self, state: dict[str, Any]) -> TenantId: - """Extract tenant ID from state.""" - return TenantId(state.get("tenant_id", state["user_id"])) - - async def _start_node(self, state: dict[str, Any]) -> dict[str, Any]: - """Start node - welcome the user and begin coaching.""" - logger.info("Starting coaching workflow", workflow_id=state["workflow_id"]) - - # Conversation already initialized in create_initial_state - state["current_step"] = "initial_assessment" - state["status"] = WorkflowStatus.WAITING_INPUT.value - state["updated_at"] = datetime.utcnow().isoformat() - - return state - - async def _initial_assessment_node(self, state: dict[str, Any]) -> dict[str, Any]: - """Initial assessment - understand the user's current situation.""" - logger.info("Processing initial assessment", workflow_id=state["workflow_id"]) - - try: - conversation_id = self._get_conversation_id(state) - tenant_id = self._get_tenant_id(state) - - # Retrieve current conversation - conversation = await self.workflow_config.conversation_service.get_conversation( - conversation_id, tenant_id - ) - - # Get user messages - user_messages = [msg for msg in conversation.messages if msg.is_from_user()] - if not user_messages: - # Still waiting for user input - return state - - latest_message = user_messages[-1] - - # Build conversation history for LLM - llm_messages = [ - LLMMessage(role=msg.role.value, content=msg.content) - for msg in conversation.messages - ] - - # Generate coaching response - system_prompt = """You are an expert life coach. Your role is to help the user explore their situation with thoughtful questions and gentle guidance. - -In this initial assessment phase: -1. Acknowledge what the user has shared -2. Ask 1-2 clarifying questions to better understand their situation -3. Be warm, supportive, and non-judgmental -4. Keep your response focused and conversational (2-3 sentences max) - -Do not give advice yet - focus on understanding first.""" - - response = await self.workflow_config.llm_service.generate_coaching_response( - conversation_history=llm_messages, - system_prompt=system_prompt, - temperature=self.workflow_config.temperature, - max_tokens=self.workflow_config.max_tokens, - ) - - # Add response to conversation - await self.workflow_config.conversation_service.add_message( - conversation_id=conversation_id, - tenant_id=tenant_id, - role=MessageRole.ASSISTANT, - content=response.content, - ) - - state["current_step"] = "goal_exploration" - state["step_data"]["initial_focus"] = latest_message.content - state["updated_at"] = datetime.utcnow().isoformat() - - except Exception as e: - logger.error( - "Error in initial assessment", error=str(e), workflow_id=state["workflow_id"] - ) - state["status"] = WorkflowStatus.FAILED.value - state["metadata"]["error"] = str(e) - - return state - - async def _goal_exploration_node(self, state: dict[str, Any]) -> dict[str, Any]: - """Goal exploration - help user clarify their goals.""" - logger.info("Processing goal exploration", workflow_id=state["workflow_id"]) - - # Placeholder implementation - state["current_step"] = "action_planning" - return state - - async def _action_planning_node(self, state: dict[str, Any]) -> dict[str, Any]: - """Action planning - create concrete next steps.""" - logger.info("Processing action planning", workflow_id=state["workflow_id"]) - - # Placeholder implementation - state["current_step"] = "reflection" - return state - - async def _reflection_node(self, state: dict[str, Any]) -> dict[str, Any]: - """Reflection - help user reflect on insights.""" - logger.info("Processing reflection", workflow_id=state["workflow_id"]) - - # Placeholder implementation - state["current_step"] = "next_steps" - return state - - async def _next_steps_node(self, state: dict[str, Any]) -> dict[str, Any]: - """Next steps - summarize and plan follow-up.""" - logger.info("Processing next steps", workflow_id=state["workflow_id"]) - - # Placeholder implementation - state["current_step"] = "completion" - return state - - async def _completion_node(self, state: dict[str, Any]) -> dict[str, Any]: - """Completion - wrap up the session.""" - logger.info("Completing coaching workflow", workflow_id=state["workflow_id"]) - - try: - conversation_id = self._get_conversation_id(state) - tenant_id = self._get_tenant_id(state) - - # Add completion message - completion_content = ( - "Thank you for this coaching session! I hope you found it valuable. " - "Remember, you can always come back when you're ready to explore further " - "or check in on your progress." - ) - - await self.workflow_config.conversation_service.add_message( - conversation_id=conversation_id, - tenant_id=tenant_id, - role=MessageRole.ASSISTANT, - content=completion_content, - ) - - # Complete the conversation - await self.workflow_config.conversation_service.complete_conversation( - conversation_id=conversation_id, tenant_id=tenant_id - ) - - except Exception as e: - logger.error("Error completing workflow", error=str(e)) - - state["status"] = WorkflowStatus.COMPLETED.value - state["completed_at"] = datetime.utcnow().isoformat() - state["updated_at"] = datetime.utcnow().isoformat() - - return state +"""Conversational coaching workflow implementation. + +Implements a multi-step coaching conversation using LangGraph, +integrated with domain entities and application services. +""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any + +import structlog +from langgraph.graph import StateGraph +from pydantic import BaseModel, Field + +from coaching.src.application.conversation.conversation_service import ( + ConversationApplicationService, +) +from coaching.src.application.llm.llm_service import LLMApplicationService +from coaching.src.core.constants import CoachingTopic, MessageRole +from coaching.src.core.types import ConversationId, TenantId, UserId +from coaching.src.domain.ports.llm_provider_port import LLMMessage + +from .base import BaseWorkflow, WorkflowState, WorkflowStatus, WorkflowType + +logger = structlog.get_logger(__name__) + + +class CoachingWorkflowInput(BaseModel): + """Input model for coaching workflow.""" + + workflow_id: str = Field(..., description="Workflow identifier") + user_id: str = Field(..., description="User identifier") + tenant_id: str = Field(..., description="Tenant identifier") + topic: CoachingTopic = Field(..., description="Coaching topic") + session_id: str | None = Field(default=None, description="Session identifier") + initial_message: str | None = Field(default=None, description="Initial user message (optional)") + + +class CoachingWorkflowConfig(BaseModel): + """Configuration for coaching workflow.""" + + conversation_service: ConversationApplicationService + llm_service: LLMApplicationService + temperature: float = Field(default=0.7, ge=0.0, le=2.0) + max_tokens: int | None = Field(default=None, gt=0) + + model_config = {"arbitrary_types_allowed": True} + + +class CoachingWorkflow(BaseWorkflow): + """Conversational coaching workflow using LangGraph and domain services.""" + + def __init__(self, config: Any, workflow_config: CoachingWorkflowConfig): + """Initialize coaching workflow. + + Args: + config: Base workflow config + workflow_config: Coaching-specific configuration + """ + super().__init__(config) + self.workflow_config = workflow_config + + @property + def workflow_type(self) -> WorkflowType: + """Get the workflow type.""" + return WorkflowType.CONVERSATIONAL_COACHING + + @property + def workflow_steps(self) -> list[str]: + """Get list of workflow step names.""" + return [ + "start", + "initial_assessment", + "goal_exploration", + "action_planning", + "reflection", + "next_steps", + "completion", + ] + + async def build_graph(self) -> StateGraph: + """Build the LangGraph workflow graph.""" + from langgraph.graph import END, START, StateGraph + + # Create graph with our state schema + # LangGraph prefers TypedDict but supports dict at runtime + graph = StateGraph(dict[str, Any]) + + # Add nodes (workflow steps) + graph.add_node("start", self._start_node) + graph.add_node("initial_assessment", self._initial_assessment_node) + graph.add_node("goal_exploration", self._goal_exploration_node) + graph.add_node("action_planning", self._action_planning_node) + graph.add_node("reflection", self._reflection_node) + graph.add_node("next_steps", self._next_steps_node) + graph.add_node("completion", self._completion_node) + + # Add edges (workflow flow) + graph.add_edge(START, "start") + graph.add_edge("start", "initial_assessment") + graph.add_edge("initial_assessment", "goal_exploration") + graph.add_edge("goal_exploration", "action_planning") + graph.add_edge("action_planning", "reflection") + graph.add_edge("reflection", "next_steps") + graph.add_edge("next_steps", "completion") + graph.add_edge("completion", END) + + return graph + + async def create_initial_state(self, user_input: dict[str, Any]) -> WorkflowState: + """Create initial workflow state from user input.""" + # Validate and parse input + workflow_input = CoachingWorkflowInput(**user_input) + + # Create conversation using domain service + initial_greeting = ( + f"Welcome to your {workflow_input.topic.value} coaching session! " + "I'm here to help you explore your thoughts and create actionable insights. " + "What would you like to focus on today?" + ) + + conversation = await self.workflow_config.conversation_service.start_conversation( + user_id=UserId(workflow_input.user_id), + tenant_id=TenantId(workflow_input.tenant_id), + topic=workflow_input.topic, + initial_message_content=initial_greeting, + metadata={ + "workflow_id": workflow_input.workflow_id, + "session_id": workflow_input.session_id, + }, + ) + + # Add initial user message if provided + if workflow_input.initial_message: + conversation = await self.workflow_config.conversation_service.add_message( + conversation_id=conversation.conversation_id, + tenant_id=TenantId(workflow_input.tenant_id), + role=MessageRole.USER, + content=workflow_input.initial_message, + ) + + return WorkflowState( + workflow_id=workflow_input.workflow_id, + workflow_type=self.workflow_type, + status=WorkflowStatus.RUNNING, + user_id=workflow_input.user_id, + session_id=workflow_input.session_id, + conversation_history=[], # Managed by conversation entity + current_step="start", + created_at=datetime.utcnow().isoformat(), + updated_at=datetime.utcnow().isoformat(), + workflow_context={ + "conversation_id": conversation.conversation_id, + "topic": workflow_input.topic.value, + }, + ) + + async def validate_state(self, state: WorkflowState) -> bool: + """Validate workflow state is consistent.""" + required_fields = ["workflow_id", "user_id", "workflow_type"] + for field in required_fields: + if not getattr(state, field, None): + return False + + if state.current_step not in self.workflow_steps: + return False + + # Validate conversation_id exists in context + return "conversation_id" in state.workflow_context + + def _get_conversation_id(self, state: dict[str, Any]) -> ConversationId: + """Extract conversation ID from state.""" + return ConversationId(state["workflow_context"]["conversation_id"]) + + def _get_tenant_id(self, state: dict[str, Any]) -> TenantId: + """Extract tenant ID from state.""" + return TenantId(state.get("tenant_id", state["user_id"])) + + async def _start_node(self, state: dict[str, Any]) -> dict[str, Any]: + """Start node - welcome the user and begin coaching.""" + logger.info("Starting coaching workflow", workflow_id=state["workflow_id"]) + + # Conversation already initialized in create_initial_state + state["current_step"] = "initial_assessment" + state["status"] = WorkflowStatus.WAITING_INPUT.value + state["updated_at"] = datetime.utcnow().isoformat() + + return state + + async def _initial_assessment_node(self, state: dict[str, Any]) -> dict[str, Any]: + """Initial assessment - understand the user's current situation.""" + logger.info("Processing initial assessment", workflow_id=state["workflow_id"]) + + try: + conversation_id = self._get_conversation_id(state) + tenant_id = self._get_tenant_id(state) + + # Retrieve current conversation + conversation = await self.workflow_config.conversation_service.get_conversation( + conversation_id, tenant_id + ) + + # Get user messages + user_messages = [msg for msg in conversation.messages if msg.is_from_user()] + if not user_messages: + # Still waiting for user input + return state + + latest_message = user_messages[-1] + + # Build conversation history for LLM + llm_messages = [ + LLMMessage(role=msg.role.value, content=msg.content) + for msg in conversation.messages + ] + + # Generate coaching response + system_prompt = """You are an expert life coach. Your role is to help the user explore their situation with thoughtful questions and gentle guidance. + +In this initial assessment phase: +1. Acknowledge what the user has shared +2. Ask 1-2 clarifying questions to better understand their situation +3. Be warm, supportive, and non-judgmental +4. Keep your response focused and conversational (2-3 sentences max) + +Do not give advice yet - focus on understanding first.""" + + response = await self.workflow_config.llm_service.generate_coaching_response( + conversation_history=llm_messages, + system_prompt=system_prompt, + temperature=self.workflow_config.temperature, + max_tokens=self.workflow_config.max_tokens, + ) + + # Add response to conversation + await self.workflow_config.conversation_service.add_message( + conversation_id=conversation_id, + tenant_id=tenant_id, + role=MessageRole.ASSISTANT, + content=response.content, + ) + + state["current_step"] = "goal_exploration" + state["step_data"]["initial_focus"] = latest_message.content + state["updated_at"] = datetime.utcnow().isoformat() + + except Exception as e: + logger.error( + "Error in initial assessment", error=str(e), workflow_id=state["workflow_id"] + ) + state["status"] = WorkflowStatus.FAILED.value + state["metadata"]["error"] = str(e) + + return state + + async def _goal_exploration_node(self, state: dict[str, Any]) -> dict[str, Any]: + """Goal exploration - help user clarify their goals.""" + logger.info("Processing goal exploration", workflow_id=state["workflow_id"]) + + # Placeholder implementation + state["current_step"] = "action_planning" + return state + + async def _action_planning_node(self, state: dict[str, Any]) -> dict[str, Any]: + """Action planning - create concrete next steps.""" + logger.info("Processing action planning", workflow_id=state["workflow_id"]) + + # Placeholder implementation + state["current_step"] = "reflection" + return state + + async def _reflection_node(self, state: dict[str, Any]) -> dict[str, Any]: + """Reflection - help user reflect on insights.""" + logger.info("Processing reflection", workflow_id=state["workflow_id"]) + + # Placeholder implementation + state["current_step"] = "next_steps" + return state + + async def _next_steps_node(self, state: dict[str, Any]) -> dict[str, Any]: + """Next steps - summarize and plan follow-up.""" + logger.info("Processing next steps", workflow_id=state["workflow_id"]) + + # Placeholder implementation + state["current_step"] = "completion" + return state + + async def _completion_node(self, state: dict[str, Any]) -> dict[str, Any]: + """Completion - wrap up the session.""" + logger.info("Completing coaching workflow", workflow_id=state["workflow_id"]) + + try: + conversation_id = self._get_conversation_id(state) + tenant_id = self._get_tenant_id(state) + + # Add completion message + completion_content = ( + "Thank you for this coaching session! I hope you found it valuable. " + "Remember, you can always come back when you're ready to explore further " + "or check in on your progress." + ) + + await self.workflow_config.conversation_service.add_message( + conversation_id=conversation_id, + tenant_id=tenant_id, + role=MessageRole.ASSISTANT, + content=completion_content, + ) + + # Complete the conversation + await self.workflow_config.conversation_service.complete_conversation( + conversation_id=conversation_id, tenant_id=tenant_id + ) + + except Exception as e: + logger.error("Error completing workflow", error=str(e)) + + state["status"] = WorkflowStatus.COMPLETED.value + state["completed_at"] = datetime.utcnow().isoformat() + state["updated_at"] = datetime.utcnow().isoformat() + + return state diff --git a/coaching/tests/e2e/test_google_vertex_e2e.py b/coaching/tests/e2e/test_google_vertex_e2e.py index b444e218..a26004da 100644 --- a/coaching/tests/e2e/test_google_vertex_e2e.py +++ b/coaching/tests/e2e/test_google_vertex_e2e.py @@ -1,88 +1,89 @@ -"""E2E test for Google Vertex AI with credentials from AWS Secrets Manager.""" - -import os - -import pytest -from coaching.src.core.config_multitenant import get_google_vertex_credentials, get_settings -from coaching.src.domain.ports.llm_provider_port import LLMMessage -from coaching.src.infrastructure.llm.google_vertex_provider import GoogleVertexLLMProvider - -pytestmark = pytest.mark.skipif( - not os.getenv("RUN_VERTEX_E2E"), - reason="Google Vertex E2E disabled by default; set RUN_VERTEX_E2E=1 to run", -) - - -@pytest.mark.e2e -@pytest.mark.asyncio -async def test_gemini_with_secrets_manager_credentials() -> None: - """ - Test Gemini model with credentials loaded from AWS Secrets Manager. - - This test verifies: - 1. Credentials are correctly loaded from Secrets Manager - 2. GoogleVertexLLMProvider initializes with those credentials - 3. Gemini model responds correctly - """ - # Get credentials from Secrets Manager - settings = get_settings() - creds = get_google_vertex_credentials() - - assert creds is not None, "Google Vertex credentials not found in Secrets Manager" - assert "project_id" in creds, "Credentials missing project_id" - assert "client_email" in creds, "Credentials missing client_email" - - project_id = creds.get("project_id") - location = settings.google_vertex_location - - # Initialize provider - provider = GoogleVertexLLMProvider(project_id=project_id, location=location) - - # Test with Gemini 2.5 Flash (faster for testing) - messages = [LLMMessage(role="user", content="What is 2+2? Reply with just the number.")] - - response = await provider.generate( - messages=messages, - model="gemini-2.5-flash", - temperature=0.5, - max_tokens=50, - ) - - assert response.content, "Response content is empty" - assert "4" in response.content, f"Expected '4' in response, got: {response.content}" - assert response.model == "gemini-2.5-flash" - assert response.provider == "google_vertex" - assert response.usage["total_tokens"] > 0 - - -@pytest.mark.e2e -@pytest.mark.asyncio -async def test_gemini_25_pro_with_secrets_manager() -> None: - """Test Gemini 2.5 Pro model.""" - creds = get_google_vertex_credentials() - assert creds is not None, "Google Vertex credentials not found" - - settings = get_settings() - provider = GoogleVertexLLMProvider( - project_id=creds.get("project_id"), - location=settings.google_vertex_location, - ) - - messages = [ - LLMMessage( - role="user", - content="Explain quantum computing in exactly one sentence.", - ) - ] - - response = await provider.generate( - messages=messages, - model="gemini-2.5-pro", - temperature=0.7, - max_tokens=100, - ) - - assert response.content - assert len(response.content) > 20 - assert response.model == "gemini-2.5-pro" - assert response.provider == "google_vertex" +"""E2E test for Google Vertex AI with credentials from AWS Secrets Manager.""" + +import os + +import pytest + +from coaching.src.core.config_multitenant import get_google_vertex_credentials, get_settings +from coaching.src.domain.ports.llm_provider_port import LLMMessage +from coaching.src.infrastructure.llm.google_vertex_provider import GoogleVertexLLMProvider + +pytestmark = pytest.mark.skipif( + not os.getenv("RUN_VERTEX_E2E"), + reason="Google Vertex E2E disabled by default; set RUN_VERTEX_E2E=1 to run", +) + + +@pytest.mark.e2e +@pytest.mark.asyncio +async def test_gemini_with_secrets_manager_credentials() -> None: + """ + Test Gemini model with credentials loaded from AWS Secrets Manager. + + This test verifies: + 1. Credentials are correctly loaded from Secrets Manager + 2. GoogleVertexLLMProvider initializes with those credentials + 3. Gemini model responds correctly + """ + # Get credentials from Secrets Manager + settings = get_settings() + creds = get_google_vertex_credentials() + + assert creds is not None, "Google Vertex credentials not found in Secrets Manager" + assert "project_id" in creds, "Credentials missing project_id" + assert "client_email" in creds, "Credentials missing client_email" + + project_id = creds.get("project_id") + location = settings.google_vertex_location + + # Initialize provider + provider = GoogleVertexLLMProvider(project_id=project_id, location=location) + + # Test with Gemini 2.5 Flash (faster for testing) + messages = [LLMMessage(role="user", content="What is 2+2? Reply with just the number.")] + + response = await provider.generate( + messages=messages, + model="gemini-2.5-flash", + temperature=0.5, + max_tokens=50, + ) + + assert response.content, "Response content is empty" + assert "4" in response.content, f"Expected '4' in response, got: {response.content}" + assert response.model == "gemini-2.5-flash" + assert response.provider == "google_vertex" + assert response.usage["total_tokens"] > 0 + + +@pytest.mark.e2e +@pytest.mark.asyncio +async def test_gemini_25_pro_with_secrets_manager() -> None: + """Test Gemini 2.5 Pro model.""" + creds = get_google_vertex_credentials() + assert creds is not None, "Google Vertex credentials not found" + + settings = get_settings() + provider = GoogleVertexLLMProvider( + project_id=creds.get("project_id"), + location=settings.google_vertex_location, + ) + + messages = [ + LLMMessage( + role="user", + content="Explain quantum computing in exactly one sentence.", + ) + ] + + response = await provider.generate( + messages=messages, + model="gemini-2.5-pro", + temperature=0.7, + max_tokens=100, + ) + + assert response.content + assert len(response.content) > 20 + assert response.model == "gemini-2.5-pro" + assert response.provider == "google_vertex" diff --git a/coaching/tests/e2e/test_inference_profile_models.py b/coaching/tests/e2e/test_inference_profile_models.py index 2f51faf5..0d00e46c 100644 --- a/coaching/tests/e2e/test_inference_profile_models.py +++ b/coaching/tests/e2e/test_inference_profile_models.py @@ -1,71 +1,72 @@ -"""E2E tests for Bedrock models requiring inference profiles. - -These tests verify that the inference profile resolution works correctly -for newer Claude models that require region-prefixed model IDs. -""" - -import pytest -from coaching.src.domain.ports.llm_provider_port import LLMMessage -from coaching.src.infrastructure.llm.bedrock_provider import BedrockLLMProvider - - -@pytest.mark.e2e -@pytest.mark.asyncio -async def test_claude_35_sonnet_v2_inference_profile(check_aws_credentials: None) -> None: - """ - Test Claude 3.5 Sonnet v2 which requires inference profiles. - - This model previously failed with: - ValidationException: Invocation of model ID anthropic.claude-3-5-sonnet-20241022-v2:0 - with on-demand throughput isn't supported. - - The BedrockLLMProvider should automatically convert this to the inference - profile format: us.anthropic.claude-3-5-sonnet-20241022-v2:0 - """ - import boto3 - - bedrock_client = boto3.client("bedrock-runtime", region_name="us-east-1") - provider = BedrockLLMProvider(bedrock_client=bedrock_client, region="us-east-1") - - messages = [LLMMessage(role="user", content="What is 2+2? Reply with just the number.")] - - # This should work now with inference profile auto-resolution - response = await provider.generate( - messages=messages, - model="anthropic.claude-3-5-sonnet-20241022-v2:0", # Base model ID - temperature=0.5, - max_tokens=50, - ) - - assert response.content - assert "4" in response.content - assert response.model == "anthropic.claude-3-5-sonnet-20241022-v2:0" - assert response.provider == "bedrock" - assert response.usage["total_tokens"] > 0 - - -@pytest.mark.e2e -@pytest.mark.asyncio -async def test_claude_35_sonnet_v2_with_explicit_prefix(check_aws_credentials: None) -> None: - """ - Test Claude 3.5 Sonnet v2 with explicit inference profile prefix. - - When the model ID already has a region prefix, it should be used as-is. - """ - import boto3 - - bedrock_client = boto3.client("bedrock-runtime", region_name="us-east-1") - provider = BedrockLLMProvider(bedrock_client=bedrock_client, region="us-east-1") - - messages = [LLMMessage(role="user", content="What is the capital of France? One word only.")] - - response = await provider.generate( - messages=messages, - model="us.anthropic.claude-3-5-sonnet-20241022-v2:0", # Explicit prefix - temperature=0.5, - max_tokens=50, - ) - - assert response.content - assert "paris" in response.content.lower() - assert response.provider == "bedrock" +"""E2E tests for Bedrock models requiring inference profiles. + +These tests verify that the inference profile resolution works correctly +for newer Claude models that require region-prefixed model IDs. +""" + +import pytest + +from coaching.src.domain.ports.llm_provider_port import LLMMessage +from coaching.src.infrastructure.llm.bedrock_provider import BedrockLLMProvider + + +@pytest.mark.e2e +@pytest.mark.asyncio +async def test_claude_35_sonnet_v2_inference_profile(check_aws_credentials: None) -> None: + """ + Test Claude 3.5 Sonnet v2 which requires inference profiles. + + This model previously failed with: + ValidationException: Invocation of model ID anthropic.claude-3-5-sonnet-20241022-v2:0 + with on-demand throughput isn't supported. + + The BedrockLLMProvider should automatically convert this to the inference + profile format: us.anthropic.claude-3-5-sonnet-20241022-v2:0 + """ + import boto3 + + bedrock_client = boto3.client("bedrock-runtime", region_name="us-east-1") + provider = BedrockLLMProvider(bedrock_client=bedrock_client, region="us-east-1") + + messages = [LLMMessage(role="user", content="What is 2+2? Reply with just the number.")] + + # This should work now with inference profile auto-resolution + response = await provider.generate( + messages=messages, + model="anthropic.claude-3-5-sonnet-20241022-v2:0", # Base model ID + temperature=0.5, + max_tokens=50, + ) + + assert response.content + assert "4" in response.content + assert response.model == "anthropic.claude-3-5-sonnet-20241022-v2:0" + assert response.provider == "bedrock" + assert response.usage["total_tokens"] > 0 + + +@pytest.mark.e2e +@pytest.mark.asyncio +async def test_claude_35_sonnet_v2_with_explicit_prefix(check_aws_credentials: None) -> None: + """ + Test Claude 3.5 Sonnet v2 with explicit inference profile prefix. + + When the model ID already has a region prefix, it should be used as-is. + """ + import boto3 + + bedrock_client = boto3.client("bedrock-runtime", region_name="us-east-1") + provider = BedrockLLMProvider(bedrock_client=bedrock_client, region="us-east-1") + + messages = [LLMMessage(role="user", content="What is the capital of France? One word only.")] + + response = await provider.generate( + messages=messages, + model="us.anthropic.claude-3-5-sonnet-20241022-v2:0", # Explicit prefix + temperature=0.5, + max_tokens=50, + ) + + assert response.content + assert "paris" in response.content.lower() + assert response.provider == "bedrock" diff --git a/coaching/tests/e2e/test_llm_providers_e2e.py b/coaching/tests/e2e/test_llm_providers_e2e.py index bf75e160..6ba9c256 100644 --- a/coaching/tests/e2e/test_llm_providers_e2e.py +++ b/coaching/tests/e2e/test_llm_providers_e2e.py @@ -1,335 +1,336 @@ -"""End-to-end tests for LLM provider implementations with real models. - -Tests direct provider calls to validate: -- Claude 3.5 Sonnet v2 (Bedrock) -- Claude Sonnet 4.5 (Bedrock) -- GPT-5 series (OpenAI) -- Gemini 2.5 Pro (Google Vertex AI) -""" - -import os - -import pytest -from coaching.src.domain.ports.llm_provider_port import LLMMessage -from coaching.src.infrastructure.llm.bedrock_provider import BedrockLLMProvider -from coaching.src.infrastructure.llm.google_vertex_provider import GoogleVertexLLMProvider -from coaching.src.infrastructure.llm.openai_provider import OpenAILLMProvider - - -@pytest.mark.e2e -@pytest.mark.asyncio -async def test_claude_35_sonnet_v2_real_generation(check_aws_credentials: None) -> None: - """ - Test Claude 3.5 Sonnet v2 real generation via Bedrock. - - Validates: - - Provider connects successfully - - Response generated - - Usage metrics returned - """ - import boto3 - - bedrock_client = boto3.client("bedrock-runtime", region_name="us-east-1") - provider = BedrockLLMProvider(bedrock_client=bedrock_client, region="us-east-1") - - messages = [ - LLMMessage(role="user", content="Explain quantum computing in exactly 2 sentences.") - ] - - response = await provider.generate( - messages=messages, - model="anthropic.claude-3-5-sonnet-20240620-v1:0", - temperature=0.7, - max_tokens=100, - ) - - assert response.content - assert len(response.content) > 50 - assert response.model == "anthropic.claude-3-5-sonnet-20240620-v1:0" - assert response.provider == "bedrock" - assert response.usage["total_tokens"] > 0 - assert response.finish_reason in ["stop", "end_turn"] - - -@pytest.mark.e2e -@pytest.mark.asyncio -async def test_claude_sonnet_45_real_generation(check_aws_credentials: None) -> None: - """ - Test Claude Sonnet 4.5 real generation via Bedrock. - - Validates: - - Latest model works - - Extended thinking capability - - High quality responses - """ - import boto3 - - bedrock_client = boto3.client("bedrock-runtime", region_name="us-east-1") - provider = BedrockLLMProvider(bedrock_client=bedrock_client, region="us-east-1") - - messages = [ - LLMMessage( - role="user", - content="Analyze the business implications of adopting AI in healthcare. Be thorough.", - ) - ] - - response = await provider.generate( - messages=messages, - model="anthropic.claude-3-5-sonnet-20240620-v1:0", - temperature=0.8, - max_tokens=500, - ) - - assert response.content - assert len(response.content) > 200 # Should be thorough - assert response.model == "anthropic.claude-3-5-sonnet-20240620-v1:0" - assert response.usage["total_tokens"] > 0 - - -@pytest.mark.e2e -@pytest.mark.asyncio -async def test_gpt5_pro_real_generation(check_openai_credentials: None) -> None: - """Test GPT-5 Pro real generation via OpenAI Responses API. - - GPT-5 Pro uses OpenAI's Responses API exclusively. This test validates - that our provider correctly handles this model. - - Validates: - - OpenAI provider works with GPT-5 Pro via Responses API - - Advanced reasoning capability (fixed high reasoning_effort) - - Logic puzzle solving ability - """ - api_key = os.getenv("OPENAI_API_KEY") - provider = OpenAILLMProvider(api_key=api_key) - - messages = [ - LLMMessage( - role="user", - content="Solve this logic puzzle: If all Bloops are Razzies and all Razzies are Lazzies, are all Bloops definitely Lazzies?", - ) - ] - - response = await provider.generate(messages=messages, model="gpt-5-pro", max_tokens=200) - - assert response.content - assert "yes" in response.content.lower() or "all bloops" in response.content.lower() - assert "gpt-5-pro" in response.model.lower() or "gpt-5" in response.model.lower() - assert response.provider == "openai" - assert response.usage["total_tokens"] > 0 - - -@pytest.mark.e2e -@pytest.mark.asyncio -async def test_gpt5_mini_real_generation(check_openai_credentials: None) -> None: - """ - Test GPT-5 Mini real generation via OpenAI. - - Validates: - - Lightweight model works - - Cost-effective option - - API call succeeds and returns usage metrics - - Note: GPT-5 Mini may have quirks with content format/encoding. - """ - api_key = os.getenv("OPENAI_API_KEY") - provider = OpenAILLMProvider(api_key=api_key) - - messages = [LLMMessage(role="user", content="Say hello in one word.")] - - response = await provider.generate( - messages=messages, - model="gpt-5-mini", - max_tokens=500, # Increase tokens to avoid truncation - ) - - # Validate API call succeeded even if content format is unusual - assert response.model.startswith("gpt-5-mini") - assert response.provider == "openai" - assert response.usage["total_tokens"] > 0 - assert response.usage["completion_tokens"] > 0 - - # Content validation - may be empty due to API quirks - if not response.content: - import warnings - - warnings.warn( - f"GPT-5 Mini returned empty content despite {response.usage['completion_tokens']} completion tokens", - stacklevel=2, - ) - - -@pytest.mark.e2e -@pytest.mark.asyncio -@pytest.mark.skip(reason="Google Cloud billing not enabled for purposepathai project") -async def test_gemini_25_pro_real_generation(check_google_credentials: None) -> None: - """ - Test Gemini 2.5 Pro real generation via Google Vertex AI. - - Validates: - - Google Vertex provider works - - Gemini 2.5 Pro access - - Long context capability - """ - project_id = os.getenv("GOOGLE_PROJECT_ID") - provider = GoogleVertexLLMProvider(project_id=project_id, location="us-central1") - - messages = [ - LLMMessage( - role="user", - content="Explain the concept of machine learning to a 10-year-old in 3 sentences.", - ) - ] - - response = await provider.generate( - messages=messages, model="gemini-1.5-pro", temperature=0.7, max_tokens=150 - ) - - assert response.content - assert len(response.content) > 50 - assert response.model == "gemini-1.5-pro" - assert response.provider == "google_vertex" - assert response.usage["total_tokens"] > 0 - - -@pytest.mark.e2e -@pytest.mark.asyncio -async def test_streaming_generation_real_llm(check_aws_credentials: None) -> None: - """Test streaming generation with real Bedrock LLM. - - Validates: - - Streaming API works (currently falls back to non-streaming) - - Complete response assembled - - Note: Streaming is not yet fully implemented in BedrockLLMProvider, - so this test validates the fallback behavior returns a complete response. - """ - import boto3 - - bedrock_client = boto3.client("bedrock-runtime", region_name="us-east-1") - provider = BedrockLLMProvider(bedrock_client=bedrock_client) - - messages = [ - LLMMessage(role="user", content="Write a 4-line poem about artificial intelligence.") - ] - - chunks = [] - async for chunk in provider.generate_stream( - messages=messages, - model="anthropic.claude-3-5-sonnet-20240620-v1:0", - temperature=0.9, - max_tokens=100, - ): - chunks.append(chunk) - - # Validate response returned (streaming may fall back to single chunk) - assert len(chunks) >= 1 # At least one chunk returned - full_response = "".join(chunks) - assert len(full_response) > 50 - assert "\n" in full_response # Poem should have line breaks - - -@pytest.mark.e2e -@pytest.mark.asyncio -async def test_token_counting_real_providers(check_aws_credentials: None) -> None: - """ - Test token counting across different providers. - - Validates: - - Token counting approximation works - - Reasonable estimates - """ - import boto3 - - bedrock_client = boto3.client("bedrock-runtime", region_name="us-east-1") - provider = BedrockLLMProvider(bedrock_client=bedrock_client) - - text = "This is a test sentence for token counting validation." - model = "anthropic.claude-3-5-sonnet-20241022-v2:0" - - token_count = await provider.count_tokens(text, model) - - # Should be reasonable (roughly 1 token per 4 characters) - assert 10 <= token_count <= 20 - - -@pytest.mark.e2e -@pytest.mark.asyncio -async def test_model_validation_real_providers() -> None: - """ - Test model validation for all new models. - - Validates: - - All new models are recognized - - Invalid models rejected - """ - import boto3 - - bedrock_client = boto3.client("bedrock-runtime", region_name="us-east-1") - bedrock_provider = BedrockLLMProvider(bedrock_client=bedrock_client) - - # Valid models - assert await bedrock_provider.validate_model("anthropic.claude-3-5-sonnet-20240620-v1:0") - assert await bedrock_provider.validate_model("anthropic.claude-3-sonnet-20240229-v1:0") - - # Invalid model - assert not await bedrock_provider.validate_model("invalid-model-id") - - -@pytest.mark.e2e -@pytest.mark.asyncio -async def test_error_handling_real_llm(check_aws_credentials: None) -> None: - """ - Test error handling with real LLM provider. - - Validates: - - Invalid parameters handled - - Clear error messages - - No crashes - """ - import boto3 - - bedrock_client = boto3.client("bedrock-runtime", region_name="us-east-1") - provider = BedrockLLMProvider(bedrock_client=bedrock_client) - - messages = [LLMMessage(role="user", content="Test")] - - # Test invalid temperature - with pytest.raises(ValueError, match="Temperature"): - await provider.generate( - messages=messages, - model="anthropic.claude-3-5-sonnet-20240620-v1:0", - temperature=2.5, # Invalid - ) - - -@pytest.mark.e2e -@pytest.mark.asyncio -@pytest.mark.skip(reason="Google Cloud billing not enabled for purposepathai project") -async def test_multimodal_capability_gemini(check_google_credentials: None) -> None: - """ - Test Gemini 2.5 Pro multimodal capabilities. - - Validates: - - Text-only works (baseline) - - Model supports multimodal input - """ - project_id = os.getenv("GOOGLE_PROJECT_ID") - provider = GoogleVertexLLMProvider(project_id=project_id) - - # Text-only baseline - messages = [ - LLMMessage( - role="user", content="Describe the benefits of using multimodal AI models in business." - ) - ] - - response = await provider.generate( - messages=messages, model="gemini-1.5-pro", temperature=0.7, max_tokens=200 - ) - - assert response.content - assert "multimodal" in response.content.lower() or "multiple" in response.content.lower() - - -__all__ = [] # Test module, no exports +"""End-to-end tests for LLM provider implementations with real models. + +Tests direct provider calls to validate: +- Claude 3.5 Sonnet v2 (Bedrock) +- Claude Sonnet 4.5 (Bedrock) +- GPT-5 series (OpenAI) +- Gemini 2.5 Pro (Google Vertex AI) +""" + +import os + +import pytest + +from coaching.src.domain.ports.llm_provider_port import LLMMessage +from coaching.src.infrastructure.llm.bedrock_provider import BedrockLLMProvider +from coaching.src.infrastructure.llm.google_vertex_provider import GoogleVertexLLMProvider +from coaching.src.infrastructure.llm.openai_provider import OpenAILLMProvider + + +@pytest.mark.e2e +@pytest.mark.asyncio +async def test_claude_35_sonnet_v2_real_generation(check_aws_credentials: None) -> None: + """ + Test Claude 3.5 Sonnet v2 real generation via Bedrock. + + Validates: + - Provider connects successfully + - Response generated + - Usage metrics returned + """ + import boto3 + + bedrock_client = boto3.client("bedrock-runtime", region_name="us-east-1") + provider = BedrockLLMProvider(bedrock_client=bedrock_client, region="us-east-1") + + messages = [ + LLMMessage(role="user", content="Explain quantum computing in exactly 2 sentences.") + ] + + response = await provider.generate( + messages=messages, + model="anthropic.claude-3-5-sonnet-20240620-v1:0", + temperature=0.7, + max_tokens=100, + ) + + assert response.content + assert len(response.content) > 50 + assert response.model == "anthropic.claude-3-5-sonnet-20240620-v1:0" + assert response.provider == "bedrock" + assert response.usage["total_tokens"] > 0 + assert response.finish_reason in ["stop", "end_turn"] + + +@pytest.mark.e2e +@pytest.mark.asyncio +async def test_claude_sonnet_45_real_generation(check_aws_credentials: None) -> None: + """ + Test Claude Sonnet 4.5 real generation via Bedrock. + + Validates: + - Latest model works + - Extended thinking capability + - High quality responses + """ + import boto3 + + bedrock_client = boto3.client("bedrock-runtime", region_name="us-east-1") + provider = BedrockLLMProvider(bedrock_client=bedrock_client, region="us-east-1") + + messages = [ + LLMMessage( + role="user", + content="Analyze the business implications of adopting AI in healthcare. Be thorough.", + ) + ] + + response = await provider.generate( + messages=messages, + model="anthropic.claude-3-5-sonnet-20240620-v1:0", + temperature=0.8, + max_tokens=500, + ) + + assert response.content + assert len(response.content) > 200 # Should be thorough + assert response.model == "anthropic.claude-3-5-sonnet-20240620-v1:0" + assert response.usage["total_tokens"] > 0 + + +@pytest.mark.e2e +@pytest.mark.asyncio +async def test_gpt5_pro_real_generation(check_openai_credentials: None) -> None: + """Test GPT-5 Pro real generation via OpenAI Responses API. + + GPT-5 Pro uses OpenAI's Responses API exclusively. This test validates + that our provider correctly handles this model. + + Validates: + - OpenAI provider works with GPT-5 Pro via Responses API + - Advanced reasoning capability (fixed high reasoning_effort) + - Logic puzzle solving ability + """ + api_key = os.getenv("OPENAI_API_KEY") + provider = OpenAILLMProvider(api_key=api_key) + + messages = [ + LLMMessage( + role="user", + content="Solve this logic puzzle: If all Bloops are Razzies and all Razzies are Lazzies, are all Bloops definitely Lazzies?", + ) + ] + + response = await provider.generate(messages=messages, model="gpt-5-pro", max_tokens=200) + + assert response.content + assert "yes" in response.content.lower() or "all bloops" in response.content.lower() + assert "gpt-5-pro" in response.model.lower() or "gpt-5" in response.model.lower() + assert response.provider == "openai" + assert response.usage["total_tokens"] > 0 + + +@pytest.mark.e2e +@pytest.mark.asyncio +async def test_gpt5_mini_real_generation(check_openai_credentials: None) -> None: + """ + Test GPT-5 Mini real generation via OpenAI. + + Validates: + - Lightweight model works + - Cost-effective option + - API call succeeds and returns usage metrics + + Note: GPT-5 Mini may have quirks with content format/encoding. + """ + api_key = os.getenv("OPENAI_API_KEY") + provider = OpenAILLMProvider(api_key=api_key) + + messages = [LLMMessage(role="user", content="Say hello in one word.")] + + response = await provider.generate( + messages=messages, + model="gpt-5-mini", + max_tokens=500, # Increase tokens to avoid truncation + ) + + # Validate API call succeeded even if content format is unusual + assert response.model.startswith("gpt-5-mini") + assert response.provider == "openai" + assert response.usage["total_tokens"] > 0 + assert response.usage["completion_tokens"] > 0 + + # Content validation - may be empty due to API quirks + if not response.content: + import warnings + + warnings.warn( + f"GPT-5 Mini returned empty content despite {response.usage['completion_tokens']} completion tokens", + stacklevel=2, + ) + + +@pytest.mark.e2e +@pytest.mark.asyncio +@pytest.mark.skip(reason="Google Cloud billing not enabled for purposepathai project") +async def test_gemini_25_pro_real_generation(check_google_credentials: None) -> None: + """ + Test Gemini 2.5 Pro real generation via Google Vertex AI. + + Validates: + - Google Vertex provider works + - Gemini 2.5 Pro access + - Long context capability + """ + project_id = os.getenv("GOOGLE_PROJECT_ID") + provider = GoogleVertexLLMProvider(project_id=project_id, location="us-central1") + + messages = [ + LLMMessage( + role="user", + content="Explain the concept of machine learning to a 10-year-old in 3 sentences.", + ) + ] + + response = await provider.generate( + messages=messages, model="gemini-1.5-pro", temperature=0.7, max_tokens=150 + ) + + assert response.content + assert len(response.content) > 50 + assert response.model == "gemini-1.5-pro" + assert response.provider == "google_vertex" + assert response.usage["total_tokens"] > 0 + + +@pytest.mark.e2e +@pytest.mark.asyncio +async def test_streaming_generation_real_llm(check_aws_credentials: None) -> None: + """Test streaming generation with real Bedrock LLM. + + Validates: + - Streaming API works (currently falls back to non-streaming) + - Complete response assembled + + Note: Streaming is not yet fully implemented in BedrockLLMProvider, + so this test validates the fallback behavior returns a complete response. + """ + import boto3 + + bedrock_client = boto3.client("bedrock-runtime", region_name="us-east-1") + provider = BedrockLLMProvider(bedrock_client=bedrock_client) + + messages = [ + LLMMessage(role="user", content="Write a 4-line poem about artificial intelligence.") + ] + + chunks = [] + async for chunk in provider.generate_stream( + messages=messages, + model="anthropic.claude-3-5-sonnet-20240620-v1:0", + temperature=0.9, + max_tokens=100, + ): + chunks.append(chunk) + + # Validate response returned (streaming may fall back to single chunk) + assert len(chunks) >= 1 # At least one chunk returned + full_response = "".join(chunks) + assert len(full_response) > 50 + assert "\n" in full_response # Poem should have line breaks + + +@pytest.mark.e2e +@pytest.mark.asyncio +async def test_token_counting_real_providers(check_aws_credentials: None) -> None: + """ + Test token counting across different providers. + + Validates: + - Token counting approximation works + - Reasonable estimates + """ + import boto3 + + bedrock_client = boto3.client("bedrock-runtime", region_name="us-east-1") + provider = BedrockLLMProvider(bedrock_client=bedrock_client) + + text = "This is a test sentence for token counting validation." + model = "anthropic.claude-3-5-sonnet-20241022-v2:0" + + token_count = await provider.count_tokens(text, model) + + # Should be reasonable (roughly 1 token per 4 characters) + assert 10 <= token_count <= 20 + + +@pytest.mark.e2e +@pytest.mark.asyncio +async def test_model_validation_real_providers() -> None: + """ + Test model validation for all new models. + + Validates: + - All new models are recognized + - Invalid models rejected + """ + import boto3 + + bedrock_client = boto3.client("bedrock-runtime", region_name="us-east-1") + bedrock_provider = BedrockLLMProvider(bedrock_client=bedrock_client) + + # Valid models + assert await bedrock_provider.validate_model("anthropic.claude-3-5-sonnet-20240620-v1:0") + assert await bedrock_provider.validate_model("anthropic.claude-3-sonnet-20240229-v1:0") + + # Invalid model + assert not await bedrock_provider.validate_model("invalid-model-id") + + +@pytest.mark.e2e +@pytest.mark.asyncio +async def test_error_handling_real_llm(check_aws_credentials: None) -> None: + """ + Test error handling with real LLM provider. + + Validates: + - Invalid parameters handled + - Clear error messages + - No crashes + """ + import boto3 + + bedrock_client = boto3.client("bedrock-runtime", region_name="us-east-1") + provider = BedrockLLMProvider(bedrock_client=bedrock_client) + + messages = [LLMMessage(role="user", content="Test")] + + # Test invalid temperature + with pytest.raises(ValueError, match="Temperature"): + await provider.generate( + messages=messages, + model="anthropic.claude-3-5-sonnet-20240620-v1:0", + temperature=2.5, # Invalid + ) + + +@pytest.mark.e2e +@pytest.mark.asyncio +@pytest.mark.skip(reason="Google Cloud billing not enabled for purposepathai project") +async def test_multimodal_capability_gemini(check_google_credentials: None) -> None: + """ + Test Gemini 2.5 Pro multimodal capabilities. + + Validates: + - Text-only works (baseline) + - Model supports multimodal input + """ + project_id = os.getenv("GOOGLE_PROJECT_ID") + provider = GoogleVertexLLMProvider(project_id=project_id) + + # Text-only baseline + messages = [ + LLMMessage( + role="user", content="Describe the benefits of using multimodal AI models in business." + ) + ] + + response = await provider.generate( + messages=messages, model="gemini-1.5-pro", temperature=0.7, max_tokens=200 + ) + + assert response.content + assert "multimodal" in response.content.lower() or "multiple" in response.content.lower() + + +__all__ = [] # Test module, no exports diff --git a/coaching/tests/integration/api/test_analysis.py b/coaching/tests/integration/api/test_analysis.py index 0507e040..c57b955d 100644 --- a/coaching/tests/integration/api/test_analysis.py +++ b/coaching/tests/integration/api/test_analysis.py @@ -8,6 +8,8 @@ from unittest.mock import AsyncMock import pytest +from fastapi.testclient import TestClient + from coaching.src.api.dependencies.ai_engine import get_generic_handler from coaching.src.api.main import app from coaching.src.api.models.analysis import ( @@ -17,7 +19,6 @@ StrategyAnalysisResponse, ) from coaching.src.core.constants import AnalysisType -from fastapi.testclient import TestClient @pytest.fixture diff --git a/coaching/tests/integration/api/test_coaching_sessions.py b/coaching/tests/integration/api/test_coaching_sessions.py index 235a7b38..46160ee9 100644 --- a/coaching/tests/integration/api/test_coaching_sessions.py +++ b/coaching/tests/integration/api/test_coaching_sessions.py @@ -1,535 +1,536 @@ -"""Integration tests for coaching sessions API routes. - -Tests the generic coaching engine API endpoints with mocked dependencies. -""" - -from datetime import UTC, datetime -from unittest.mock import AsyncMock - -import pytest -from coaching.src.api.main import app -from coaching.src.api.routes.coaching_sessions import ( - get_coaching_session_repository, - get_coaching_session_service, -) -from coaching.src.core.constants import ConversationStatus, MessageRole -from coaching.src.domain.entities.coaching_session import CoachingSession -from coaching.src.services.coaching_session_service import ( - CoachingSessionService, - InvalidTopicError, - MessageResponse, - SessionCompletionResponse, - SessionDetails, - SessionResponse, - SessionStateResponse, - SessionSummary, - TopicStatus, - TopicsWithStatusResponse, -) -from fastapi.testclient import TestClient - -# ============================================================================= -# Fixtures -# ============================================================================= - - -@pytest.fixture -def mock_session() -> CoachingSession: - """Create a mock coaching session entity.""" - return CoachingSession.create( - tenant_id="tenant_test", - user_id="user_test", - topic_id="core_values", - context={"business_name": "Test Business"}, - ) - - -@pytest.fixture -def mock_session_response() -> SessionResponse: - """Create a mock session response.""" - return SessionResponse( - session_id="session_123", - tenant_id="tenant_test", - topic_id="core_values", - status=ConversationStatus.ACTIVE, - message="Welcome! Let's explore your core values.", - message_count=1, - estimated_completion=0.05, - ) - - -@pytest.fixture -def mock_message_response() -> MessageResponse: - """Create a mock message response.""" - return MessageResponse( - session_id="session_123", - message="That's a great insight! Tell me more.", - message_count=3, - estimated_completion=0.15, - status=ConversationStatus.ACTIVE, - ) - - -@pytest.fixture -def mock_state_response() -> SessionStateResponse: - """Create a mock state change response.""" - return SessionStateResponse( - session_id="session_123", - status=ConversationStatus.PAUSED, - topic_id="core_values", - turn_count=1, - max_turns=10, - created_at=datetime.now(UTC).isoformat(), - updated_at=datetime.now(UTC).isoformat(), - message="Session paused successfully", - ) - - -@pytest.fixture -def mock_completion_response() -> SessionCompletionResponse: - """Create a mock completion response.""" - return SessionCompletionResponse( - session_id="session_123", - status=ConversationStatus.COMPLETED, - result={"core_values": [{"name": "Integrity", "description": "Acting with honesty"}]}, - message_count=10, - ) - - -@pytest.fixture -def mock_session_details() -> SessionDetails: - """Create mock session details.""" - from coaching.src.services.coaching_session_service import MessageDetail - - return SessionDetails( - session_id="session_123", - tenant_id="tenant_test", - topic_id="core_values", - user_id="user_test", - status=ConversationStatus.ACTIVE, - messages=[ - MessageDetail( - role=MessageRole.ASSISTANT, - content="Welcome!", - timestamp=datetime.now(UTC).isoformat(), - ), - MessageDetail( - role=MessageRole.USER, - content="Hello", - timestamp=datetime.now(UTC).isoformat(), - ), - ], - context={"business_name": "Test Business"}, - max_turns=10, - message_count=2, - estimated_completion=0.1, - created_at=datetime.now(UTC).isoformat(), - updated_at=datetime.now(UTC).isoformat(), - ) - - -@pytest.fixture -def mock_session_summary() -> SessionSummary: - """Create mock session summary.""" - return SessionSummary( - session_id="session_123", - topic_id="core_values", - status=ConversationStatus.ACTIVE, - turn_count=5, - message_count=5, - created_at=datetime.now(UTC).isoformat(), - updated_at=datetime.now(UTC).isoformat(), - ) - - -@pytest.fixture -def mock_coaching_session_service( - mock_session_response, - mock_message_response, - mock_state_response, - mock_completion_response, - mock_session_details, - mock_session_summary, -): - """Create mock coaching session service.""" - service = AsyncMock(spec=CoachingSessionService) - service.initiate_session = AsyncMock(return_value=mock_session_response) - service.resume_session = AsyncMock(return_value=mock_session_response) - service.get_or_create_session = AsyncMock(return_value=mock_session_response) - service.send_message = AsyncMock(return_value=mock_message_response) - service.pause_session = AsyncMock(return_value=mock_state_response) - service.cancel_session = AsyncMock( - return_value=SessionStateResponse( - session_id="session_123", - status=ConversationStatus.CANCELLED, - topic_id="core_values", - turn_count=1, - max_turns=10, - created_at=datetime.now(UTC).isoformat(), - updated_at=datetime.now(UTC).isoformat(), - message="Session cancelled", - ) - ) - service.complete_session = AsyncMock(return_value=mock_completion_response) - service.get_session = AsyncMock(return_value=mock_session_details) - service.list_user_sessions = AsyncMock(return_value=[mock_session_summary]) - service.get_topics_with_status = AsyncMock( - return_value=TopicsWithStatusResponse( - topics=[ - TopicStatus( - topic_id="core_values", - name="Core Values", - description="Define and refine company core values", - status="not_started", - session_id=None, - completed_at=None, - ) - ] - ) - ) - return service - - -@pytest.fixture -def mock_session_repository(mock_session): - """Create mock session repository.""" - repo = AsyncMock() - repo.list_by_tenant_user = AsyncMock(return_value=[mock_session]) - return repo - - -@pytest.fixture -def client(mock_coaching_session_service, mock_session_repository): - """Create test client with dependency overrides.""" - app.dependency_overrides[get_coaching_session_service] = lambda: mock_coaching_session_service - app.dependency_overrides[get_coaching_session_repository] = lambda: mock_session_repository - - with TestClient(app) as c: - yield c - - # Clean up overrides - app.dependency_overrides = {} - - -# ============================================================================= -# Test Classes -# ============================================================================= - - -class TestGetTopicsStatus: - """Tests for GET /ai/coaching/topics endpoint.""" - - def test_get_topics_status_success(self, client, mock_session): - """Test successful retrieval of topics with status.""" - response = client.get( - "/api/v1/ai/coaching/topics", - headers={"Authorization": "Bearer test_token"}, - ) - - assert response.status_code == 200 - data = response.json() - assert data["success"] is True - assert "data" in data - assert "topics" in data["data"] - topics = data["data"]["topics"] - assert len(topics) > 0 - - # Check topic structure - topic = topics[0] - assert "topic_id" in topic - assert "name" in topic - assert "description" in topic - assert "status" in topic - - -class TestStartSession: - """Tests for POST /ai/coaching/start endpoint.""" - - def test_start_session_success(self, client, mock_session_response): - """Test successful session start.""" - response = client.post( - "/api/v1/ai/coaching/start", - json={"topic_id": "core_values"}, - headers={"Authorization": "Bearer test_token"}, - ) - - assert response.status_code == 200 - data = response.json() - assert data["success"] is True - assert data["data"]["session_id"] == mock_session_response.session_id - assert data["data"]["topic_id"] == "core_values" - assert data["data"]["status"] == "active" - - def test_start_session_with_context(self, client, mock_coaching_session_service): - """Test session start with context data.""" - response = client.post( - "/api/v1/ai/coaching/start", - json={ - "topic_id": "core_values", - "context": {"business_name": "Test Corp"}, - }, - headers={"Authorization": "Bearer test_token"}, - ) - - assert response.status_code == 200 - mock_coaching_session_service.get_or_create_session.assert_called_once() - - def test_start_session_invalid_topic(self, client, mock_coaching_session_service): - """Test session start with invalid topic returns 422.""" - mock_coaching_session_service.get_or_create_session.side_effect = InvalidTopicError( - "invalid_topic" - ) - - response = client.post( - "/api/v1/ai/coaching/start", - json={"topic_id": "invalid_topic"}, - headers={"Authorization": "Bearer test_token"}, - ) - - assert response.status_code == 422 - data = response.json() - assert data["detail"]["code"] == "INVALID_TOPIC" - - -class TestSendMessage: - """Tests for POST /ai/coaching/message endpoint.""" - - def test_send_message_success(self, client, mock_message_response): - """Test successful message send.""" - response = client.post( - "/api/v1/ai/coaching/message", - json={ - "session_id": "session_123", - "message": "I believe integrity is my core value", - }, - headers={"Authorization": "Bearer test_token"}, - ) - - assert response.status_code == 200 - data = response.json() - assert data["success"] is True - assert "coach_message" in data["data"] - assert data["data"]["session_id"] == "session_123" - - def test_send_message_session_not_found(self, client, mock_coaching_session_service): - """Test message to non-existent session returns 422.""" - from coaching.src.domain.exceptions.session_exceptions import SessionNotFoundError - - mock_coaching_session_service.send_message.side_effect = SessionNotFoundError( - session_id="invalid_id" - ) - - response = client.post( - "/api/v1/ai/coaching/message", - json={ - "session_id": "invalid_id", - "message": "Hello", - }, - headers={"Authorization": "Bearer test_token"}, - ) - - assert response.status_code == 422 - data = response.json() - assert data["detail"]["code"] == "SESSION_NOT_FOUND" - - def test_send_message_empty_message_rejected(self, client): - """Test that empty message is rejected by validation.""" - response = client.post( - "/api/v1/ai/coaching/message", - json={ - "session_id": "session_123", - "message": "", - }, - headers={"Authorization": "Bearer test_token"}, - ) - - assert response.status_code == 422 # Validation error - - -class TestPauseSession: - """Tests for POST /ai/coaching/pause endpoint.""" - - def test_pause_session_success(self, client, mock_state_response): - """Test successful session pause.""" - response = client.post( - "/api/v1/ai/coaching/pause", - json={"session_id": "session_123"}, - headers={"Authorization": "Bearer test_token"}, - ) - - assert response.status_code == 200 - data = response.json() - assert data["success"] is True - assert data["data"]["status"] == "paused" - assert data["data"]["session_id"] == "session_123" - assert data["data"]["topic_id"] == "core_values" - assert data["data"]["turn_count"] == 1 - assert data["data"]["max_turns"] == 10 - assert "created_at" in data["data"] - assert "updated_at" in data["data"] - - def test_pause_session_not_found(self, client, mock_coaching_session_service): - """Test pause of non-existent session returns 422.""" - from coaching.src.domain.exceptions.session_exceptions import SessionNotFoundError - - mock_coaching_session_service.pause_session.side_effect = SessionNotFoundError( - session_id="invalid_id" - ) - - response = client.post( - "/api/v1/ai/coaching/pause", - json={"session_id": "invalid_id"}, - headers={"Authorization": "Bearer test_token"}, - ) - - assert response.status_code == 422 - data = response.json() - assert data["detail"]["code"] == "SESSION_NOT_FOUND" - - -class TestCompleteSession: - """Tests for POST /ai/coaching/complete endpoint.""" - - def test_complete_session_success(self, client, mock_completion_response): - """Test successful session completion.""" - response = client.post( - "/api/v1/ai/coaching/complete", - json={"session_id": "session_123"}, - headers={"Authorization": "Bearer test_token"}, - ) - - assert response.status_code == 200 - data = response.json() - assert data["success"] is True - assert data["data"]["status"] == "completed" - assert "result" in data["data"] - assert "core_values" in data["data"]["result"] - - def test_complete_session_not_active(self, client, mock_coaching_session_service): - """Test completion of non-active session returns 400.""" - from coaching.src.domain.exceptions.session_exceptions import SessionNotActiveError - - mock_coaching_session_service.complete_session.side_effect = SessionNotActiveError( - session_id="session_123", - current_status="paused", - ) - - response = client.post( - "/api/v1/ai/coaching/complete", - json={"session_id": "session_123"}, - headers={"Authorization": "Bearer test_token"}, - ) - - assert response.status_code == 400 - data = response.json() - assert data["detail"]["code"] == "SESSION_NOT_ACTIVE" - - -class TestCancelSession: - """Tests for POST /ai/coaching/cancel endpoint.""" - - def test_cancel_session_success(self, client): - """Test successful session cancellation.""" - response = client.post( - "/api/v1/ai/coaching/cancel", - json={"session_id": "session_123"}, - headers={"Authorization": "Bearer test_token"}, - ) - - assert response.status_code == 200 - data = response.json() - assert data["success"] is True - assert data["data"]["status"] == "cancelled" - - -class TestGetSession: - """Tests for GET /ai/coaching/session endpoint.""" - - def test_get_session_success(self, client, mock_session_details): - """Test successful session retrieval.""" - response = client.get( - "/api/v1/ai/coaching/session", - params={"session_id": "session_123"}, - headers={"Authorization": "Bearer test_token"}, - ) - - assert response.status_code == 200 - data = response.json() - assert data["success"] is True - assert data["data"]["session_id"] == "session_123" - assert "messages" in data["data"] - - def test_get_session_not_found(self, client, mock_coaching_session_service): - """Test get non-existent session returns 422.""" - mock_coaching_session_service.get_session.return_value = None - - response = client.get( - "/api/v1/ai/coaching/session", - params={"session_id": "invalid_id"}, - headers={"Authorization": "Bearer test_token"}, - ) - - assert response.status_code == 422 - data = response.json() - assert data["detail"]["code"] == "SESSION_NOT_FOUND" - - -class TestListSessions: - """Tests for GET /ai/coaching/sessions endpoint.""" - - def test_list_sessions_success(self, client, mock_session_summary): - """Test successful session listing.""" - response = client.get( - "/api/v1/ai/coaching/sessions", - headers={"Authorization": "Bearer test_token"}, - ) - - assert response.status_code == 200 - data = response.json() - assert data["success"] is True - assert isinstance(data["data"], list) - assert len(data["data"]) == 1 - - def test_list_sessions_with_filters(self, client, mock_coaching_session_service): - """Test session listing with filters.""" - response = client.get( - "/api/v1/ai/coaching/sessions", - params={"include_completed": "true", "limit": "10"}, - headers={"Authorization": "Bearer test_token"}, - ) - - assert response.status_code == 200 - mock_coaching_session_service.list_user_sessions.assert_called_once() - - -class TestErrorHandling: - """Tests for error handling across endpoints.""" - - def test_internal_server_error_handled(self, client, mock_coaching_session_service): - """Test that internal errors return 500.""" - mock_coaching_session_service.get_or_create_session.side_effect = Exception( - "Database connection failed" - ) - - response = client.post( - "/api/v1/ai/coaching/start", - json={"topic_id": "core_values"}, - headers={"Authorization": "Bearer test_token"}, - ) - - assert response.status_code == 500 - data = response.json() - assert "detail" in data - - -__all__ = [ - "TestCancelSession", - "TestCompleteSession", - "TestErrorHandling", - "TestGetSession", - "TestGetTopicsStatus", - "TestListSessions", - "TestPauseSession", - "TestSendMessage", - "TestStartSession", -] +"""Integration tests for coaching sessions API routes. + +Tests the generic coaching engine API endpoints with mocked dependencies. +""" + +from datetime import UTC, datetime +from unittest.mock import AsyncMock + +import pytest +from fastapi.testclient import TestClient + +from coaching.src.api.main import app +from coaching.src.api.routes.coaching_sessions import ( + get_coaching_session_repository, + get_coaching_session_service, +) +from coaching.src.core.constants import ConversationStatus, MessageRole +from coaching.src.domain.entities.coaching_session import CoachingSession +from coaching.src.services.coaching_session_service import ( + CoachingSessionService, + InvalidTopicError, + MessageResponse, + SessionCompletionResponse, + SessionDetails, + SessionResponse, + SessionStateResponse, + SessionSummary, + TopicStatus, + TopicsWithStatusResponse, +) + +# ============================================================================= +# Fixtures +# ============================================================================= + + +@pytest.fixture +def mock_session() -> CoachingSession: + """Create a mock coaching session entity.""" + return CoachingSession.create( + tenant_id="tenant_test", + user_id="user_test", + topic_id="core_values", + context={"business_name": "Test Business"}, + ) + + +@pytest.fixture +def mock_session_response() -> SessionResponse: + """Create a mock session response.""" + return SessionResponse( + session_id="session_123", + tenant_id="tenant_test", + topic_id="core_values", + status=ConversationStatus.ACTIVE, + message="Welcome! Let's explore your core values.", + message_count=1, + estimated_completion=0.05, + ) + + +@pytest.fixture +def mock_message_response() -> MessageResponse: + """Create a mock message response.""" + return MessageResponse( + session_id="session_123", + message="That's a great insight! Tell me more.", + message_count=3, + estimated_completion=0.15, + status=ConversationStatus.ACTIVE, + ) + + +@pytest.fixture +def mock_state_response() -> SessionStateResponse: + """Create a mock state change response.""" + return SessionStateResponse( + session_id="session_123", + status=ConversationStatus.PAUSED, + topic_id="core_values", + turn_count=1, + max_turns=10, + created_at=datetime.now(UTC).isoformat(), + updated_at=datetime.now(UTC).isoformat(), + message="Session paused successfully", + ) + + +@pytest.fixture +def mock_completion_response() -> SessionCompletionResponse: + """Create a mock completion response.""" + return SessionCompletionResponse( + session_id="session_123", + status=ConversationStatus.COMPLETED, + result={"core_values": [{"name": "Integrity", "description": "Acting with honesty"}]}, + message_count=10, + ) + + +@pytest.fixture +def mock_session_details() -> SessionDetails: + """Create mock session details.""" + from coaching.src.services.coaching_session_service import MessageDetail + + return SessionDetails( + session_id="session_123", + tenant_id="tenant_test", + topic_id="core_values", + user_id="user_test", + status=ConversationStatus.ACTIVE, + messages=[ + MessageDetail( + role=MessageRole.ASSISTANT, + content="Welcome!", + timestamp=datetime.now(UTC).isoformat(), + ), + MessageDetail( + role=MessageRole.USER, + content="Hello", + timestamp=datetime.now(UTC).isoformat(), + ), + ], + context={"business_name": "Test Business"}, + max_turns=10, + message_count=2, + estimated_completion=0.1, + created_at=datetime.now(UTC).isoformat(), + updated_at=datetime.now(UTC).isoformat(), + ) + + +@pytest.fixture +def mock_session_summary() -> SessionSummary: + """Create mock session summary.""" + return SessionSummary( + session_id="session_123", + topic_id="core_values", + status=ConversationStatus.ACTIVE, + turn_count=5, + message_count=5, + created_at=datetime.now(UTC).isoformat(), + updated_at=datetime.now(UTC).isoformat(), + ) + + +@pytest.fixture +def mock_coaching_session_service( + mock_session_response, + mock_message_response, + mock_state_response, + mock_completion_response, + mock_session_details, + mock_session_summary, +): + """Create mock coaching session service.""" + service = AsyncMock(spec=CoachingSessionService) + service.initiate_session = AsyncMock(return_value=mock_session_response) + service.resume_session = AsyncMock(return_value=mock_session_response) + service.get_or_create_session = AsyncMock(return_value=mock_session_response) + service.send_message = AsyncMock(return_value=mock_message_response) + service.pause_session = AsyncMock(return_value=mock_state_response) + service.cancel_session = AsyncMock( + return_value=SessionStateResponse( + session_id="session_123", + status=ConversationStatus.CANCELLED, + topic_id="core_values", + turn_count=1, + max_turns=10, + created_at=datetime.now(UTC).isoformat(), + updated_at=datetime.now(UTC).isoformat(), + message="Session cancelled", + ) + ) + service.complete_session = AsyncMock(return_value=mock_completion_response) + service.get_session = AsyncMock(return_value=mock_session_details) + service.list_user_sessions = AsyncMock(return_value=[mock_session_summary]) + service.get_topics_with_status = AsyncMock( + return_value=TopicsWithStatusResponse( + topics=[ + TopicStatus( + topic_id="core_values", + name="Core Values", + description="Define and refine company core values", + status="not_started", + session_id=None, + completed_at=None, + ) + ] + ) + ) + return service + + +@pytest.fixture +def mock_session_repository(mock_session): + """Create mock session repository.""" + repo = AsyncMock() + repo.list_by_tenant_user = AsyncMock(return_value=[mock_session]) + return repo + + +@pytest.fixture +def client(mock_coaching_session_service, mock_session_repository): + """Create test client with dependency overrides.""" + app.dependency_overrides[get_coaching_session_service] = lambda: mock_coaching_session_service + app.dependency_overrides[get_coaching_session_repository] = lambda: mock_session_repository + + with TestClient(app) as c: + yield c + + # Clean up overrides + app.dependency_overrides = {} + + +# ============================================================================= +# Test Classes +# ============================================================================= + + +class TestGetTopicsStatus: + """Tests for GET /ai/coaching/topics endpoint.""" + + def test_get_topics_status_success(self, client, mock_session): + """Test successful retrieval of topics with status.""" + response = client.get( + "/api/v1/ai/coaching/topics", + headers={"Authorization": "Bearer test_token"}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert "data" in data + assert "topics" in data["data"] + topics = data["data"]["topics"] + assert len(topics) > 0 + + # Check topic structure + topic = topics[0] + assert "topic_id" in topic + assert "name" in topic + assert "description" in topic + assert "status" in topic + + +class TestStartSession: + """Tests for POST /ai/coaching/start endpoint.""" + + def test_start_session_success(self, client, mock_session_response): + """Test successful session start.""" + response = client.post( + "/api/v1/ai/coaching/start", + json={"topic_id": "core_values"}, + headers={"Authorization": "Bearer test_token"}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert data["data"]["session_id"] == mock_session_response.session_id + assert data["data"]["topic_id"] == "core_values" + assert data["data"]["status"] == "active" + + def test_start_session_with_context(self, client, mock_coaching_session_service): + """Test session start with context data.""" + response = client.post( + "/api/v1/ai/coaching/start", + json={ + "topic_id": "core_values", + "context": {"business_name": "Test Corp"}, + }, + headers={"Authorization": "Bearer test_token"}, + ) + + assert response.status_code == 200 + mock_coaching_session_service.get_or_create_session.assert_called_once() + + def test_start_session_invalid_topic(self, client, mock_coaching_session_service): + """Test session start with invalid topic returns 422.""" + mock_coaching_session_service.get_or_create_session.side_effect = InvalidTopicError( + "invalid_topic" + ) + + response = client.post( + "/api/v1/ai/coaching/start", + json={"topic_id": "invalid_topic"}, + headers={"Authorization": "Bearer test_token"}, + ) + + assert response.status_code == 422 + data = response.json() + assert data["detail"]["code"] == "INVALID_TOPIC" + + +class TestSendMessage: + """Tests for POST /ai/coaching/message endpoint.""" + + def test_send_message_success(self, client, mock_message_response): + """Test successful message send.""" + response = client.post( + "/api/v1/ai/coaching/message", + json={ + "session_id": "session_123", + "message": "I believe integrity is my core value", + }, + headers={"Authorization": "Bearer test_token"}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert "coach_message" in data["data"] + assert data["data"]["session_id"] == "session_123" + + def test_send_message_session_not_found(self, client, mock_coaching_session_service): + """Test message to non-existent session returns 422.""" + from coaching.src.domain.exceptions.session_exceptions import SessionNotFoundError + + mock_coaching_session_service.send_message.side_effect = SessionNotFoundError( + session_id="invalid_id" + ) + + response = client.post( + "/api/v1/ai/coaching/message", + json={ + "session_id": "invalid_id", + "message": "Hello", + }, + headers={"Authorization": "Bearer test_token"}, + ) + + assert response.status_code == 422 + data = response.json() + assert data["detail"]["code"] == "SESSION_NOT_FOUND" + + def test_send_message_empty_message_rejected(self, client): + """Test that empty message is rejected by validation.""" + response = client.post( + "/api/v1/ai/coaching/message", + json={ + "session_id": "session_123", + "message": "", + }, + headers={"Authorization": "Bearer test_token"}, + ) + + assert response.status_code == 422 # Validation error + + +class TestPauseSession: + """Tests for POST /ai/coaching/pause endpoint.""" + + def test_pause_session_success(self, client, mock_state_response): + """Test successful session pause.""" + response = client.post( + "/api/v1/ai/coaching/pause", + json={"session_id": "session_123"}, + headers={"Authorization": "Bearer test_token"}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert data["data"]["status"] == "paused" + assert data["data"]["session_id"] == "session_123" + assert data["data"]["topic_id"] == "core_values" + assert data["data"]["turn_count"] == 1 + assert data["data"]["max_turns"] == 10 + assert "created_at" in data["data"] + assert "updated_at" in data["data"] + + def test_pause_session_not_found(self, client, mock_coaching_session_service): + """Test pause of non-existent session returns 422.""" + from coaching.src.domain.exceptions.session_exceptions import SessionNotFoundError + + mock_coaching_session_service.pause_session.side_effect = SessionNotFoundError( + session_id="invalid_id" + ) + + response = client.post( + "/api/v1/ai/coaching/pause", + json={"session_id": "invalid_id"}, + headers={"Authorization": "Bearer test_token"}, + ) + + assert response.status_code == 422 + data = response.json() + assert data["detail"]["code"] == "SESSION_NOT_FOUND" + + +class TestCompleteSession: + """Tests for POST /ai/coaching/complete endpoint.""" + + def test_complete_session_success(self, client, mock_completion_response): + """Test successful session completion.""" + response = client.post( + "/api/v1/ai/coaching/complete", + json={"session_id": "session_123"}, + headers={"Authorization": "Bearer test_token"}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert data["data"]["status"] == "completed" + assert "result" in data["data"] + assert "core_values" in data["data"]["result"] + + def test_complete_session_not_active(self, client, mock_coaching_session_service): + """Test completion of non-active session returns 400.""" + from coaching.src.domain.exceptions.session_exceptions import SessionNotActiveError + + mock_coaching_session_service.complete_session.side_effect = SessionNotActiveError( + session_id="session_123", + current_status="paused", + ) + + response = client.post( + "/api/v1/ai/coaching/complete", + json={"session_id": "session_123"}, + headers={"Authorization": "Bearer test_token"}, + ) + + assert response.status_code == 400 + data = response.json() + assert data["detail"]["code"] == "SESSION_NOT_ACTIVE" + + +class TestCancelSession: + """Tests for POST /ai/coaching/cancel endpoint.""" + + def test_cancel_session_success(self, client): + """Test successful session cancellation.""" + response = client.post( + "/api/v1/ai/coaching/cancel", + json={"session_id": "session_123"}, + headers={"Authorization": "Bearer test_token"}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert data["data"]["status"] == "cancelled" + + +class TestGetSession: + """Tests for GET /ai/coaching/session endpoint.""" + + def test_get_session_success(self, client, mock_session_details): + """Test successful session retrieval.""" + response = client.get( + "/api/v1/ai/coaching/session", + params={"session_id": "session_123"}, + headers={"Authorization": "Bearer test_token"}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert data["data"]["session_id"] == "session_123" + assert "messages" in data["data"] + + def test_get_session_not_found(self, client, mock_coaching_session_service): + """Test get non-existent session returns 422.""" + mock_coaching_session_service.get_session.return_value = None + + response = client.get( + "/api/v1/ai/coaching/session", + params={"session_id": "invalid_id"}, + headers={"Authorization": "Bearer test_token"}, + ) + + assert response.status_code == 422 + data = response.json() + assert data["detail"]["code"] == "SESSION_NOT_FOUND" + + +class TestListSessions: + """Tests for GET /ai/coaching/sessions endpoint.""" + + def test_list_sessions_success(self, client, mock_session_summary): + """Test successful session listing.""" + response = client.get( + "/api/v1/ai/coaching/sessions", + headers={"Authorization": "Bearer test_token"}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert isinstance(data["data"], list) + assert len(data["data"]) == 1 + + def test_list_sessions_with_filters(self, client, mock_coaching_session_service): + """Test session listing with filters.""" + response = client.get( + "/api/v1/ai/coaching/sessions", + params={"include_completed": "true", "limit": "10"}, + headers={"Authorization": "Bearer test_token"}, + ) + + assert response.status_code == 200 + mock_coaching_session_service.list_user_sessions.assert_called_once() + + +class TestErrorHandling: + """Tests for error handling across endpoints.""" + + def test_internal_server_error_handled(self, client, mock_coaching_session_service): + """Test that internal errors return 500.""" + mock_coaching_session_service.get_or_create_session.side_effect = Exception( + "Database connection failed" + ) + + response = client.post( + "/api/v1/ai/coaching/start", + json={"topic_id": "core_values"}, + headers={"Authorization": "Bearer test_token"}, + ) + + assert response.status_code == 500 + data = response.json() + assert "detail" in data + + +__all__ = [ + "TestCancelSession", + "TestCompleteSession", + "TestErrorHandling", + "TestGetSession", + "TestGetTopicsStatus", + "TestListSessions", + "TestPauseSession", + "TestSendMessage", + "TestStartSession", +] diff --git a/coaching/tests/integration/api/test_conversations.py b/coaching/tests/integration/api/test_conversations.py index 7388f411..269d149d 100644 --- a/coaching/tests/integration/api/test_conversations.py +++ b/coaching/tests/integration/api/test_conversations.py @@ -7,6 +7,8 @@ from unittest.mock import AsyncMock import pytest +from fastapi.testclient import TestClient + from coaching.src.api.dependencies import get_conversation_repository, get_conversation_service from coaching.src.api.dependencies.ai_engine import get_generic_handler from coaching.src.api.main import app @@ -20,7 +22,6 @@ ConversationSummary, MessageResponse, ) -from fastapi.testclient import TestClient @pytest.fixture diff --git a/coaching/tests/integration/test_api.py b/coaching/tests/integration/test_api.py index c3acf672..880dcc5d 100644 --- a/coaching/tests/integration/test_api.py +++ b/coaching/tests/integration/test_api.py @@ -1,68 +1,69 @@ -"""Integration tests for API endpoints.""" - -from typing import Any - -import pytest -from coaching.src.api.main import app -from fastapi.testclient import TestClient - - -@pytest.fixture -def client() -> TestClient: - """Create test client.""" - return TestClient(app, raise_server_exceptions=False) - - -def test_root_endpoint(client: TestClient) -> None: - """Test root endpoint.""" - response = client.get("/") - assert response.status_code == 200 - data = response.json() - assert data["name"] == "PurposePath AI Coaching API" - assert data["version"] == "2.0.0" - assert "docs" in data - - -def test_health_endpoint(client: TestClient) -> None: - """Test health check endpoint.""" - response = client.get("/api/v1/health/") - assert response.status_code == 200 - data: dict[str, Any] = response.json() - # Health uses ApiResponse envelope - assert isinstance(data, dict) - assert data.get("success") is True - assert isinstance(data.get("data"), dict) - assert data["data"].get("status") == "healthy" - assert "timestamp" in data["data"] - - -class TestConversationEndpoints: - """Test conversation API endpoints.""" - - def test_initiate_conversation_validation(self, client: TestClient) -> None: - """Test conversation initiation with invalid data.""" - # Missing required fields - response = client.post("/api/v1/conversations/initiate", json={}) - assert response.status_code == 422 # Validation error - - def test_initiate_conversation_valid_request(self, client: TestClient) -> None: - """Test conversation initiation with valid data.""" - request_data: dict[str, Any] = { - "user_id": "test-user-123", - "topic": "core_values", - "context": {}, - "language": "en", - } - - # This will fail due to missing dependencies (DynamoDB, etc.) - # but we can test that the endpoint exists and validates input - response = client.post( - "/api/v1/conversations/initiate", - json=request_data, - headers={"Authorization": "Bearer test-token"}, - ) - - # We expect either success or a dependency error, not validation error - if response.status_code == 422: - print(f"Validation Error: {response.json()}") - assert response.status_code != 422 +"""Integration tests for API endpoints.""" + +from typing import Any + +import pytest +from fastapi.testclient import TestClient + +from coaching.src.api.main import app + + +@pytest.fixture +def client() -> TestClient: + """Create test client.""" + return TestClient(app, raise_server_exceptions=False) + + +def test_root_endpoint(client: TestClient) -> None: + """Test root endpoint.""" + response = client.get("/") + assert response.status_code == 200 + data = response.json() + assert data["name"] == "PurposePath AI Coaching API" + assert data["version"] == "2.0.0" + assert "docs" in data + + +def test_health_endpoint(client: TestClient) -> None: + """Test health check endpoint.""" + response = client.get("/api/v1/health/") + assert response.status_code == 200 + data: dict[str, Any] = response.json() + # Health uses ApiResponse envelope + assert isinstance(data, dict) + assert data.get("success") is True + assert isinstance(data.get("data"), dict) + assert data["data"].get("status") == "healthy" + assert "timestamp" in data["data"] + + +class TestConversationEndpoints: + """Test conversation API endpoints.""" + + def test_initiate_conversation_validation(self, client: TestClient) -> None: + """Test conversation initiation with invalid data.""" + # Missing required fields + response = client.post("/api/v1/conversations/initiate", json={}) + assert response.status_code == 422 # Validation error + + def test_initiate_conversation_valid_request(self, client: TestClient) -> None: + """Test conversation initiation with valid data.""" + request_data: dict[str, Any] = { + "user_id": "test-user-123", + "topic": "core_values", + "context": {}, + "language": "en", + } + + # This will fail due to missing dependencies (DynamoDB, etc.) + # but we can test that the endpoint exists and validates input + response = client.post( + "/api/v1/conversations/initiate", + json=request_data, + headers={"Authorization": "Bearer test-token"}, + ) + + # We expect either success or a dependency error, not validation error + if response.status_code == 422: + print(f"Validation Error: {response.json()}") + assert response.status_code != 422 diff --git a/coaching/tests/integration/test_business_api_integration.py b/coaching/tests/integration/test_business_api_integration.py index c633938c..30de2cac 100644 --- a/coaching/tests/integration/test_business_api_integration.py +++ b/coaching/tests/integration/test_business_api_integration.py @@ -23,6 +23,7 @@ import httpx import pytest import structlog + from coaching.src.infrastructure.external.business_api_client import BusinessApiClient logger = structlog.get_logger() diff --git a/coaching/tests/integration/test_conversation_with_topics_e2e.py b/coaching/tests/integration/test_conversation_with_topics_e2e.py index 14075447..9ff33982 100644 --- a/coaching/tests/integration/test_conversation_with_topics_e2e.py +++ b/coaching/tests/integration/test_conversation_with_topics_e2e.py @@ -6,6 +6,7 @@ from datetime import UTC, datetime import pytest + from coaching.src.core.constants import ConversationPhase, ConversationStatus from coaching.src.domain.entities.conversation import Conversation from coaching.src.domain.entities.llm_topic import LLMTopic, PromptInfo diff --git a/coaching/tests/integration/test_sql_template_generation_integration.py b/coaching/tests/integration/test_sql_template_generation_integration.py index d3b51148..568640f1 100644 --- a/coaching/tests/integration/test_sql_template_generation_integration.py +++ b/coaching/tests/integration/test_sql_template_generation_integration.py @@ -7,6 +7,7 @@ import httpx import pytest + from coaching.src.integration.sql_template.cdata_mcp_client import CDataMcpClient from coaching.src.integration.sql_template.enums import ErrorCode from coaching.src.integration.sql_template.errors import SqlTemplateGenerationError diff --git a/coaching/tests/integration/test_topic_system_e2e.py b/coaching/tests/integration/test_topic_system_e2e.py index 655c0964..80a17b35 100644 --- a/coaching/tests/integration/test_topic_system_e2e.py +++ b/coaching/tests/integration/test_topic_system_e2e.py @@ -7,6 +7,7 @@ from unittest.mock import AsyncMock import pytest + from coaching.src.domain.entities.llm_topic import LLMTopic, PromptInfo from coaching.src.repositories.topic_repository import TopicRepository from coaching.src.services.prompt_service import PromptService diff --git a/coaching/tests/integration/test_unified_ai_engine.py b/coaching/tests/integration/test_unified_ai_engine.py index 8378192a..09a740a2 100644 --- a/coaching/tests/integration/test_unified_ai_engine.py +++ b/coaching/tests/integration/test_unified_ai_engine.py @@ -9,6 +9,7 @@ """ import pytest + from coaching.src.application.ai_engine.unified_ai_engine import UnifiedAIEngine from coaching.src.core.topic_registry import ( ENDPOINT_REGISTRY, diff --git a/coaching/tests/performance/test_api_performance.py b/coaching/tests/performance/test_api_performance.py index 7ede79ef..252dcf46 100644 --- a/coaching/tests/performance/test_api_performance.py +++ b/coaching/tests/performance/test_api_performance.py @@ -1,124 +1,125 @@ -"""API performance tests for production readiness.""" - -import asyncio -import time -from typing import Any - -import pytest -from httpx import AsyncClient -from shared.observability.performance import measure_time - - -@pytest.mark.performance -@pytest.mark.asyncio -class TestAPIPerformance: - """Performance tests for API endpoints.""" - - @pytest.fixture - def api_base_url(self) -> str: - """Get API base URL for testing.""" - return "http://localhost:8000" # Update for actual testing - - @pytest.fixture - async def async_client(self, api_base_url: str) -> AsyncClient: - """Create async HTTP client.""" - async with AsyncClient(base_url=api_base_url, timeout=30.0) as client: - # Check if API is running and is NOT DynamoDB Local - try: - response = await client.get("/api/v1/health") - if response.status_code == 400 and "MissingAuthenticationToken" in response.text: - pytest.skip("Port 8000 is running DynamoDB Local, not the API server") - except Exception: - pass # Let the test fail or handle connection error - yield client - - async def test_health_endpoint_latency(self, async_client: AsyncClient) -> None: - """Test health endpoint responds within acceptable time.""" - with measure_time("health_check", record_metric=False) as timing: - response = await async_client.get("/api/v1/health") - - assert response.status_code == 200 - assert timing["duration_ms"] < 100, "Health check should respond in <100ms" - - async def test_concurrent_health_checks(self, async_client: AsyncClient) -> None: - """Test API handles concurrent requests.""" - concurrent_requests = 50 - - async def make_request() -> dict[str, Any]: - start = time.time() - response = await async_client.get("/api/v1/health") - duration = (time.time() - start) * 1000 - return { - "status_code": response.status_code, - "duration_ms": duration, - } - - # Execute concurrent requests - with measure_time( - f"{concurrent_requests}_concurrent_requests", record_metric=False - ) as timing: - results = await asyncio.gather(*[make_request() for _ in range(concurrent_requests)]) - - # Validate results - successful = [r for r in results if r["status_code"] == 200] - assert len(successful) == concurrent_requests, "All requests should succeed" - - # Check P95 latency - latencies = sorted([r["duration_ms"] for r in results]) - p95_index = int(len(latencies) * 0.95) - p95_latency = latencies[p95_index] - - assert p95_latency < 2000, f"P95 latency should be <2s, got {p95_latency}ms" - assert timing["duration_ms"] < 10000, "50 concurrent requests should complete in <10s" - - -@pytest.mark.performance -@pytest.mark.skip(reason="Requires deployed API endpoint") -class TestCoachingEndpointPerformance: - """Performance tests for coaching endpoints.""" - - async def test_suggestion_generation_latency(self) -> None: - """Test suggestion generation completes within acceptable time.""" - # This would test actual coaching endpoints - # Skipped until integration environment is available - pass - - async def test_conversation_processing_throughput(self) -> None: - """Test conversation processing throughput.""" - # This would test message processing capacity - # Skipped until integration environment is available - pass - - -@pytest.mark.performance -class TestDatabasePerformance: - """Performance tests for database operations.""" - - @pytest.mark.skip(reason="Requires DynamoDB connection") - async def test_conversation_query_latency(self) -> None: - """Test conversation queries complete within acceptable time.""" - # Test DynamoDB query performance - pass - - @pytest.mark.skip(reason="Requires DynamoDB connection") - async def test_bulk_write_performance(self) -> None: - """Test bulk write operations.""" - # Test batch write performance - pass - - -@pytest.mark.performance -class TestLLMPerformance: - """Performance tests for LLM operations.""" - - @pytest.mark.skip(reason="Requires LLM API") - async def test_llm_response_time(self) -> None: - """Test LLM responds within acceptable time.""" - # Test LLM latency - pass - - @pytest.mark.skip(reason="Requires LLM API") - async def test_token_usage_optimization(self) -> None: - """Test token usage is optimized.""" - # Test token counts are reasonable - pass +"""API performance tests for production readiness.""" + +import asyncio +import time +from typing import Any + +import pytest +from httpx import AsyncClient + +from shared.observability.performance import measure_time + + +@pytest.mark.performance +@pytest.mark.asyncio +class TestAPIPerformance: + """Performance tests for API endpoints.""" + + @pytest.fixture + def api_base_url(self) -> str: + """Get API base URL for testing.""" + return "http://localhost:8000" # Update for actual testing + + @pytest.fixture + async def async_client(self, api_base_url: str) -> AsyncClient: + """Create async HTTP client.""" + async with AsyncClient(base_url=api_base_url, timeout=30.0) as client: + # Check if API is running and is NOT DynamoDB Local + try: + response = await client.get("/api/v1/health") + if response.status_code == 400 and "MissingAuthenticationToken" in response.text: + pytest.skip("Port 8000 is running DynamoDB Local, not the API server") + except Exception: + pass # Let the test fail or handle connection error + yield client + + async def test_health_endpoint_latency(self, async_client: AsyncClient) -> None: + """Test health endpoint responds within acceptable time.""" + with measure_time("health_check", record_metric=False) as timing: + response = await async_client.get("/api/v1/health") + + assert response.status_code == 200 + assert timing["duration_ms"] < 100, "Health check should respond in <100ms" + + async def test_concurrent_health_checks(self, async_client: AsyncClient) -> None: + """Test API handles concurrent requests.""" + concurrent_requests = 50 + + async def make_request() -> dict[str, Any]: + start = time.time() + response = await async_client.get("/api/v1/health") + duration = (time.time() - start) * 1000 + return { + "status_code": response.status_code, + "duration_ms": duration, + } + + # Execute concurrent requests + with measure_time( + f"{concurrent_requests}_concurrent_requests", record_metric=False + ) as timing: + results = await asyncio.gather(*[make_request() for _ in range(concurrent_requests)]) + + # Validate results + successful = [r for r in results if r["status_code"] == 200] + assert len(successful) == concurrent_requests, "All requests should succeed" + + # Check P95 latency + latencies = sorted([r["duration_ms"] for r in results]) + p95_index = int(len(latencies) * 0.95) + p95_latency = latencies[p95_index] + + assert p95_latency < 2000, f"P95 latency should be <2s, got {p95_latency}ms" + assert timing["duration_ms"] < 10000, "50 concurrent requests should complete in <10s" + + +@pytest.mark.performance +@pytest.mark.skip(reason="Requires deployed API endpoint") +class TestCoachingEndpointPerformance: + """Performance tests for coaching endpoints.""" + + async def test_suggestion_generation_latency(self) -> None: + """Test suggestion generation completes within acceptable time.""" + # This would test actual coaching endpoints + # Skipped until integration environment is available + pass + + async def test_conversation_processing_throughput(self) -> None: + """Test conversation processing throughput.""" + # This would test message processing capacity + # Skipped until integration environment is available + pass + + +@pytest.mark.performance +class TestDatabasePerformance: + """Performance tests for database operations.""" + + @pytest.mark.skip(reason="Requires DynamoDB connection") + async def test_conversation_query_latency(self) -> None: + """Test conversation queries complete within acceptable time.""" + # Test DynamoDB query performance + pass + + @pytest.mark.skip(reason="Requires DynamoDB connection") + async def test_bulk_write_performance(self) -> None: + """Test bulk write operations.""" + # Test batch write performance + pass + + +@pytest.mark.performance +class TestLLMPerformance: + """Performance tests for LLM operations.""" + + @pytest.mark.skip(reason="Requires LLM API") + async def test_llm_response_time(self) -> None: + """Test LLM responds within acceptable time.""" + # Test LLM latency + pass + + @pytest.mark.skip(reason="Requires LLM API") + async def test_token_usage_optimization(self) -> None: + """Test token usage is optimized.""" + # Test token counts are reasonable + pass diff --git a/coaching/tests/test_business_data_api.py b/coaching/tests/test_business_data_api.py index b5375b5e..872401eb 100644 --- a/coaching/tests/test_business_data_api.py +++ b/coaching/tests/test_business_data_api.py @@ -5,12 +5,13 @@ from unittest.mock import Mock, patch import pytest +from fastapi.testclient import TestClient + from coaching.src.api.auth import get_current_context from coaching.src.api.main import app from coaching.src.api.multitenant_dependencies import ( get_multitenant_conversation_service, ) -from fastapi.testclient import TestClient from shared.models.multitenant import ( Permission, RequestContext, diff --git a/coaching/tests/test_langgraph_workflows.py b/coaching/tests/test_langgraph_workflows.py index c5ea204b..479f7358 100644 --- a/coaching/tests/test_langgraph_workflows.py +++ b/coaching/tests/test_langgraph_workflows.py @@ -1,432 +1,433 @@ -""" -Test suite for LangGraph workflow orchestration - Issue #81. - -Tests all acceptance criteria: -- LangGraphWorkflowOrchestrator initialization -- Workflow graph construction utilities -- Workflow execution with mock providers -- Error handling and recovery -- State persistence interface -""" - -import uuid -from datetime import datetime -from unittest.mock import AsyncMock, MagicMock - -import pytest -from coaching.src.llm.workflow_orchestrator import ( - AdvancedStateManager, - LangGraphWorkflowOrchestrator, -) -from coaching.src.workflows.analysis_workflow_template import AnalysisWorkflowTemplate -from coaching.src.workflows.base import WorkflowConfig, WorkflowState, WorkflowStatus, WorkflowType -from coaching.src.workflows.conversation_workflow_template import ConversationWorkflowTemplate - - -class MockProvider: - """Mock AI provider for testing.""" - - def __init__(self): - self.provider_type = "mock" - - async def generate_response(self, messages, system_prompt, **kwargs): - """Mock response generation.""" - mock_response = MagicMock() - mock_response.content = "This is a mock response for testing." - return mock_response - - async def analyze_text(self, text, analysis_prompt, **kwargs): - """Mock text analysis.""" - mock_analysis = MagicMock() - mock_analysis.model_dump.return_value = { - "values": ["authenticity", "growth"], - "emotions": ["curious", "motivated"], - "goals": ["career development"], - "challenges": [], - "themes": ["self-discovery", "purpose"], - } - return mock_analysis - - -class MockCacheService: - """Mock cache service for testing state persistence.""" - - def __init__(self): - self.storage = {} - - async def save_workflow_state(self, workflow_id, state_data): - """Mock save workflow state.""" - self.storage[workflow_id] = state_data - - async def load_workflow_state(self, workflow_id): - """Mock load workflow state.""" - return self.storage.get(workflow_id) - - -class TestLangGraphWorkflowOrchestrator: - """Test the enhanced LangGraph workflow orchestrator.""" - - @pytest.fixture - def mock_provider_manager(self): - """Create mock provider manager.""" - manager = MagicMock() - manager.get_provider.return_value = MockProvider() - manager.initialize = AsyncMock() - return manager - - @pytest.fixture - def mock_cache_service(self): - """Create mock cache service.""" - return MockCacheService() - - @pytest.fixture - def orchestrator(self, mock_cache_service): - """Create orchestrator instance.""" - return LangGraphWorkflowOrchestrator(cache_service=mock_cache_service) - - @pytest.mark.asyncio - async def test_orchestrator_initialization(self, orchestrator): - """Test orchestrator initialization - Acceptance Criteria 1.""" - # Test basic initialization - assert orchestrator is not None - assert hasattr(orchestrator, "_graph_utilities") - assert hasattr(orchestrator, "_state_manager") - assert isinstance(orchestrator._state_manager, AdvancedStateManager) - - # Test initialization method - await orchestrator.initialize() - # Should not raise exceptions - - @pytest.mark.asyncio - async def test_workflow_registration(self, orchestrator): - """Test workflow registration and graph construction - Acceptance Criteria 2.""" - # Register conversation workflow - orchestrator.register_workflow( - WorkflowType.CONVERSATIONAL_COACHING, ConversationWorkflowTemplate - ) - - # Register analysis workflow - orchestrator.register_workflow(WorkflowType.SINGLE_SHOT_ANALYSIS, AnalysisWorkflowTemplate) - - # Test workflow registration - assert WorkflowType.CONVERSATIONAL_COACHING in orchestrator._workflow_registry - assert WorkflowType.SINGLE_SHOT_ANALYSIS in orchestrator._workflow_registry - - # Test graph creation - config = WorkflowConfig(workflow_type=WorkflowType.CONVERSATIONAL_COACHING) - await orchestrator.create_workflow_graph(WorkflowType.CONVERSATIONAL_COACHING, config) - # Graph creation should not raise exceptions - - @pytest.mark.asyncio - async def test_conversational_workflow_execution(self, orchestrator, mock_provider_manager): - """Test conversational workflow execution - Acceptance Criteria 3.""" - # Register workflow - orchestrator.register_workflow( - WorkflowType.CONVERSATIONAL_COACHING, ConversationWorkflowTemplate - ) - - # Mock provider manager - orchestrator.provider_manager = mock_provider_manager - - # Also register a provider on the global instance that the workflow template uses - from coaching.src.llm.providers.manager import provider_manager as global_provider_manager - - # Add a mock provider to the global instance - mock_provider = MockProvider() - global_provider_manager._providers["test"] = mock_provider - global_provider_manager._default_provider = "test" - - # Test workflow start - user_id = "test_user_123" - initial_input = { - "content": "I want to explore my career values.", - "user_id": user_id, - } - - workflow_state = await orchestrator.start_workflow( - workflow_type=WorkflowType.CONVERSATIONAL_COACHING, - user_id=user_id, - initial_input=initial_input, - session_id="test_session", - ) - - # Validate workflow state - assert workflow_state.workflow_type == WorkflowType.CONVERSATIONAL_COACHING - assert workflow_state.user_id == user_id - # Workflow completes after processing initial input in test mode - assert workflow_state.status in [ - WorkflowStatus.COMPLETED, - "completed", # May be string instead of enum - ] - assert len(workflow_state.conversation_history) > 0 - - # Skip workflow continuation test if workflow already completed - # (In test mode, workflow completes after processing initial input to prevent infinite loops) - if workflow_state.status not in [WorkflowStatus.COMPLETED, "completed"]: - # Test workflow continuation - continue_input = { - "content": "I value creativity and helping others.", - } - - continued_state = await orchestrator.continue_workflow( - workflow_id=workflow_state.workflow_id, user_input=continue_input - ) - - assert continued_state.workflow_id == workflow_state.workflow_id - assert len(continued_state.conversation_history) > len( - workflow_state.conversation_history - ) - - @pytest.mark.asyncio - async def test_analysis_workflow_execution(self, orchestrator, mock_provider_manager): - """Test single-shot analysis workflow execution - Acceptance Criteria 3.""" - # Register workflow - orchestrator.register_workflow(WorkflowType.SINGLE_SHOT_ANALYSIS, AnalysisWorkflowTemplate) - - # Mock provider manager - orchestrator.provider_manager = mock_provider_manager - - # Also register a provider on the global instance that the workflow template uses - from coaching.src.llm.providers.manager import provider_manager as global_provider_manager - - # Add a mock provider to the global instance - mock_provider = MockProvider() - global_provider_manager._providers["test"] = mock_provider - global_provider_manager._default_provider = "test" - - # Test analysis workflow - user_id = "test_user_456" - initial_input = { - "content": "I believe in honesty, creativity, and making a positive impact. I want to find work that aligns with these values.", - "analysis_type": "values", - "user_id": user_id, - } - - workflow_state = await orchestrator.start_workflow( - workflow_type=WorkflowType.SINGLE_SHOT_ANALYSIS, - user_id=user_id, - initial_input=initial_input, - ) - - # Validate analysis results - assert workflow_state.workflow_type == WorkflowType.SINGLE_SHOT_ANALYSIS - assert workflow_state.user_id == user_id - assert workflow_state.status in [ - WorkflowStatus.COMPLETED, - WorkflowStatus.RUNNING, - ] - assert "results" in workflow_state.model_dump() - - @pytest.mark.asyncio - async def test_workflow_state_persistence(self, orchestrator, mock_cache_service): - """Test workflow state persistence - Acceptance Criteria 4.""" - # Register workflow - orchestrator.register_workflow( - WorkflowType.CONVERSATIONAL_COACHING, ConversationWorkflowTemplate - ) - - # Create test state - workflow_state = WorkflowState( - workflow_id=str(uuid.uuid4()), - workflow_type=WorkflowType.CONVERSATIONAL_COACHING, - user_id="test_user", - current_step="greeting", - conversation_history=[{"role": "user", "content": "Hello"}], - created_at=datetime.utcnow().isoformat(), - ) - - # Test state saving - await orchestrator._state_manager.save_state(workflow_state.workflow_id, workflow_state) - - # Verify state was saved - assert workflow_state.workflow_id in mock_cache_service.storage - - # Test state loading - loaded_state = await orchestrator._state_manager.load_state(workflow_state.workflow_id) - assert loaded_state is not None - assert loaded_state.workflow_id == workflow_state.workflow_id - - @pytest.mark.asyncio - async def test_error_handling_and_recovery(self, orchestrator): - """Test error handling and recovery mechanisms - Acceptance Criteria 5.""" - # Test starting workflow with unregistered type - with pytest.raises(ValueError, match="Workflow type not registered"): - await orchestrator.start_workflow( - workflow_type=WorkflowType.GOAL_SETTING, # Not registered - user_id="test_user", - initial_input={"content": "test"}, - ) - - # Test continuing non-existent workflow - with pytest.raises(KeyError, match="Workflow not found"): - await orchestrator.continue_workflow( - workflow_id="non_existent_id", user_input={"content": "test"} - ) - - def test_graph_utilities_standard_nodes(self): - """Test graph utilities and standard node creation.""" - from coaching.src.llm.workflow_orchestrator import GraphUtilities - - # Test standard nodes creation - nodes = GraphUtilities.create_standard_nodes() - - expected_nodes = [ - "greeting", - "question_generation", - "response_analysis", - "insight_extraction", - "follow_up", - "completion", - ] - - for node_name in expected_nodes: - assert node_name in nodes - assert callable(nodes[node_name]) - - @pytest.mark.asyncio - async def test_state_manager_cleanup(self, mock_cache_service): - """Test state manager cleanup functionality.""" - state_manager = AdvancedStateManager(mock_cache_service) - - # Create old completed state - old_state = WorkflowState( - workflow_id="old_workflow", - workflow_type=WorkflowType.CONVERSATIONAL_COACHING, - user_id="test_user", - status=WorkflowStatus.COMPLETED, - completed_at=(datetime.utcnow().timestamp() - 25 * 3600).__str__(), # 25 hours ago - ) - - # Create recent state - recent_state = WorkflowState( - workflow_id="recent_workflow", - workflow_type=WorkflowType.CONVERSATIONAL_COACHING, - user_id="test_user", - status=WorkflowStatus.RUNNING, - created_at=datetime.utcnow().isoformat(), - ) - - # Save states - await state_manager.save_state("old_workflow", old_state) - await state_manager.save_state("recent_workflow", recent_state) - - # Test cleanup - cleaned_count = await state_manager.cleanup_old_states(max_age_hours=24) - - # Should have cleaned the old completed state - assert cleaned_count >= 0 # Cleanup logic may vary - - @pytest.mark.asyncio - async def test_workflow_templates_validation(self): - """Test workflow template state validation.""" - # Test conversation workflow template - conversation_template = ConversationWorkflowTemplate( - WorkflowConfig(workflow_type=WorkflowType.CONVERSATIONAL_COACHING) - ) - - # Create valid state - valid_state = WorkflowState( - workflow_id="test_id", - workflow_type=WorkflowType.CONVERSATIONAL_COACHING, - user_id="test_user", - current_step="greeting", - ) - - assert await conversation_template.validate_state(valid_state) - - # Test analysis workflow template - analysis_template = AnalysisWorkflowTemplate( - WorkflowConfig(workflow_type=WorkflowType.SINGLE_SHOT_ANALYSIS) - ) - - # Create valid state with content - valid_analysis_state = WorkflowState( - workflow_id="test_analysis_id", - workflow_type=WorkflowType.SINGLE_SHOT_ANALYSIS, - user_id="test_user", - current_step="input_validation", - conversation_history=[{"role": "user", "content": "Test content for analysis"}], - ) - - assert await analysis_template.validate_state(valid_analysis_state) - - -class TestWorkflowTemplateIntegration: - """Integration tests for workflow templates.""" - - @pytest.mark.asyncio - async def test_conversation_workflow_nodes(self): - """Test individual conversation workflow nodes.""" - template = ConversationWorkflowTemplate( - WorkflowConfig(workflow_type=WorkflowType.CONVERSATIONAL_COACHING) - ) - - # Test greeting node - test_state = {"workflow_id": "test_123", "messages": [], "current_step": "start"} - - result_state = await template.greeting_node(test_state) - assert result_state["current_step"] == "greeting" - assert len(result_state["messages"]) > 0 - assert "updated_at" in result_state - - @pytest.mark.asyncio - async def test_analysis_workflow_validation(self): - """Test analysis workflow input validation.""" - template = AnalysisWorkflowTemplate( - WorkflowConfig(workflow_type=WorkflowType.SINGLE_SHOT_ANALYSIS) - ) - - # Test with valid input - valid_state = { - "workflow_id": "test_analysis", - "messages": [ - {"role": "user", "content": "This is a meaningful piece of text for analysis."} - ], - "analysis_type": "values", - } - - result_state = await template.input_validation_node(valid_state) - assert result_state["current_step"] == "input_validation" - assert "step_data" in result_state - assert result_state["step_data"]["validation"]["is_valid"] - - # Test with invalid input (too short) - invalid_state = { - "workflow_id": "test_invalid", - "messages": [{"role": "user", "content": "Short"}], - "analysis_type": "general", - } - - result_state = await template.input_validation_node(invalid_state) - assert not result_state["step_data"]["validation"]["is_valid"] - assert result_state["status"] == "failed" - - -if __name__ == "__main__": - # Run tests - import asyncio - - async def run_tests(): - """Run basic test to verify functionality.""" - print("🧪 Running LangGraph Workflow Tests...") - - # Test orchestrator creation - orchestrator = LangGraphWorkflowOrchestrator(MockCacheService()) - await orchestrator.initialize() - print("✅ Orchestrator initialization: PASSED") - - # Test workflow registration - orchestrator.register_workflow( - WorkflowType.CONVERSATIONAL_COACHING, ConversationWorkflowTemplate - ) - print("✅ Workflow registration: PASSED") - - # Test graph creation - config = WorkflowConfig(workflow_type=WorkflowType.CONVERSATIONAL_COACHING) - await orchestrator.create_workflow_graph(WorkflowType.CONVERSATIONAL_COACHING, config) - print("✅ Graph construction: PASSED") - - print("🎉 All basic tests completed successfully!") - - # Run the test - asyncio.run(run_tests()) +""" +Test suite for LangGraph workflow orchestration - Issue #81. + +Tests all acceptance criteria: +- LangGraphWorkflowOrchestrator initialization +- Workflow graph construction utilities +- Workflow execution with mock providers +- Error handling and recovery +- State persistence interface +""" + +import uuid +from datetime import datetime +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from coaching.src.llm.workflow_orchestrator import ( + AdvancedStateManager, + LangGraphWorkflowOrchestrator, +) +from coaching.src.workflows.analysis_workflow_template import AnalysisWorkflowTemplate +from coaching.src.workflows.base import WorkflowConfig, WorkflowState, WorkflowStatus, WorkflowType +from coaching.src.workflows.conversation_workflow_template import ConversationWorkflowTemplate + + +class MockProvider: + """Mock AI provider for testing.""" + + def __init__(self): + self.provider_type = "mock" + + async def generate_response(self, messages, system_prompt, **kwargs): + """Mock response generation.""" + mock_response = MagicMock() + mock_response.content = "This is a mock response for testing." + return mock_response + + async def analyze_text(self, text, analysis_prompt, **kwargs): + """Mock text analysis.""" + mock_analysis = MagicMock() + mock_analysis.model_dump.return_value = { + "values": ["authenticity", "growth"], + "emotions": ["curious", "motivated"], + "goals": ["career development"], + "challenges": [], + "themes": ["self-discovery", "purpose"], + } + return mock_analysis + + +class MockCacheService: + """Mock cache service for testing state persistence.""" + + def __init__(self): + self.storage = {} + + async def save_workflow_state(self, workflow_id, state_data): + """Mock save workflow state.""" + self.storage[workflow_id] = state_data + + async def load_workflow_state(self, workflow_id): + """Mock load workflow state.""" + return self.storage.get(workflow_id) + + +class TestLangGraphWorkflowOrchestrator: + """Test the enhanced LangGraph workflow orchestrator.""" + + @pytest.fixture + def mock_provider_manager(self): + """Create mock provider manager.""" + manager = MagicMock() + manager.get_provider.return_value = MockProvider() + manager.initialize = AsyncMock() + return manager + + @pytest.fixture + def mock_cache_service(self): + """Create mock cache service.""" + return MockCacheService() + + @pytest.fixture + def orchestrator(self, mock_cache_service): + """Create orchestrator instance.""" + return LangGraphWorkflowOrchestrator(cache_service=mock_cache_service) + + @pytest.mark.asyncio + async def test_orchestrator_initialization(self, orchestrator): + """Test orchestrator initialization - Acceptance Criteria 1.""" + # Test basic initialization + assert orchestrator is not None + assert hasattr(orchestrator, "_graph_utilities") + assert hasattr(orchestrator, "_state_manager") + assert isinstance(orchestrator._state_manager, AdvancedStateManager) + + # Test initialization method + await orchestrator.initialize() + # Should not raise exceptions + + @pytest.mark.asyncio + async def test_workflow_registration(self, orchestrator): + """Test workflow registration and graph construction - Acceptance Criteria 2.""" + # Register conversation workflow + orchestrator.register_workflow( + WorkflowType.CONVERSATIONAL_COACHING, ConversationWorkflowTemplate + ) + + # Register analysis workflow + orchestrator.register_workflow(WorkflowType.SINGLE_SHOT_ANALYSIS, AnalysisWorkflowTemplate) + + # Test workflow registration + assert WorkflowType.CONVERSATIONAL_COACHING in orchestrator._workflow_registry + assert WorkflowType.SINGLE_SHOT_ANALYSIS in orchestrator._workflow_registry + + # Test graph creation + config = WorkflowConfig(workflow_type=WorkflowType.CONVERSATIONAL_COACHING) + await orchestrator.create_workflow_graph(WorkflowType.CONVERSATIONAL_COACHING, config) + # Graph creation should not raise exceptions + + @pytest.mark.asyncio + async def test_conversational_workflow_execution(self, orchestrator, mock_provider_manager): + """Test conversational workflow execution - Acceptance Criteria 3.""" + # Register workflow + orchestrator.register_workflow( + WorkflowType.CONVERSATIONAL_COACHING, ConversationWorkflowTemplate + ) + + # Mock provider manager + orchestrator.provider_manager = mock_provider_manager + + # Also register a provider on the global instance that the workflow template uses + from coaching.src.llm.providers.manager import provider_manager as global_provider_manager + + # Add a mock provider to the global instance + mock_provider = MockProvider() + global_provider_manager._providers["test"] = mock_provider + global_provider_manager._default_provider = "test" + + # Test workflow start + user_id = "test_user_123" + initial_input = { + "content": "I want to explore my career values.", + "user_id": user_id, + } + + workflow_state = await orchestrator.start_workflow( + workflow_type=WorkflowType.CONVERSATIONAL_COACHING, + user_id=user_id, + initial_input=initial_input, + session_id="test_session", + ) + + # Validate workflow state + assert workflow_state.workflow_type == WorkflowType.CONVERSATIONAL_COACHING + assert workflow_state.user_id == user_id + # Workflow completes after processing initial input in test mode + assert workflow_state.status in [ + WorkflowStatus.COMPLETED, + "completed", # May be string instead of enum + ] + assert len(workflow_state.conversation_history) > 0 + + # Skip workflow continuation test if workflow already completed + # (In test mode, workflow completes after processing initial input to prevent infinite loops) + if workflow_state.status not in [WorkflowStatus.COMPLETED, "completed"]: + # Test workflow continuation + continue_input = { + "content": "I value creativity and helping others.", + } + + continued_state = await orchestrator.continue_workflow( + workflow_id=workflow_state.workflow_id, user_input=continue_input + ) + + assert continued_state.workflow_id == workflow_state.workflow_id + assert len(continued_state.conversation_history) > len( + workflow_state.conversation_history + ) + + @pytest.mark.asyncio + async def test_analysis_workflow_execution(self, orchestrator, mock_provider_manager): + """Test single-shot analysis workflow execution - Acceptance Criteria 3.""" + # Register workflow + orchestrator.register_workflow(WorkflowType.SINGLE_SHOT_ANALYSIS, AnalysisWorkflowTemplate) + + # Mock provider manager + orchestrator.provider_manager = mock_provider_manager + + # Also register a provider on the global instance that the workflow template uses + from coaching.src.llm.providers.manager import provider_manager as global_provider_manager + + # Add a mock provider to the global instance + mock_provider = MockProvider() + global_provider_manager._providers["test"] = mock_provider + global_provider_manager._default_provider = "test" + + # Test analysis workflow + user_id = "test_user_456" + initial_input = { + "content": "I believe in honesty, creativity, and making a positive impact. I want to find work that aligns with these values.", + "analysis_type": "values", + "user_id": user_id, + } + + workflow_state = await orchestrator.start_workflow( + workflow_type=WorkflowType.SINGLE_SHOT_ANALYSIS, + user_id=user_id, + initial_input=initial_input, + ) + + # Validate analysis results + assert workflow_state.workflow_type == WorkflowType.SINGLE_SHOT_ANALYSIS + assert workflow_state.user_id == user_id + assert workflow_state.status in [ + WorkflowStatus.COMPLETED, + WorkflowStatus.RUNNING, + ] + assert "results" in workflow_state.model_dump() + + @pytest.mark.asyncio + async def test_workflow_state_persistence(self, orchestrator, mock_cache_service): + """Test workflow state persistence - Acceptance Criteria 4.""" + # Register workflow + orchestrator.register_workflow( + WorkflowType.CONVERSATIONAL_COACHING, ConversationWorkflowTemplate + ) + + # Create test state + workflow_state = WorkflowState( + workflow_id=str(uuid.uuid4()), + workflow_type=WorkflowType.CONVERSATIONAL_COACHING, + user_id="test_user", + current_step="greeting", + conversation_history=[{"role": "user", "content": "Hello"}], + created_at=datetime.utcnow().isoformat(), + ) + + # Test state saving + await orchestrator._state_manager.save_state(workflow_state.workflow_id, workflow_state) + + # Verify state was saved + assert workflow_state.workflow_id in mock_cache_service.storage + + # Test state loading + loaded_state = await orchestrator._state_manager.load_state(workflow_state.workflow_id) + assert loaded_state is not None + assert loaded_state.workflow_id == workflow_state.workflow_id + + @pytest.mark.asyncio + async def test_error_handling_and_recovery(self, orchestrator): + """Test error handling and recovery mechanisms - Acceptance Criteria 5.""" + # Test starting workflow with unregistered type + with pytest.raises(ValueError, match="Workflow type not registered"): + await orchestrator.start_workflow( + workflow_type=WorkflowType.GOAL_SETTING, # Not registered + user_id="test_user", + initial_input={"content": "test"}, + ) + + # Test continuing non-existent workflow + with pytest.raises(KeyError, match="Workflow not found"): + await orchestrator.continue_workflow( + workflow_id="non_existent_id", user_input={"content": "test"} + ) + + def test_graph_utilities_standard_nodes(self): + """Test graph utilities and standard node creation.""" + from coaching.src.llm.workflow_orchestrator import GraphUtilities + + # Test standard nodes creation + nodes = GraphUtilities.create_standard_nodes() + + expected_nodes = [ + "greeting", + "question_generation", + "response_analysis", + "insight_extraction", + "follow_up", + "completion", + ] + + for node_name in expected_nodes: + assert node_name in nodes + assert callable(nodes[node_name]) + + @pytest.mark.asyncio + async def test_state_manager_cleanup(self, mock_cache_service): + """Test state manager cleanup functionality.""" + state_manager = AdvancedStateManager(mock_cache_service) + + # Create old completed state + old_state = WorkflowState( + workflow_id="old_workflow", + workflow_type=WorkflowType.CONVERSATIONAL_COACHING, + user_id="test_user", + status=WorkflowStatus.COMPLETED, + completed_at=(datetime.utcnow().timestamp() - 25 * 3600).__str__(), # 25 hours ago + ) + + # Create recent state + recent_state = WorkflowState( + workflow_id="recent_workflow", + workflow_type=WorkflowType.CONVERSATIONAL_COACHING, + user_id="test_user", + status=WorkflowStatus.RUNNING, + created_at=datetime.utcnow().isoformat(), + ) + + # Save states + await state_manager.save_state("old_workflow", old_state) + await state_manager.save_state("recent_workflow", recent_state) + + # Test cleanup + cleaned_count = await state_manager.cleanup_old_states(max_age_hours=24) + + # Should have cleaned the old completed state + assert cleaned_count >= 0 # Cleanup logic may vary + + @pytest.mark.asyncio + async def test_workflow_templates_validation(self): + """Test workflow template state validation.""" + # Test conversation workflow template + conversation_template = ConversationWorkflowTemplate( + WorkflowConfig(workflow_type=WorkflowType.CONVERSATIONAL_COACHING) + ) + + # Create valid state + valid_state = WorkflowState( + workflow_id="test_id", + workflow_type=WorkflowType.CONVERSATIONAL_COACHING, + user_id="test_user", + current_step="greeting", + ) + + assert await conversation_template.validate_state(valid_state) + + # Test analysis workflow template + analysis_template = AnalysisWorkflowTemplate( + WorkflowConfig(workflow_type=WorkflowType.SINGLE_SHOT_ANALYSIS) + ) + + # Create valid state with content + valid_analysis_state = WorkflowState( + workflow_id="test_analysis_id", + workflow_type=WorkflowType.SINGLE_SHOT_ANALYSIS, + user_id="test_user", + current_step="input_validation", + conversation_history=[{"role": "user", "content": "Test content for analysis"}], + ) + + assert await analysis_template.validate_state(valid_analysis_state) + + +class TestWorkflowTemplateIntegration: + """Integration tests for workflow templates.""" + + @pytest.mark.asyncio + async def test_conversation_workflow_nodes(self): + """Test individual conversation workflow nodes.""" + template = ConversationWorkflowTemplate( + WorkflowConfig(workflow_type=WorkflowType.CONVERSATIONAL_COACHING) + ) + + # Test greeting node + test_state = {"workflow_id": "test_123", "messages": [], "current_step": "start"} + + result_state = await template.greeting_node(test_state) + assert result_state["current_step"] == "greeting" + assert len(result_state["messages"]) > 0 + assert "updated_at" in result_state + + @pytest.mark.asyncio + async def test_analysis_workflow_validation(self): + """Test analysis workflow input validation.""" + template = AnalysisWorkflowTemplate( + WorkflowConfig(workflow_type=WorkflowType.SINGLE_SHOT_ANALYSIS) + ) + + # Test with valid input + valid_state = { + "workflow_id": "test_analysis", + "messages": [ + {"role": "user", "content": "This is a meaningful piece of text for analysis."} + ], + "analysis_type": "values", + } + + result_state = await template.input_validation_node(valid_state) + assert result_state["current_step"] == "input_validation" + assert "step_data" in result_state + assert result_state["step_data"]["validation"]["is_valid"] + + # Test with invalid input (too short) + invalid_state = { + "workflow_id": "test_invalid", + "messages": [{"role": "user", "content": "Short"}], + "analysis_type": "general", + } + + result_state = await template.input_validation_node(invalid_state) + assert not result_state["step_data"]["validation"]["is_valid"] + assert result_state["status"] == "failed" + + +if __name__ == "__main__": + # Run tests + import asyncio + + async def run_tests(): + """Run basic test to verify functionality.""" + print("🧪 Running LangGraph Workflow Tests...") + + # Test orchestrator creation + orchestrator = LangGraphWorkflowOrchestrator(MockCacheService()) + await orchestrator.initialize() + print("✅ Orchestrator initialization: PASSED") + + # Test workflow registration + orchestrator.register_workflow( + WorkflowType.CONVERSATIONAL_COACHING, ConversationWorkflowTemplate + ) + print("✅ Workflow registration: PASSED") + + # Test graph creation + config = WorkflowConfig(workflow_type=WorkflowType.CONVERSATIONAL_COACHING) + await orchestrator.create_workflow_graph(WorkflowType.CONVERSATIONAL_COACHING, config) + print("✅ Graph construction: PASSED") + + print("🎉 All basic tests completed successfully!") + + # Run the test + asyncio.run(run_tests()) diff --git a/coaching/tests/test_llm_service_refactoring.py b/coaching/tests/test_llm_service_refactoring.py index e1de4ccc..a06a9dd4 100644 --- a/coaching/tests/test_llm_service_refactoring.py +++ b/coaching/tests/test_llm_service_refactoring.py @@ -1,449 +1,450 @@ -""" -Test suite for Issue #82 - LLM Service Refactoring for Multi-Provider Support. - -Tests all acceptance criteria: -- LLM service uses new provider manager -- Backward compatibility maintained -- Provider selection logic works -- Graceful fallback between providers -- Service-level error handling -""" - -from datetime import datetime -from unittest.mock import MagicMock - -import pytest -from coaching.src.models.llm_models import LLMResponse -from coaching.src.services.llm_service import LLMService -from coaching.src.services.llm_service_adapter import LLMServiceAdapter -from coaching.src.workflows.base import WorkflowState, WorkflowStatus - - -class MockProvider: - """Mock AI provider for testing.""" - - def __init__(self, provider_type="mock", should_fail=False): - self.provider_type = provider_type - self.should_fail = should_fail - self.call_count = 0 - - async def generate_response(self, messages, system_prompt, **kwargs): - """Mock response generation.""" - self.call_count += 1 - - if self.should_fail: - raise Exception(f"{self.provider_type} provider failed") - - mock_response = MagicMock() - mock_response.content = f"Mock response from {self.provider_type} provider" - return mock_response - - -class MockProviderManager: - """Mock provider manager for testing.""" - - def __init__(self): - self.providers = { - "bedrock": MockProvider("bedrock"), - "anthropic": MockProvider("anthropic"), - "openai": MockProvider("openai"), - } - self.initialized = False - - async def initialize(self): - """Mock initialization.""" - self.initialized = True - - async def is_provider_available(self, provider_name: str) -> bool: - """Check if provider is available.""" - return provider_name in self.providers - - async def get_provider(self, provider_name: str): - """Get provider by name.""" - if provider_name not in self.providers: - raise ValueError(f"Provider {provider_name} not found") - return self.providers[provider_name] - - def set_provider_failure(self, provider_name: str, should_fail: bool): - """Set provider to fail for testing.""" - if provider_name in self.providers: - self.providers[provider_name].should_fail = should_fail - - -class MockWorkflowOrchestrator: - """Mock workflow orchestrator for testing.""" - - def __init__(self, provider_manager=None): - self.workflows = {} - self.provider_manager = provider_manager - - async def start_workflow( - self, workflow_type, user_id, initial_input, session_id=None, config=None - ): - """Mock workflow start.""" - workflow_id = f"workflow_{len(self.workflows)}" - - # Check if provider should fail - provider_name = initial_input.get("provider", "bedrock") - if self.provider_manager: - provider = await self.provider_manager.get_provider(provider_name) - if provider.should_fail: - raise Exception(f"{provider_name} provider failed") - - # Create mock workflow state - state = WorkflowState( - workflow_id=workflow_id, - workflow_type=workflow_type, - user_id=user_id, - status=WorkflowStatus.COMPLETED, - current_step="completed", - created_at=datetime.utcnow().isoformat(), - completed_at=datetime.utcnow().isoformat(), - results={ - "response": f"Mock response for {workflow_type.value}", - "insights": ["Mock insight 1", "Mock insight 2"], - "confidence": 0.85, - }, - metadata={ - "token_usage": 150, - "model_id": initial_input.get("model_id", "test-model"), - "provider": provider_name, - }, - ) - - self.workflows[workflow_id] = state - return state - - def get_workflow_statistics(self): - """Get workflow statistics.""" - return { - "total_workflows": len(self.workflows), - "active_workflows": 0, - "status_counts": {}, - } - - -class MockPromptService: - """Mock prompt service for testing.""" - - async def get_template(self, topic: str): - """Get mock prompt template.""" - mock_template = MagicMock() - mock_template.system_prompt = f"System prompt for {topic}" - mock_template.llm_config = MagicMock() - mock_template.llm_config.temperature = 0.7 - mock_template.llm_config.max_tokens = 1000 - return mock_template - - -class TestLLMServiceAdapter: - """Test the LLM service adapter - core of Issue #82.""" - - @pytest.fixture - def mock_provider_manager(self): - """Create mock provider manager.""" - return MockProviderManager() - - @pytest.fixture - def mock_workflow_orchestrator(self, mock_provider_manager): - """Create mock workflow orchestrator.""" - return MockWorkflowOrchestrator(provider_manager=mock_provider_manager) - - @pytest.fixture - async def adapter(self, mock_provider_manager, mock_workflow_orchestrator): - """Create adapter instance.""" - await mock_provider_manager.initialize() - return LLMServiceAdapter( - provider_manager=mock_provider_manager, - workflow_orchestrator=mock_workflow_orchestrator, - default_provider="bedrock", - fallback_providers=["anthropic", "openai"], - ) - - @pytest.mark.asyncio - async def test_adapter_initialization(self, adapter): - """Test adapter initialization - Acceptance Criteria 1.""" - assert adapter is not None - assert adapter.default_provider == "bedrock" - assert adapter.fallback_providers == ["anthropic", "openai"] - - @pytest.mark.asyncio - async def test_get_response_with_default_provider(self, adapter, mock_provider_manager): - """Test response generation with default provider - Acceptance Criteria 2.""" - conversation_id = "test_conv_123" - topic = "core_values" - messages = [{"role": "user", "content": "What are my core values?"}] - system_prompt = "You are a coaching assistant." - - response = await adapter.get_response( - conversation_id=conversation_id, - topic=topic, - messages=messages, - system_prompt=system_prompt, - ) - - # Verify response structure - assert "response" in response - assert "provider" in response - assert response["provider"] == "bedrock" - assert "workflow_id" in response - - @pytest.mark.asyncio - async def test_provider_fallback_mechanism(self, adapter, mock_provider_manager): - """Test graceful fallback between providers - Acceptance Criteria 3.""" - # Make bedrock fail - mock_provider_manager.set_provider_failure("bedrock", True) - - conversation_id = "test_fallback_conv" - topic = "purpose" - messages = [{"role": "user", "content": "What is my purpose?"}] - system_prompt = "You are a coaching assistant." - - response = await adapter.get_response( - conversation_id=conversation_id, - topic=topic, - messages=messages, - system_prompt=system_prompt, - ) - - # Should fallback to anthropic - assert "response" in response - assert response.get("provider") in ["anthropic", "openai"] - - @pytest.mark.asyncio - async def test_all_providers_fail(self, adapter, mock_provider_manager): - """Test error handling when all providers fail - Acceptance Criteria 4.""" - # Make all providers fail - for provider_name in ["bedrock", "anthropic", "openai"]: - mock_provider_manager.set_provider_failure(provider_name, True) - - conversation_id = "test_all_fail_conv" - topic = "vision" - messages = [{"role": "user", "content": "Help me create a vision"}] - system_prompt = "You are a coaching assistant." - - response = await adapter.get_response( - conversation_id=conversation_id, - topic=topic, - messages=messages, - system_prompt=system_prompt, - ) - - # Should return error response - assert "error" in response - assert "response" in response - assert "technical difficulties" in response["response"].lower() - - @pytest.mark.asyncio - async def test_provider_status(self, adapter): - """Test provider status monitoring.""" - status = await adapter.get_provider_status() - - assert "default_provider" in status - assert "fallback_providers" in status - assert "providers" in status - - # Check all providers are listed - assert "bedrock" in status["providers"] - assert "anthropic" in status["providers"] - assert "openai" in status["providers"] - - @pytest.mark.asyncio - async def test_health_check(self, adapter): - """Test health check functionality.""" - health = await adapter.health_check() - - assert "adapter" in health - assert "provider_manager" in health - assert "workflow_orchestrator" in health - assert "providers" in health - - -class TestRefactoredLLMService: - """Test the refactored LLM service - Issue #82 integration.""" - - @pytest.fixture - def mock_provider_manager(self): - """Create mock provider manager.""" - return MockProviderManager() - - @pytest.fixture - def mock_workflow_orchestrator(self): - """Create mock workflow orchestrator.""" - return MockWorkflowOrchestrator() - - @pytest.fixture - def mock_prompt_service(self): - """Create mock prompt service.""" - return MockPromptService() - - @pytest.fixture - async def llm_service( - self, mock_provider_manager, mock_workflow_orchestrator, mock_prompt_service - ): - """Create LLM service instance.""" - await mock_provider_manager.initialize() - return LLMService( - provider_manager=mock_provider_manager, - workflow_orchestrator=mock_workflow_orchestrator, - prompt_service=mock_prompt_service, - tenant_id="test_tenant", - user_id="test_user", - default_provider="bedrock", - fallback_providers=["anthropic", "openai"], - ) - - @pytest.mark.asyncio - async def test_llm_service_initialization(self, llm_service): - """Test LLM service initialization with adapter - Acceptance Criteria 1.""" - assert llm_service is not None - assert hasattr(llm_service, "adapter") - assert isinstance(llm_service.adapter, LLMServiceAdapter) - - @pytest.mark.asyncio - async def test_generate_coaching_response(self, llm_service): - """Test coaching response generation - Acceptance Criteria 2.""" - conversation_id = "test_coaching_conv" - topic = "core_values" - user_message = "I value honesty and integrity." - conversation_history = [] - - response = await llm_service.generate_coaching_response( - conversation_id=conversation_id, - topic=topic, - user_message=user_message, - conversation_history=conversation_history, - ) - - # Verify response is LLMResponse type - assert isinstance(response, LLMResponse) - assert response.response is not None - assert response.conversation_id == conversation_id - assert response.tenant_id == "test_tenant" - assert response.user_id == "test_user" - - @pytest.mark.asyncio - async def test_backward_compatibility(self, llm_service): - """Test backward compatibility with existing API - Acceptance Criteria 3.""" - # Test that all original LLMService methods still work - conversation_id = "test_compat_conv" - topic = "purpose" - user_message = "Help me find my purpose." - conversation_history = [] - - # Should not raise any exceptions - response = await llm_service.generate_coaching_response( - conversation_id=conversation_id, - topic=topic, - user_message=user_message, - conversation_history=conversation_history, - ) - - assert response is not None - assert hasattr(response, "response") - assert hasattr(response, "token_usage") - assert hasattr(response, "model_id") - - @pytest.mark.asyncio - async def test_single_shot_analysis(self, llm_service): - """Test single-shot analysis with new adapter - Acceptance Criteria 4.""" - topic = "core_values" - user_input = "I believe in honesty, creativity, and making a positive impact." - analysis_type = "general" - - result = await llm_service.generate_single_shot_analysis( - topic=topic, - user_input=user_input, - analysis_type=analysis_type, - ) - - assert result is not None - assert "analysis" in result - assert "insights" in result - assert "confidence_score" in result - assert "provider" in result - - @pytest.mark.asyncio - async def test_service_health(self, llm_service): - """Test service health check.""" - health = await llm_service.get_service_health() - - assert "adapter" in health - assert "providers" in health - - @pytest.mark.asyncio - async def test_provider_status(self, llm_service): - """Test provider status retrieval.""" - status = await llm_service.get_provider_status() - - assert "default_provider" in status - assert "providers" in status - - -class TestProviderSwitching: - """Test provider switching functionality.""" - - @pytest.fixture - async def service_with_switches(self): - """Create service that tests provider switching.""" - provider_manager = MockProviderManager() - await provider_manager.initialize() - orchestrator = MockWorkflowOrchestrator() - prompt_service = MockPromptService() - - return LLMService( - provider_manager=provider_manager, - workflow_orchestrator=orchestrator, - prompt_service=prompt_service, - default_provider="anthropic", # Different default - fallback_providers=["bedrock", "openai"], - ) - - @pytest.mark.asyncio - async def test_custom_default_provider(self, service_with_switches): - """Test custom default provider selection.""" - assert service_with_switches.adapter.default_provider == "anthropic" - assert service_with_switches.adapter.fallback_providers == ["bedrock", "openai"] - - @pytest.mark.asyncio - async def test_provider_override(self, service_with_switches): - """Test explicit provider override in request.""" - # This would be tested with actual provider override in the adapter - status = await service_with_switches.get_provider_status() - assert status["default_provider"] == "anthropic" - - -if __name__ == "__main__": - # Run basic validation tests - import asyncio - - async def run_basic_tests(): - """Run basic validation tests.""" - print("🧪 Running Issue #82 Tests...") - - # Test adapter initialization - provider_manager = MockProviderManager() - await provider_manager.initialize() - orchestrator = MockWorkflowOrchestrator() - - adapter = LLMServiceAdapter( - provider_manager=provider_manager, - workflow_orchestrator=orchestrator, - default_provider="bedrock", - fallback_providers=["anthropic", "openai"], - ) - print("✅ Adapter initialization: PASSED") - - # Test provider status - status = await adapter.get_provider_status() - assert "providers" in status - print("✅ Provider status: PASSED") - - # Test health check - health = await adapter.health_check() - assert "adapter" in health - print("✅ Health check: PASSED") - - print("🎉 All basic tests completed successfully!") - - asyncio.run(run_basic_tests()) +""" +Test suite for Issue #82 - LLM Service Refactoring for Multi-Provider Support. + +Tests all acceptance criteria: +- LLM service uses new provider manager +- Backward compatibility maintained +- Provider selection logic works +- Graceful fallback between providers +- Service-level error handling +""" + +from datetime import datetime +from unittest.mock import MagicMock + +import pytest + +from coaching.src.models.llm_models import LLMResponse +from coaching.src.services.llm_service import LLMService +from coaching.src.services.llm_service_adapter import LLMServiceAdapter +from coaching.src.workflows.base import WorkflowState, WorkflowStatus + + +class MockProvider: + """Mock AI provider for testing.""" + + def __init__(self, provider_type="mock", should_fail=False): + self.provider_type = provider_type + self.should_fail = should_fail + self.call_count = 0 + + async def generate_response(self, messages, system_prompt, **kwargs): + """Mock response generation.""" + self.call_count += 1 + + if self.should_fail: + raise Exception(f"{self.provider_type} provider failed") + + mock_response = MagicMock() + mock_response.content = f"Mock response from {self.provider_type} provider" + return mock_response + + +class MockProviderManager: + """Mock provider manager for testing.""" + + def __init__(self): + self.providers = { + "bedrock": MockProvider("bedrock"), + "anthropic": MockProvider("anthropic"), + "openai": MockProvider("openai"), + } + self.initialized = False + + async def initialize(self): + """Mock initialization.""" + self.initialized = True + + async def is_provider_available(self, provider_name: str) -> bool: + """Check if provider is available.""" + return provider_name in self.providers + + async def get_provider(self, provider_name: str): + """Get provider by name.""" + if provider_name not in self.providers: + raise ValueError(f"Provider {provider_name} not found") + return self.providers[provider_name] + + def set_provider_failure(self, provider_name: str, should_fail: bool): + """Set provider to fail for testing.""" + if provider_name in self.providers: + self.providers[provider_name].should_fail = should_fail + + +class MockWorkflowOrchestrator: + """Mock workflow orchestrator for testing.""" + + def __init__(self, provider_manager=None): + self.workflows = {} + self.provider_manager = provider_manager + + async def start_workflow( + self, workflow_type, user_id, initial_input, session_id=None, config=None + ): + """Mock workflow start.""" + workflow_id = f"workflow_{len(self.workflows)}" + + # Check if provider should fail + provider_name = initial_input.get("provider", "bedrock") + if self.provider_manager: + provider = await self.provider_manager.get_provider(provider_name) + if provider.should_fail: + raise Exception(f"{provider_name} provider failed") + + # Create mock workflow state + state = WorkflowState( + workflow_id=workflow_id, + workflow_type=workflow_type, + user_id=user_id, + status=WorkflowStatus.COMPLETED, + current_step="completed", + created_at=datetime.utcnow().isoformat(), + completed_at=datetime.utcnow().isoformat(), + results={ + "response": f"Mock response for {workflow_type.value}", + "insights": ["Mock insight 1", "Mock insight 2"], + "confidence": 0.85, + }, + metadata={ + "token_usage": 150, + "model_id": initial_input.get("model_id", "test-model"), + "provider": provider_name, + }, + ) + + self.workflows[workflow_id] = state + return state + + def get_workflow_statistics(self): + """Get workflow statistics.""" + return { + "total_workflows": len(self.workflows), + "active_workflows": 0, + "status_counts": {}, + } + + +class MockPromptService: + """Mock prompt service for testing.""" + + async def get_template(self, topic: str): + """Get mock prompt template.""" + mock_template = MagicMock() + mock_template.system_prompt = f"System prompt for {topic}" + mock_template.llm_config = MagicMock() + mock_template.llm_config.temperature = 0.7 + mock_template.llm_config.max_tokens = 1000 + return mock_template + + +class TestLLMServiceAdapter: + """Test the LLM service adapter - core of Issue #82.""" + + @pytest.fixture + def mock_provider_manager(self): + """Create mock provider manager.""" + return MockProviderManager() + + @pytest.fixture + def mock_workflow_orchestrator(self, mock_provider_manager): + """Create mock workflow orchestrator.""" + return MockWorkflowOrchestrator(provider_manager=mock_provider_manager) + + @pytest.fixture + async def adapter(self, mock_provider_manager, mock_workflow_orchestrator): + """Create adapter instance.""" + await mock_provider_manager.initialize() + return LLMServiceAdapter( + provider_manager=mock_provider_manager, + workflow_orchestrator=mock_workflow_orchestrator, + default_provider="bedrock", + fallback_providers=["anthropic", "openai"], + ) + + @pytest.mark.asyncio + async def test_adapter_initialization(self, adapter): + """Test adapter initialization - Acceptance Criteria 1.""" + assert adapter is not None + assert adapter.default_provider == "bedrock" + assert adapter.fallback_providers == ["anthropic", "openai"] + + @pytest.mark.asyncio + async def test_get_response_with_default_provider(self, adapter, mock_provider_manager): + """Test response generation with default provider - Acceptance Criteria 2.""" + conversation_id = "test_conv_123" + topic = "core_values" + messages = [{"role": "user", "content": "What are my core values?"}] + system_prompt = "You are a coaching assistant." + + response = await adapter.get_response( + conversation_id=conversation_id, + topic=topic, + messages=messages, + system_prompt=system_prompt, + ) + + # Verify response structure + assert "response" in response + assert "provider" in response + assert response["provider"] == "bedrock" + assert "workflow_id" in response + + @pytest.mark.asyncio + async def test_provider_fallback_mechanism(self, adapter, mock_provider_manager): + """Test graceful fallback between providers - Acceptance Criteria 3.""" + # Make bedrock fail + mock_provider_manager.set_provider_failure("bedrock", True) + + conversation_id = "test_fallback_conv" + topic = "purpose" + messages = [{"role": "user", "content": "What is my purpose?"}] + system_prompt = "You are a coaching assistant." + + response = await adapter.get_response( + conversation_id=conversation_id, + topic=topic, + messages=messages, + system_prompt=system_prompt, + ) + + # Should fallback to anthropic + assert "response" in response + assert response.get("provider") in ["anthropic", "openai"] + + @pytest.mark.asyncio + async def test_all_providers_fail(self, adapter, mock_provider_manager): + """Test error handling when all providers fail - Acceptance Criteria 4.""" + # Make all providers fail + for provider_name in ["bedrock", "anthropic", "openai"]: + mock_provider_manager.set_provider_failure(provider_name, True) + + conversation_id = "test_all_fail_conv" + topic = "vision" + messages = [{"role": "user", "content": "Help me create a vision"}] + system_prompt = "You are a coaching assistant." + + response = await adapter.get_response( + conversation_id=conversation_id, + topic=topic, + messages=messages, + system_prompt=system_prompt, + ) + + # Should return error response + assert "error" in response + assert "response" in response + assert "technical difficulties" in response["response"].lower() + + @pytest.mark.asyncio + async def test_provider_status(self, adapter): + """Test provider status monitoring.""" + status = await adapter.get_provider_status() + + assert "default_provider" in status + assert "fallback_providers" in status + assert "providers" in status + + # Check all providers are listed + assert "bedrock" in status["providers"] + assert "anthropic" in status["providers"] + assert "openai" in status["providers"] + + @pytest.mark.asyncio + async def test_health_check(self, adapter): + """Test health check functionality.""" + health = await adapter.health_check() + + assert "adapter" in health + assert "provider_manager" in health + assert "workflow_orchestrator" in health + assert "providers" in health + + +class TestRefactoredLLMService: + """Test the refactored LLM service - Issue #82 integration.""" + + @pytest.fixture + def mock_provider_manager(self): + """Create mock provider manager.""" + return MockProviderManager() + + @pytest.fixture + def mock_workflow_orchestrator(self): + """Create mock workflow orchestrator.""" + return MockWorkflowOrchestrator() + + @pytest.fixture + def mock_prompt_service(self): + """Create mock prompt service.""" + return MockPromptService() + + @pytest.fixture + async def llm_service( + self, mock_provider_manager, mock_workflow_orchestrator, mock_prompt_service + ): + """Create LLM service instance.""" + await mock_provider_manager.initialize() + return LLMService( + provider_manager=mock_provider_manager, + workflow_orchestrator=mock_workflow_orchestrator, + prompt_service=mock_prompt_service, + tenant_id="test_tenant", + user_id="test_user", + default_provider="bedrock", + fallback_providers=["anthropic", "openai"], + ) + + @pytest.mark.asyncio + async def test_llm_service_initialization(self, llm_service): + """Test LLM service initialization with adapter - Acceptance Criteria 1.""" + assert llm_service is not None + assert hasattr(llm_service, "adapter") + assert isinstance(llm_service.adapter, LLMServiceAdapter) + + @pytest.mark.asyncio + async def test_generate_coaching_response(self, llm_service): + """Test coaching response generation - Acceptance Criteria 2.""" + conversation_id = "test_coaching_conv" + topic = "core_values" + user_message = "I value honesty and integrity." + conversation_history = [] + + response = await llm_service.generate_coaching_response( + conversation_id=conversation_id, + topic=topic, + user_message=user_message, + conversation_history=conversation_history, + ) + + # Verify response is LLMResponse type + assert isinstance(response, LLMResponse) + assert response.response is not None + assert response.conversation_id == conversation_id + assert response.tenant_id == "test_tenant" + assert response.user_id == "test_user" + + @pytest.mark.asyncio + async def test_backward_compatibility(self, llm_service): + """Test backward compatibility with existing API - Acceptance Criteria 3.""" + # Test that all original LLMService methods still work + conversation_id = "test_compat_conv" + topic = "purpose" + user_message = "Help me find my purpose." + conversation_history = [] + + # Should not raise any exceptions + response = await llm_service.generate_coaching_response( + conversation_id=conversation_id, + topic=topic, + user_message=user_message, + conversation_history=conversation_history, + ) + + assert response is not None + assert hasattr(response, "response") + assert hasattr(response, "token_usage") + assert hasattr(response, "model_id") + + @pytest.mark.asyncio + async def test_single_shot_analysis(self, llm_service): + """Test single-shot analysis with new adapter - Acceptance Criteria 4.""" + topic = "core_values" + user_input = "I believe in honesty, creativity, and making a positive impact." + analysis_type = "general" + + result = await llm_service.generate_single_shot_analysis( + topic=topic, + user_input=user_input, + analysis_type=analysis_type, + ) + + assert result is not None + assert "analysis" in result + assert "insights" in result + assert "confidence_score" in result + assert "provider" in result + + @pytest.mark.asyncio + async def test_service_health(self, llm_service): + """Test service health check.""" + health = await llm_service.get_service_health() + + assert "adapter" in health + assert "providers" in health + + @pytest.mark.asyncio + async def test_provider_status(self, llm_service): + """Test provider status retrieval.""" + status = await llm_service.get_provider_status() + + assert "default_provider" in status + assert "providers" in status + + +class TestProviderSwitching: + """Test provider switching functionality.""" + + @pytest.fixture + async def service_with_switches(self): + """Create service that tests provider switching.""" + provider_manager = MockProviderManager() + await provider_manager.initialize() + orchestrator = MockWorkflowOrchestrator() + prompt_service = MockPromptService() + + return LLMService( + provider_manager=provider_manager, + workflow_orchestrator=orchestrator, + prompt_service=prompt_service, + default_provider="anthropic", # Different default + fallback_providers=["bedrock", "openai"], + ) + + @pytest.mark.asyncio + async def test_custom_default_provider(self, service_with_switches): + """Test custom default provider selection.""" + assert service_with_switches.adapter.default_provider == "anthropic" + assert service_with_switches.adapter.fallback_providers == ["bedrock", "openai"] + + @pytest.mark.asyncio + async def test_provider_override(self, service_with_switches): + """Test explicit provider override in request.""" + # This would be tested with actual provider override in the adapter + status = await service_with_switches.get_provider_status() + assert status["default_provider"] == "anthropic" + + +if __name__ == "__main__": + # Run basic validation tests + import asyncio + + async def run_basic_tests(): + """Run basic validation tests.""" + print("🧪 Running Issue #82 Tests...") + + # Test adapter initialization + provider_manager = MockProviderManager() + await provider_manager.initialize() + orchestrator = MockWorkflowOrchestrator() + + adapter = LLMServiceAdapter( + provider_manager=provider_manager, + workflow_orchestrator=orchestrator, + default_provider="bedrock", + fallback_providers=["anthropic", "openai"], + ) + print("✅ Adapter initialization: PASSED") + + # Test provider status + status = await adapter.get_provider_status() + assert "providers" in status + print("✅ Provider status: PASSED") + + # Test health check + health = await adapter.health_check() + assert "adapter" in health + print("✅ Health check: PASSED") + + print("🎉 All basic tests completed successfully!") + + asyncio.run(run_basic_tests()) diff --git a/coaching/tests/unit/api/handlers/test_eventbridge_handler.py b/coaching/tests/unit/api/handlers/test_eventbridge_handler.py index 0b598b1c..b793a11c 100644 --- a/coaching/tests/unit/api/handlers/test_eventbridge_handler.py +++ b/coaching/tests/unit/api/handlers/test_eventbridge_handler.py @@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, patch import pytest + from coaching.src.api.handlers.eventbridge_handler import ( handle_ai_job_created_event, handle_eventbridge_event, diff --git a/coaching/tests/unit/api/models/test_async_ai.py b/coaching/tests/unit/api/models/test_async_ai.py index 535a8c64..c93c73ba 100644 --- a/coaching/tests/unit/api/models/test_async_ai.py +++ b/coaching/tests/unit/api/models/test_async_ai.py @@ -3,9 +3,10 @@ from datetime import UTC, datetime, timedelta import pytest -from coaching.src.api.models.async_ai import AsyncAIRequest from pydantic import ValidationError +from coaching.src.api.models.async_ai import AsyncAIRequest + pytestmark = pytest.mark.unit diff --git a/coaching/tests/unit/api/models/test_job_status_contract.py b/coaching/tests/unit/api/models/test_job_status_contract.py index d40d3f49..bf2ab6c3 100644 --- a/coaching/tests/unit/api/models/test_job_status_contract.py +++ b/coaching/tests/unit/api/models/test_job_status_contract.py @@ -1,6 +1,7 @@ """Tests for backend polling status mapping.""" import pytest + from coaching.src.api.models.job_status_contract import api_contract_status_for_job_status from coaching.src.domain.entities.ai_job import AIJobStatus diff --git a/coaching/tests/unit/api/models/test_strategic_planning.py b/coaching/tests/unit/api/models/test_strategic_planning.py index 3dfaba3d..1e74a27d 100644 --- a/coaching/tests/unit/api/models/test_strategic_planning.py +++ b/coaching/tests/unit/api/models/test_strategic_planning.py @@ -5,6 +5,8 @@ """ import pytest +from pydantic import ValidationError + from coaching.src.api.models.strategic_planning import ( ActionSuggestion, ActionSuggestionsData, @@ -19,7 +21,6 @@ StrategySuggestionsResponse, SuggestedTarget, ) -from pydantic import ValidationError @pytest.mark.unit diff --git a/coaching/tests/unit/api/routes/admin/test_prompts.py b/coaching/tests/unit/api/routes/admin/test_prompts.py index b837f659..7eaffcee 100644 --- a/coaching/tests/unit/api/routes/admin/test_prompts.py +++ b/coaching/tests/unit/api/routes/admin/test_prompts.py @@ -2,13 +2,14 @@ from unittest.mock import AsyncMock import pytest +from fastapi import FastAPI, status +from fastapi.testclient import TestClient + from coaching.src.api.dependencies import get_s3_prompt_storage, get_topic_repository from coaching.src.api.middleware.admin_auth import require_admin_access from coaching.src.api.routes.admin.prompts import router from coaching.src.domain.entities.llm_topic import LLMTopic from coaching.src.domain.exceptions.topic_exceptions import DuplicateTopicError -from fastapi import FastAPI, status -from fastapi.testclient import TestClient from shared.models.multitenant import RequestContext, UserRole # Setup app diff --git a/coaching/tests/unit/api/routes/test_ai_execute.py b/coaching/tests/unit/api/routes/test_ai_execute.py index 1b6acf61..9da1a1c2 100644 --- a/coaching/tests/unit/api/routes/test_ai_execute.py +++ b/coaching/tests/unit/api/routes/test_ai_execute.py @@ -7,6 +7,9 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest +from fastapi import status +from fastapi.testclient import TestClient + from coaching.src.api.main import app from coaching.src.api.models.ai_execute import ( GenericAIRequest, @@ -17,8 +20,6 @@ ) from coaching.src.core.constants import TopicCategory, TopicType from coaching.src.core.topic_registry import TopicDefinition -from fastapi import status -from fastapi.testclient import TestClient pytestmark = pytest.mark.unit diff --git a/coaching/tests/unit/api/routes/test_ai_execute_async.py b/coaching/tests/unit/api/routes/test_ai_execute_async.py index 143027be..9de13d89 100644 --- a/coaching/tests/unit/api/routes/test_ai_execute_async.py +++ b/coaching/tests/unit/api/routes/test_ai_execute_async.py @@ -4,9 +4,10 @@ from unittest.mock import AsyncMock import pytest +from fastapi.testclient import TestClient + from coaching.src.api.main import app from coaching.src.domain.entities.ai_job import AIJob -from fastapi.testclient import TestClient pytestmark = pytest.mark.unit diff --git a/coaching/tests/unit/api/routes/test_coaching_sessions_async.py b/coaching/tests/unit/api/routes/test_coaching_sessions_async.py index 856a44bb..154af50d 100644 --- a/coaching/tests/unit/api/routes/test_coaching_sessions_async.py +++ b/coaching/tests/unit/api/routes/test_coaching_sessions_async.py @@ -3,6 +3,7 @@ from unittest.mock import AsyncMock import pytest + from coaching.src.api.routes.coaching_sessions import get_message_job_status from coaching.src.domain.entities.ai_job import AIJob, AIJobStatus, AIJobType from shared.models.multitenant import RequestContext, UserRole diff --git a/coaching/tests/unit/api/test_admin_topics.py b/coaching/tests/unit/api/test_admin_topics.py index 5d1c97b5..54398f0b 100644 --- a/coaching/tests/unit/api/test_admin_topics.py +++ b/coaching/tests/unit/api/test_admin_topics.py @@ -4,6 +4,9 @@ from unittest.mock import AsyncMock import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + from coaching.src.api.auth import get_current_context from coaching.src.api.dependencies import ( get_s3_prompt_storage, @@ -14,8 +17,6 @@ from coaching.src.api.routes.admin.topics import router from coaching.src.domain.entities.llm_topic import LLMTopic, PromptInfo from coaching.src.domain.entities.llm_usage_record import LlmUsageRecord -from fastapi import FastAPI -from fastapi.testclient import TestClient from shared.models.multitenant import RequestContext, UserRole pytestmark = pytest.mark.unit diff --git a/coaching/tests/unit/application/ai_engine/test_unified_ai_engine.py b/coaching/tests/unit/application/ai_engine/test_unified_ai_engine.py index ceb60ed3..f5ecba1a 100644 --- a/coaching/tests/unit/application/ai_engine/test_unified_ai_engine.py +++ b/coaching/tests/unit/application/ai_engine/test_unified_ai_engine.py @@ -1,6 +1,8 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest +from pydantic import BaseModel + from coaching.src.application.ai_engine.response_serializer import ResponseSerializer from coaching.src.application.ai_engine.unified_ai_engine import ( ParameterValidationError, @@ -19,7 +21,6 @@ from coaching.src.infrastructure.llm.provider_factory import LLMProviderFactory from coaching.src.repositories.topic_repository import TopicRepository from coaching.src.services.s3_prompt_storage import S3PromptStorage -from pydantic import BaseModel class SampleResponseModel(BaseModel): diff --git a/coaching/tests/unit/application/analysis/test_operations_ai_service.py b/coaching/tests/unit/application/analysis/test_operations_ai_service.py index adc189e4..55278a5f 100644 --- a/coaching/tests/unit/application/analysis/test_operations_ai_service.py +++ b/coaching/tests/unit/application/analysis/test_operations_ai_service.py @@ -1,849 +1,850 @@ -"""Unit tests for OperationsAIService (Issue #63). - -Tests all three service methods with mocked LLM service: -- analyze_strategic_alignment -- suggest_prioritization -- optimize_scheduling -""" - -from unittest.mock import AsyncMock, MagicMock - -import pytest -from coaching.src.application.analysis.operations_ai_service import OperationsAIService - - -@pytest.fixture -def mock_llm_service(): - """Create mock LLM service.""" - service = MagicMock() - service.generate_analysis = AsyncMock() - # Mock the response object structure - mock_response = MagicMock() - mock_response.content = "" - mock_response.usage = {"total_tokens": 100} - service.generate_analysis.return_value = mock_response - return service - - -@pytest.fixture -def operations_service(mock_llm_service): - """Create OperationsAIService with mocked LLM service.""" - return OperationsAIService(llm_service=mock_llm_service) - - -@pytest.fixture -def sample_actions(): - """Sample actions for testing.""" - return [ - { - "id": "act_1", - "title": "Launch marketing campaign", - "description": "Launch Q4 marketing campaign for new product", - "priority": "high", - "status": "in_progress", - }, - { - "id": "act_2", - "title": "Improve customer support", - "description": "Implement 24/7 customer support system", - "priority": "medium", - "status": "planned", - }, - ] - - -@pytest.fixture -def sample_goals(): - """Sample goals for testing.""" - return [ - { - "id": "goal_1", - "intent": "Increase revenue by 30% in Q4", - "strategies": ["Expand market reach", "Launch new products"], - }, - { - "id": "goal_2", - "intent": "Improve customer satisfaction to 90%", - "strategies": ["Enhance support quality", "Reduce response time"], - }, - ] - - -@pytest.fixture -def sample_business_foundation(): - """Sample business foundation.""" - return { - "vision": "To be the market leader in customer experience", - "purpose": "Empower businesses with exceptional tools", - "coreValues": ["Customer First", "Innovation", "Excellence"], - } - - -@pytest.fixture -def sample_business_context(): - """Sample business context for prioritization.""" - return { - "currentGoals": ["Increase revenue", "Improve retention"], - "constraints": ["Limited budget", "Small team"], - "urgentDeadlines": ["Product launch: Nov 15"], - } - - -@pytest.fixture -def sample_scheduling_constraints(): - """Sample scheduling constraints.""" - return { - "teamCapacity": 160, - "criticalDeadlines": [{"date": "2025-11-15", "description": "Product launch"}], - "teamAvailability": [ - {"personId": "dev_1", "hoursPerWeek": 40, "unavailableDates": []}, - ], - } - - -# ============================================================================ -# Strategic Alignment Tests -# ============================================================================ - - -class TestAnalyzeStrategicAlignment: - """Test suite for analyze_strategic_alignment method.""" - - @pytest.mark.asyncio - async def test_successful_alignment_analysis( - self, - operations_service, - mock_llm_service, - sample_actions, - sample_goals, - sample_business_foundation, - ): - """Test successful strategic alignment analysis.""" - # Arrange - llm_response = """{ - "alignmentAnalysis": [ - { - "actionId": "act_1", - "alignmentScore": 85, - "strategicConnections": [ - { - "goalId": "goal_1", - "goalTitle": "Increase revenue by 30%", - "alignmentScore": 90, - "impact": "high" - } - ], - "recommendations": [ - "Link to specific revenue KPIs", - "Add measurable success criteria" - ] - } - ], - "overallAlignment": 82, - "insights": [ - "Strong alignment with revenue goals", - "Consider prioritizing high-impact actions" - ] - }""" - mock_llm_service.generate_analysis.return_value.content = llm_response - - # Act - result = await operations_service.analyze_strategic_alignment( - actions=sample_actions, - goals=sample_goals, - business_foundation=sample_business_foundation, - ) - - # Assert - assert isinstance(result, dict) - assert "alignmentAnalysis" in result - assert "overallAlignment" in result - assert result["overallAlignment"] == 82 - assert len(result["alignmentAnalysis"]) >= 1 - assert result["alignmentAnalysis"][0]["actionId"] == "act_1" - assert result["alignmentAnalysis"][0]["alignmentScore"] == 85 - mock_llm_service.generate_analysis.assert_called_once() - - @pytest.mark.asyncio - async def test_alignment_with_no_actions_raises_error( - self, - operations_service, - sample_goals, - sample_business_foundation, - ): - """Test that empty actions list raises ValueError.""" - # Act & Assert - with pytest.raises(ValueError) as exc_info: - await operations_service.analyze_strategic_alignment( - actions=[], - goals=sample_goals, - business_foundation=sample_business_foundation, - ) - assert "At least one action is required" in str(exc_info.value) - - @pytest.mark.asyncio - async def test_alignment_with_no_goals_raises_error( - self, - operations_service, - sample_actions, - sample_business_foundation, - ): - """Test that empty goals list raises ValueError.""" - # Act & Assert - with pytest.raises(ValueError) as exc_info: - await operations_service.analyze_strategic_alignment( - actions=sample_actions, - goals=[], - business_foundation=sample_business_foundation, - ) - assert "At least one goal is required" in str(exc_info.value) - - @pytest.mark.asyncio - async def test_alignment_with_missing_foundation_raises_error( - self, - operations_service, - sample_actions, - sample_goals, - ): - """Test that missing vision/purpose raises ValueError.""" - # Act & Assert - with pytest.raises(ValueError) as exc_info: - await operations_service.analyze_strategic_alignment( - actions=sample_actions, - goals=sample_goals, - business_foundation={}, - ) - assert "vision and purpose" in str(exc_info.value) - - @pytest.mark.asyncio - async def test_alignment_uses_correct_temperature( - self, - operations_service, - mock_llm_service, - sample_actions, - sample_goals, - sample_business_foundation, - ): - """Test that alignment analysis uses temperature 0.6.""" - # Arrange - mock_llm_service.generate_analysis.return_value.content = ( - '{"alignmentAnalysis": [], "overallAlignment": 50}' - ) - - # Act - await operations_service.analyze_strategic_alignment( - actions=sample_actions, - goals=sample_goals, - business_foundation=sample_business_foundation, - ) - - # Assert - call_args = mock_llm_service.generate_analysis.call_args - assert call_args[1]["temperature"] == 0.6 - - @pytest.mark.asyncio - async def test_alignment_handles_malformed_json( - self, - operations_service, - mock_llm_service, - sample_actions, - sample_goals, - sample_business_foundation, - ): - """Test fallback when LLM returns malformed JSON.""" - # Arrange - mock_llm_service.generate_analysis.return_value.content = "Not valid JSON" - - # Act - result = await operations_service.analyze_strategic_alignment( - actions=sample_actions, - goals=sample_goals, - business_foundation=sample_business_foundation, - ) - - # Assert - Should return fallback structure - assert isinstance(result, dict) - assert "alignmentAnalysis" in result - assert "overallAlignment" in result - assert result["overallAlignment"] == 50 - assert len(result["alignmentAnalysis"]) == len(sample_actions) - - -# ============================================================================ -# Prioritization Tests -# ============================================================================ - - -class TestSuggestPrioritization: - """Test suite for suggest_prioritization method.""" - - @pytest.mark.asyncio - async def test_successful_prioritization_suggestions( - self, - operations_service, - mock_llm_service, - sample_business_context, - ): - """Test successful prioritization suggestions.""" - # Arrange - actions = [ - { - "id": "act_1", - "title": "Critical bug fix", - "currentPriority": "medium", - "dueDate": "2025-11-10", - "impact": "high", - "effort": "low", - "status": "planned", - "linkedGoals": ["goal_1"], - } - ] - llm_response = """[ - { - "actionId": "act_1", - "suggestedPriority": "critical", - "currentPriority": "medium", - "reasoning": "High impact bug affecting revenue goals", - "confidence": 0.92, - "urgencyFactors": ["Upcoming product launch", "Customer complaints"], - "impactFactors": ["Revenue impact", "Customer satisfaction"], - "recommendedAction": "escalate", - "estimatedBusinessValue": 50000 - } - ]""" - mock_llm_service.generate_analysis.return_value.content = llm_response - - # Act - result = await operations_service.suggest_prioritization( - actions=actions, - business_context=sample_business_context, - ) - - # Assert - assert isinstance(result, list) - assert len(result) >= 1 - assert result[0]["actionId"] == "act_1" - assert result[0]["suggestedPriority"] == "critical" - assert result[0]["confidence"] == 0.92 - assert result[0]["recommendedAction"] == "escalate" - mock_llm_service.generate_analysis.assert_called_once() - - @pytest.mark.asyncio - async def test_prioritization_with_no_actions_raises_error( - self, - operations_service, - sample_business_context, - ): - """Test that empty actions list raises ValueError.""" - # Act & Assert - with pytest.raises(ValueError) as exc_info: - await operations_service.suggest_prioritization( - actions=[], - business_context=sample_business_context, - ) - assert "At least one action is required" in str(exc_info.value) - - @pytest.mark.asyncio - async def test_prioritization_uses_correct_temperature( - self, - operations_service, - mock_llm_service, - sample_business_context, - ): - """Test that prioritization uses temperature 0.5.""" - # Arrange - actions = [ - { - "id": "act_1", - "title": "Test", - "currentPriority": "medium", - "status": "planned", - "linkedGoals": [], - } - ] - mock_llm_service.generate_analysis.return_value.content = "[]" - - # Act - await operations_service.suggest_prioritization( - actions=actions, - business_context=sample_business_context, - ) - - # Assert - call_args = mock_llm_service.generate_analysis.call_args - assert call_args[1]["temperature"] == 0.5 - - @pytest.mark.asyncio - async def test_prioritization_handles_malformed_json( - self, - operations_service, - mock_llm_service, - sample_business_context, - ): - """Test fallback when LLM returns malformed JSON.""" - # Arrange - actions = [ - { - "id": "act_1", - "title": "Test", - "currentPriority": "medium", - "status": "planned", - "linkedGoals": [], - } - ] - mock_llm_service.generate_analysis.return_value.content = "Invalid JSON" - - # Act - result = await operations_service.suggest_prioritization( - actions=actions, - business_context=sample_business_context, - ) - - # Assert - Should return fallback suggestions - assert isinstance(result, list) - assert len(result) == len(actions) - assert result[0]["actionId"] == "act_1" - assert result[0]["confidence"] == 0.5 - assert result[0]["recommendedAction"] == "maintain" - - -# ============================================================================ -# Scheduling Tests -# ============================================================================ - - -class TestOptimizeScheduling: - """Test suite for optimize_scheduling method.""" - - @pytest.mark.asyncio - async def test_successful_scheduling_optimization( - self, - operations_service, - mock_llm_service, - sample_scheduling_constraints, - ): - """Test successful scheduling optimization.""" - # Arrange - actions = [ - { - "id": "act_1", - "title": "Develop feature", - "estimatedDuration": 40, - "dependencies": [], - "assignedTo": "dev_1", - "currentStartDate": None, - "currentDueDate": None, - "priority": "high", - } - ] - llm_response = """[ - { - "actionId": "act_1", - "suggestedStartDate": "2025-11-01", - "suggestedDueDate": "2025-11-05", - "reasoning": "Optimal schedule considering team capacity", - "confidence": 0.88, - "dependencies": [], - "resourceConsiderations": ["dev_1 has 40h available"], - "risks": ["Tight deadline before product launch"], - "alternativeSchedules": [ - { - "startDate": "2025-11-08", - "dueDate": "2025-11-12", - "rationale": "More buffer time before launch" - } - ] - } - ]""" - mock_llm_service.generate_analysis.return_value.content = llm_response - - # Act - result = await operations_service.optimize_scheduling( - actions=actions, - constraints=sample_scheduling_constraints, - ) - - # Assert - assert isinstance(result, list) - assert len(result) >= 1 - assert result[0]["actionId"] == "act_1" - assert result[0]["suggestedStartDate"] == "2025-11-01" - assert result[0]["confidence"] == 0.88 - assert len(result[0]["alternativeSchedules"]) >= 1 - mock_llm_service.generate_analysis.assert_called_once() - - @pytest.mark.asyncio - async def test_scheduling_with_no_actions_raises_error( - self, - operations_service, - sample_scheduling_constraints, - ): - """Test that empty actions list raises ValueError.""" - # Act & Assert - with pytest.raises(ValueError) as exc_info: - await operations_service.optimize_scheduling( - actions=[], - constraints=sample_scheduling_constraints, - ) - assert "At least one action is required" in str(exc_info.value) - - @pytest.mark.asyncio - async def test_scheduling_with_no_capacity_raises_error( - self, - operations_service, - ): - """Test that missing team capacity raises ValueError.""" - # Arrange - actions = [{"id": "act_1", "title": "Test", "estimatedDuration": 40, "priority": "high"}] - - # Act & Assert - with pytest.raises(ValueError) as exc_info: - await operations_service.optimize_scheduling( - actions=actions, - constraints={}, - ) - assert "Team capacity is required" in str(exc_info.value) - - @pytest.mark.asyncio - async def test_scheduling_uses_correct_temperature( - self, - operations_service, - mock_llm_service, - sample_scheduling_constraints, - ): - """Test that scheduling uses temperature 0.4.""" - # Arrange - actions = [{"id": "act_1", "title": "Test", "estimatedDuration": 40, "priority": "high"}] - mock_llm_service.generate_analysis.return_value.content = "[]" - - # Act - await operations_service.optimize_scheduling( - actions=actions, - constraints=sample_scheduling_constraints, - ) - - # Assert - call_args = mock_llm_service.generate_analysis.call_args - assert call_args[1]["temperature"] == 0.4 - - @pytest.mark.asyncio - async def test_scheduling_handles_malformed_json( - self, - operations_service, - mock_llm_service, - sample_scheduling_constraints, - ): - """Test fallback when LLM returns malformed JSON.""" - # Arrange - actions = [{"id": "act_1", "title": "Test", "estimatedDuration": 40, "priority": "high"}] - mock_llm_service.generate_analysis.return_value.content = "Not JSON" - - # Act - result = await operations_service.optimize_scheduling( - actions=actions, - constraints=sample_scheduling_constraints, - ) - - # Assert - Should return fallback schedules - assert isinstance(result, list) - assert len(result) == len(actions) - assert result[0]["actionId"] == "act_1" - assert "suggestedStartDate" in result[0] - assert result[0]["confidence"] == 0.5 - - -# ============================================================================ -# Root Cause Suggestions Tests (Issue #64) -# ============================================================================ - - -class TestSuggestRootCauseMethods: - """Test suite for suggest_root_cause_methods method.""" - - @pytest.mark.asyncio - async def test_successful_root_cause_suggestions( - self, - operations_service, - mock_llm_service, - ): - """Test successful root cause method suggestions.""" - # Arrange - issue = { - "issueTitle": "Customer retention declining", - "issueDescription": "We've seen a 20% drop in customer retention over the last quarter", - "businessImpact": "high", - } - context = { - "reportedBy": "Sales team", - "dateReported": "2025-10-20", - "affectedAreas": ["Customer Success", "Sales"], - "relatedActions": ["act_123"], - } - - llm_response = """[ - { - "method": "five_whys", - "confidence": 0.92, - "suggestions": { - "fiveWhys": { - "suggestedQuestions": [ - "Why is customer retention declining?", - "Why are customers choosing competitors?" - ], - "potentialRootCauses": [ - "Product quality issues", - "Poor customer support" - ] - } - }, - "reasoning": "Five Whys is ideal for operational issues" - }, - { - "method": "swot", - "confidence": 0.85, - "suggestions": { - "swot": { - "strengths": ["Strong brand"], - "weaknesses": ["Limited support staff"], - "opportunities": ["Automation"], - "threats": ["Competitors"] - } - }, - "reasoning": "SWOT helps identify strategic factors" - } - ]""" - mock_llm_service.generate_analysis.return_value.content = llm_response - - # Act - result = await operations_service.suggest_root_cause_methods( - issue=issue, - context=context, - ) - - # Assert - assert isinstance(result, list) - assert len(result) == 2 - assert result[0]["method"] == "five_whys" - assert result[0]["confidence"] == 0.92 - assert "fiveWhys" in result[0]["suggestions"] - assert len(result[0]["suggestions"]["fiveWhys"]["suggestedQuestions"]) >= 2 - mock_llm_service.generate_analysis.assert_called_once() - - @pytest.mark.asyncio - async def test_root_cause_with_missing_title_raises_error( - self, - operations_service, - ): - """Test that missing issue title raises ValueError.""" - # Act & Assert - with pytest.raises(ValueError) as exc_info: - await operations_service.suggest_root_cause_methods( - issue={"issueDescription": "Test"}, - context={}, - ) - assert "title and description" in str(exc_info.value).lower() - - @pytest.mark.asyncio - async def test_root_cause_uses_correct_temperature( - self, - operations_service, - mock_llm_service, - ): - """Test that root cause analysis uses temperature 0.6.""" - # Arrange - issue = { - "issueTitle": "Test issue", - "issueDescription": "Test description", - "businessImpact": "medium", - } - mock_llm_service.generate_analysis.return_value.content = "[]" - - # Act - await operations_service.suggest_root_cause_methods( - issue=issue, - context={}, - ) - - # Assert - call_args = mock_llm_service.generate_analysis.call_args - assert call_args[1]["temperature"] == 0.6 - - @pytest.mark.asyncio - async def test_root_cause_handles_malformed_json( - self, - operations_service, - mock_llm_service, - ): - """Test fallback when LLM returns malformed JSON.""" - # Arrange - issue = { - "issueTitle": "Test issue", - "issueDescription": "Test description", - "businessImpact": "medium", - } - mock_llm_service.generate_analysis.return_value.content = "Not JSON" - - # Act - result = await operations_service.suggest_root_cause_methods( - issue=issue, - context={}, - ) - - # Assert - Should return fallback suggestions - assert isinstance(result, list) - assert len(result) >= 1 - assert result[0]["method"] == "five_whys" - assert result[0]["confidence"] == 0.7 - assert "fiveWhys" in result[0]["suggestions"] - - -# ============================================================================ -# Action Plan Generation Tests (Issue #64) -# ============================================================================ - - -class TestGenerateActionPlan: - """Test suite for generate_action_plan method.""" - - @pytest.mark.asyncio - async def test_successful_action_plan_generation( - self, - operations_service, - mock_llm_service, - ): - """Test successful action plan generation.""" - # Arrange - issue = { - "title": "System performance degradation", - "description": "API response times have increased by 300%", - "impact": "critical", - "rootCause": "Database query optimization needed", - } - constraints = { - "timeline": "2 weeks", - "budget": 10000, - "availableResources": ["2 backend developers", "1 DBA"], - } - context = { - "relatedGoals": ["Improve system reliability"], - "currentActions": ["Monitoring implementation"], - "businessPriorities": ["Customer experience", "System stability"], - } - - llm_response = """[ - { - "title": "Optimize database queries", - "description": "Review and optimize slow queries identified in logs", - "priority": "critical", - "estimatedDuration": 40, - "estimatedCost": 5000, - "assignmentSuggestion": "Senior Backend Developer + DBA", - "dependencies": [], - "confidence": 0.95, - "reasoning": "Directly addresses identified root cause", - "expectedOutcome": "50% reduction in response times", - "risks": ["May require schema changes"] - }, - { - "title": "Implement query caching", - "description": "Add Redis caching layer for frequent queries", - "priority": "high", - "estimatedDuration": 24, - "estimatedCost": 3000, - "assignmentSuggestion": "Backend Developer", - "dependencies": ["Optimize database queries"], - "confidence": 0.88, - "reasoning": "Prevents similar issues in future", - "expectedOutcome": "Further 30% performance improvement", - "risks": ["Cache invalidation complexity"] - } - ]""" - mock_llm_service.generate_analysis.return_value.content = llm_response - - # Act - result = await operations_service.generate_action_plan( - issue=issue, - constraints=constraints, - context=context, - ) - - # Assert - assert isinstance(result, list) - assert len(result) == 2 - assert result[0]["title"] == "Optimize database queries" - assert result[0]["priority"] == "critical" - assert result[0]["estimatedDuration"] == 40 - assert result[0]["confidence"] == 0.95 - assert len(result[0]["risks"]) > 0 - mock_llm_service.generate_analysis.assert_called_once() - - @pytest.mark.asyncio - async def test_action_plan_with_missing_title_raises_error( - self, - operations_service, - ): - """Test that missing issue title raises ValueError.""" - # Act & Assert - with pytest.raises(ValueError) as exc_info: - await operations_service.generate_action_plan( - issue={"description": "Test"}, - constraints={}, - context={}, - ) - assert "title and description" in str(exc_info.value).lower() - - @pytest.mark.asyncio - async def test_action_plan_uses_correct_temperature( - self, - operations_service, - mock_llm_service, - ): - """Test that action plan generation uses temperature 0.5.""" - # Arrange - issue = { - "title": "Test issue", - "description": "Test description", - "impact": "medium", - } - mock_llm_service.generate_analysis.return_value.content = "[]" - - # Act - await operations_service.generate_action_plan( - issue=issue, - constraints={}, - context={}, - ) - - # Assert - call_args = mock_llm_service.generate_analysis.call_args - assert call_args[1]["temperature"] == 0.5 - - @pytest.mark.asyncio - async def test_action_plan_handles_malformed_json( - self, - operations_service, - mock_llm_service, - ): - """Test fallback when LLM returns malformed JSON.""" - # Arrange - issue = { - "title": "Test issue", - "description": "Test description", - "impact": "medium", - } - mock_llm_service.generate_analysis.return_value.content = "Not JSON" - - # Act - result = await operations_service.generate_action_plan( - issue=issue, - constraints={}, - context={}, - ) - - # Assert - Should return fallback actions - assert isinstance(result, list) - assert len(result) >= 2 - assert result[0]["title"] == "Investigate root cause" - assert result[0]["priority"] == "high" - assert result[0]["confidence"] == 0.8 - assert len(result[0]["risks"]) > 0 +"""Unit tests for OperationsAIService (Issue #63). + +Tests all three service methods with mocked LLM service: +- analyze_strategic_alignment +- suggest_prioritization +- optimize_scheduling +""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from coaching.src.application.analysis.operations_ai_service import OperationsAIService + + +@pytest.fixture +def mock_llm_service(): + """Create mock LLM service.""" + service = MagicMock() + service.generate_analysis = AsyncMock() + # Mock the response object structure + mock_response = MagicMock() + mock_response.content = "" + mock_response.usage = {"total_tokens": 100} + service.generate_analysis.return_value = mock_response + return service + + +@pytest.fixture +def operations_service(mock_llm_service): + """Create OperationsAIService with mocked LLM service.""" + return OperationsAIService(llm_service=mock_llm_service) + + +@pytest.fixture +def sample_actions(): + """Sample actions for testing.""" + return [ + { + "id": "act_1", + "title": "Launch marketing campaign", + "description": "Launch Q4 marketing campaign for new product", + "priority": "high", + "status": "in_progress", + }, + { + "id": "act_2", + "title": "Improve customer support", + "description": "Implement 24/7 customer support system", + "priority": "medium", + "status": "planned", + }, + ] + + +@pytest.fixture +def sample_goals(): + """Sample goals for testing.""" + return [ + { + "id": "goal_1", + "intent": "Increase revenue by 30% in Q4", + "strategies": ["Expand market reach", "Launch new products"], + }, + { + "id": "goal_2", + "intent": "Improve customer satisfaction to 90%", + "strategies": ["Enhance support quality", "Reduce response time"], + }, + ] + + +@pytest.fixture +def sample_business_foundation(): + """Sample business foundation.""" + return { + "vision": "To be the market leader in customer experience", + "purpose": "Empower businesses with exceptional tools", + "coreValues": ["Customer First", "Innovation", "Excellence"], + } + + +@pytest.fixture +def sample_business_context(): + """Sample business context for prioritization.""" + return { + "currentGoals": ["Increase revenue", "Improve retention"], + "constraints": ["Limited budget", "Small team"], + "urgentDeadlines": ["Product launch: Nov 15"], + } + + +@pytest.fixture +def sample_scheduling_constraints(): + """Sample scheduling constraints.""" + return { + "teamCapacity": 160, + "criticalDeadlines": [{"date": "2025-11-15", "description": "Product launch"}], + "teamAvailability": [ + {"personId": "dev_1", "hoursPerWeek": 40, "unavailableDates": []}, + ], + } + + +# ============================================================================ +# Strategic Alignment Tests +# ============================================================================ + + +class TestAnalyzeStrategicAlignment: + """Test suite for analyze_strategic_alignment method.""" + + @pytest.mark.asyncio + async def test_successful_alignment_analysis( + self, + operations_service, + mock_llm_service, + sample_actions, + sample_goals, + sample_business_foundation, + ): + """Test successful strategic alignment analysis.""" + # Arrange + llm_response = """{ + "alignmentAnalysis": [ + { + "actionId": "act_1", + "alignmentScore": 85, + "strategicConnections": [ + { + "goalId": "goal_1", + "goalTitle": "Increase revenue by 30%", + "alignmentScore": 90, + "impact": "high" + } + ], + "recommendations": [ + "Link to specific revenue KPIs", + "Add measurable success criteria" + ] + } + ], + "overallAlignment": 82, + "insights": [ + "Strong alignment with revenue goals", + "Consider prioritizing high-impact actions" + ] + }""" + mock_llm_service.generate_analysis.return_value.content = llm_response + + # Act + result = await operations_service.analyze_strategic_alignment( + actions=sample_actions, + goals=sample_goals, + business_foundation=sample_business_foundation, + ) + + # Assert + assert isinstance(result, dict) + assert "alignmentAnalysis" in result + assert "overallAlignment" in result + assert result["overallAlignment"] == 82 + assert len(result["alignmentAnalysis"]) >= 1 + assert result["alignmentAnalysis"][0]["actionId"] == "act_1" + assert result["alignmentAnalysis"][0]["alignmentScore"] == 85 + mock_llm_service.generate_analysis.assert_called_once() + + @pytest.mark.asyncio + async def test_alignment_with_no_actions_raises_error( + self, + operations_service, + sample_goals, + sample_business_foundation, + ): + """Test that empty actions list raises ValueError.""" + # Act & Assert + with pytest.raises(ValueError) as exc_info: + await operations_service.analyze_strategic_alignment( + actions=[], + goals=sample_goals, + business_foundation=sample_business_foundation, + ) + assert "At least one action is required" in str(exc_info.value) + + @pytest.mark.asyncio + async def test_alignment_with_no_goals_raises_error( + self, + operations_service, + sample_actions, + sample_business_foundation, + ): + """Test that empty goals list raises ValueError.""" + # Act & Assert + with pytest.raises(ValueError) as exc_info: + await operations_service.analyze_strategic_alignment( + actions=sample_actions, + goals=[], + business_foundation=sample_business_foundation, + ) + assert "At least one goal is required" in str(exc_info.value) + + @pytest.mark.asyncio + async def test_alignment_with_missing_foundation_raises_error( + self, + operations_service, + sample_actions, + sample_goals, + ): + """Test that missing vision/purpose raises ValueError.""" + # Act & Assert + with pytest.raises(ValueError) as exc_info: + await operations_service.analyze_strategic_alignment( + actions=sample_actions, + goals=sample_goals, + business_foundation={}, + ) + assert "vision and purpose" in str(exc_info.value) + + @pytest.mark.asyncio + async def test_alignment_uses_correct_temperature( + self, + operations_service, + mock_llm_service, + sample_actions, + sample_goals, + sample_business_foundation, + ): + """Test that alignment analysis uses temperature 0.6.""" + # Arrange + mock_llm_service.generate_analysis.return_value.content = ( + '{"alignmentAnalysis": [], "overallAlignment": 50}' + ) + + # Act + await operations_service.analyze_strategic_alignment( + actions=sample_actions, + goals=sample_goals, + business_foundation=sample_business_foundation, + ) + + # Assert + call_args = mock_llm_service.generate_analysis.call_args + assert call_args[1]["temperature"] == 0.6 + + @pytest.mark.asyncio + async def test_alignment_handles_malformed_json( + self, + operations_service, + mock_llm_service, + sample_actions, + sample_goals, + sample_business_foundation, + ): + """Test fallback when LLM returns malformed JSON.""" + # Arrange + mock_llm_service.generate_analysis.return_value.content = "Not valid JSON" + + # Act + result = await operations_service.analyze_strategic_alignment( + actions=sample_actions, + goals=sample_goals, + business_foundation=sample_business_foundation, + ) + + # Assert - Should return fallback structure + assert isinstance(result, dict) + assert "alignmentAnalysis" in result + assert "overallAlignment" in result + assert result["overallAlignment"] == 50 + assert len(result["alignmentAnalysis"]) == len(sample_actions) + + +# ============================================================================ +# Prioritization Tests +# ============================================================================ + + +class TestSuggestPrioritization: + """Test suite for suggest_prioritization method.""" + + @pytest.mark.asyncio + async def test_successful_prioritization_suggestions( + self, + operations_service, + mock_llm_service, + sample_business_context, + ): + """Test successful prioritization suggestions.""" + # Arrange + actions = [ + { + "id": "act_1", + "title": "Critical bug fix", + "currentPriority": "medium", + "dueDate": "2025-11-10", + "impact": "high", + "effort": "low", + "status": "planned", + "linkedGoals": ["goal_1"], + } + ] + llm_response = """[ + { + "actionId": "act_1", + "suggestedPriority": "critical", + "currentPriority": "medium", + "reasoning": "High impact bug affecting revenue goals", + "confidence": 0.92, + "urgencyFactors": ["Upcoming product launch", "Customer complaints"], + "impactFactors": ["Revenue impact", "Customer satisfaction"], + "recommendedAction": "escalate", + "estimatedBusinessValue": 50000 + } + ]""" + mock_llm_service.generate_analysis.return_value.content = llm_response + + # Act + result = await operations_service.suggest_prioritization( + actions=actions, + business_context=sample_business_context, + ) + + # Assert + assert isinstance(result, list) + assert len(result) >= 1 + assert result[0]["actionId"] == "act_1" + assert result[0]["suggestedPriority"] == "critical" + assert result[0]["confidence"] == 0.92 + assert result[0]["recommendedAction"] == "escalate" + mock_llm_service.generate_analysis.assert_called_once() + + @pytest.mark.asyncio + async def test_prioritization_with_no_actions_raises_error( + self, + operations_service, + sample_business_context, + ): + """Test that empty actions list raises ValueError.""" + # Act & Assert + with pytest.raises(ValueError) as exc_info: + await operations_service.suggest_prioritization( + actions=[], + business_context=sample_business_context, + ) + assert "At least one action is required" in str(exc_info.value) + + @pytest.mark.asyncio + async def test_prioritization_uses_correct_temperature( + self, + operations_service, + mock_llm_service, + sample_business_context, + ): + """Test that prioritization uses temperature 0.5.""" + # Arrange + actions = [ + { + "id": "act_1", + "title": "Test", + "currentPriority": "medium", + "status": "planned", + "linkedGoals": [], + } + ] + mock_llm_service.generate_analysis.return_value.content = "[]" + + # Act + await operations_service.suggest_prioritization( + actions=actions, + business_context=sample_business_context, + ) + + # Assert + call_args = mock_llm_service.generate_analysis.call_args + assert call_args[1]["temperature"] == 0.5 + + @pytest.mark.asyncio + async def test_prioritization_handles_malformed_json( + self, + operations_service, + mock_llm_service, + sample_business_context, + ): + """Test fallback when LLM returns malformed JSON.""" + # Arrange + actions = [ + { + "id": "act_1", + "title": "Test", + "currentPriority": "medium", + "status": "planned", + "linkedGoals": [], + } + ] + mock_llm_service.generate_analysis.return_value.content = "Invalid JSON" + + # Act + result = await operations_service.suggest_prioritization( + actions=actions, + business_context=sample_business_context, + ) + + # Assert - Should return fallback suggestions + assert isinstance(result, list) + assert len(result) == len(actions) + assert result[0]["actionId"] == "act_1" + assert result[0]["confidence"] == 0.5 + assert result[0]["recommendedAction"] == "maintain" + + +# ============================================================================ +# Scheduling Tests +# ============================================================================ + + +class TestOptimizeScheduling: + """Test suite for optimize_scheduling method.""" + + @pytest.mark.asyncio + async def test_successful_scheduling_optimization( + self, + operations_service, + mock_llm_service, + sample_scheduling_constraints, + ): + """Test successful scheduling optimization.""" + # Arrange + actions = [ + { + "id": "act_1", + "title": "Develop feature", + "estimatedDuration": 40, + "dependencies": [], + "assignedTo": "dev_1", + "currentStartDate": None, + "currentDueDate": None, + "priority": "high", + } + ] + llm_response = """[ + { + "actionId": "act_1", + "suggestedStartDate": "2025-11-01", + "suggestedDueDate": "2025-11-05", + "reasoning": "Optimal schedule considering team capacity", + "confidence": 0.88, + "dependencies": [], + "resourceConsiderations": ["dev_1 has 40h available"], + "risks": ["Tight deadline before product launch"], + "alternativeSchedules": [ + { + "startDate": "2025-11-08", + "dueDate": "2025-11-12", + "rationale": "More buffer time before launch" + } + ] + } + ]""" + mock_llm_service.generate_analysis.return_value.content = llm_response + + # Act + result = await operations_service.optimize_scheduling( + actions=actions, + constraints=sample_scheduling_constraints, + ) + + # Assert + assert isinstance(result, list) + assert len(result) >= 1 + assert result[0]["actionId"] == "act_1" + assert result[0]["suggestedStartDate"] == "2025-11-01" + assert result[0]["confidence"] == 0.88 + assert len(result[0]["alternativeSchedules"]) >= 1 + mock_llm_service.generate_analysis.assert_called_once() + + @pytest.mark.asyncio + async def test_scheduling_with_no_actions_raises_error( + self, + operations_service, + sample_scheduling_constraints, + ): + """Test that empty actions list raises ValueError.""" + # Act & Assert + with pytest.raises(ValueError) as exc_info: + await operations_service.optimize_scheduling( + actions=[], + constraints=sample_scheduling_constraints, + ) + assert "At least one action is required" in str(exc_info.value) + + @pytest.mark.asyncio + async def test_scheduling_with_no_capacity_raises_error( + self, + operations_service, + ): + """Test that missing team capacity raises ValueError.""" + # Arrange + actions = [{"id": "act_1", "title": "Test", "estimatedDuration": 40, "priority": "high"}] + + # Act & Assert + with pytest.raises(ValueError) as exc_info: + await operations_service.optimize_scheduling( + actions=actions, + constraints={}, + ) + assert "Team capacity is required" in str(exc_info.value) + + @pytest.mark.asyncio + async def test_scheduling_uses_correct_temperature( + self, + operations_service, + mock_llm_service, + sample_scheduling_constraints, + ): + """Test that scheduling uses temperature 0.4.""" + # Arrange + actions = [{"id": "act_1", "title": "Test", "estimatedDuration": 40, "priority": "high"}] + mock_llm_service.generate_analysis.return_value.content = "[]" + + # Act + await operations_service.optimize_scheduling( + actions=actions, + constraints=sample_scheduling_constraints, + ) + + # Assert + call_args = mock_llm_service.generate_analysis.call_args + assert call_args[1]["temperature"] == 0.4 + + @pytest.mark.asyncio + async def test_scheduling_handles_malformed_json( + self, + operations_service, + mock_llm_service, + sample_scheduling_constraints, + ): + """Test fallback when LLM returns malformed JSON.""" + # Arrange + actions = [{"id": "act_1", "title": "Test", "estimatedDuration": 40, "priority": "high"}] + mock_llm_service.generate_analysis.return_value.content = "Not JSON" + + # Act + result = await operations_service.optimize_scheduling( + actions=actions, + constraints=sample_scheduling_constraints, + ) + + # Assert - Should return fallback schedules + assert isinstance(result, list) + assert len(result) == len(actions) + assert result[0]["actionId"] == "act_1" + assert "suggestedStartDate" in result[0] + assert result[0]["confidence"] == 0.5 + + +# ============================================================================ +# Root Cause Suggestions Tests (Issue #64) +# ============================================================================ + + +class TestSuggestRootCauseMethods: + """Test suite for suggest_root_cause_methods method.""" + + @pytest.mark.asyncio + async def test_successful_root_cause_suggestions( + self, + operations_service, + mock_llm_service, + ): + """Test successful root cause method suggestions.""" + # Arrange + issue = { + "issueTitle": "Customer retention declining", + "issueDescription": "We've seen a 20% drop in customer retention over the last quarter", + "businessImpact": "high", + } + context = { + "reportedBy": "Sales team", + "dateReported": "2025-10-20", + "affectedAreas": ["Customer Success", "Sales"], + "relatedActions": ["act_123"], + } + + llm_response = """[ + { + "method": "five_whys", + "confidence": 0.92, + "suggestions": { + "fiveWhys": { + "suggestedQuestions": [ + "Why is customer retention declining?", + "Why are customers choosing competitors?" + ], + "potentialRootCauses": [ + "Product quality issues", + "Poor customer support" + ] + } + }, + "reasoning": "Five Whys is ideal for operational issues" + }, + { + "method": "swot", + "confidence": 0.85, + "suggestions": { + "swot": { + "strengths": ["Strong brand"], + "weaknesses": ["Limited support staff"], + "opportunities": ["Automation"], + "threats": ["Competitors"] + } + }, + "reasoning": "SWOT helps identify strategic factors" + } + ]""" + mock_llm_service.generate_analysis.return_value.content = llm_response + + # Act + result = await operations_service.suggest_root_cause_methods( + issue=issue, + context=context, + ) + + # Assert + assert isinstance(result, list) + assert len(result) == 2 + assert result[0]["method"] == "five_whys" + assert result[0]["confidence"] == 0.92 + assert "fiveWhys" in result[0]["suggestions"] + assert len(result[0]["suggestions"]["fiveWhys"]["suggestedQuestions"]) >= 2 + mock_llm_service.generate_analysis.assert_called_once() + + @pytest.mark.asyncio + async def test_root_cause_with_missing_title_raises_error( + self, + operations_service, + ): + """Test that missing issue title raises ValueError.""" + # Act & Assert + with pytest.raises(ValueError) as exc_info: + await operations_service.suggest_root_cause_methods( + issue={"issueDescription": "Test"}, + context={}, + ) + assert "title and description" in str(exc_info.value).lower() + + @pytest.mark.asyncio + async def test_root_cause_uses_correct_temperature( + self, + operations_service, + mock_llm_service, + ): + """Test that root cause analysis uses temperature 0.6.""" + # Arrange + issue = { + "issueTitle": "Test issue", + "issueDescription": "Test description", + "businessImpact": "medium", + } + mock_llm_service.generate_analysis.return_value.content = "[]" + + # Act + await operations_service.suggest_root_cause_methods( + issue=issue, + context={}, + ) + + # Assert + call_args = mock_llm_service.generate_analysis.call_args + assert call_args[1]["temperature"] == 0.6 + + @pytest.mark.asyncio + async def test_root_cause_handles_malformed_json( + self, + operations_service, + mock_llm_service, + ): + """Test fallback when LLM returns malformed JSON.""" + # Arrange + issue = { + "issueTitle": "Test issue", + "issueDescription": "Test description", + "businessImpact": "medium", + } + mock_llm_service.generate_analysis.return_value.content = "Not JSON" + + # Act + result = await operations_service.suggest_root_cause_methods( + issue=issue, + context={}, + ) + + # Assert - Should return fallback suggestions + assert isinstance(result, list) + assert len(result) >= 1 + assert result[0]["method"] == "five_whys" + assert result[0]["confidence"] == 0.7 + assert "fiveWhys" in result[0]["suggestions"] + + +# ============================================================================ +# Action Plan Generation Tests (Issue #64) +# ============================================================================ + + +class TestGenerateActionPlan: + """Test suite for generate_action_plan method.""" + + @pytest.mark.asyncio + async def test_successful_action_plan_generation( + self, + operations_service, + mock_llm_service, + ): + """Test successful action plan generation.""" + # Arrange + issue = { + "title": "System performance degradation", + "description": "API response times have increased by 300%", + "impact": "critical", + "rootCause": "Database query optimization needed", + } + constraints = { + "timeline": "2 weeks", + "budget": 10000, + "availableResources": ["2 backend developers", "1 DBA"], + } + context = { + "relatedGoals": ["Improve system reliability"], + "currentActions": ["Monitoring implementation"], + "businessPriorities": ["Customer experience", "System stability"], + } + + llm_response = """[ + { + "title": "Optimize database queries", + "description": "Review and optimize slow queries identified in logs", + "priority": "critical", + "estimatedDuration": 40, + "estimatedCost": 5000, + "assignmentSuggestion": "Senior Backend Developer + DBA", + "dependencies": [], + "confidence": 0.95, + "reasoning": "Directly addresses identified root cause", + "expectedOutcome": "50% reduction in response times", + "risks": ["May require schema changes"] + }, + { + "title": "Implement query caching", + "description": "Add Redis caching layer for frequent queries", + "priority": "high", + "estimatedDuration": 24, + "estimatedCost": 3000, + "assignmentSuggestion": "Backend Developer", + "dependencies": ["Optimize database queries"], + "confidence": 0.88, + "reasoning": "Prevents similar issues in future", + "expectedOutcome": "Further 30% performance improvement", + "risks": ["Cache invalidation complexity"] + } + ]""" + mock_llm_service.generate_analysis.return_value.content = llm_response + + # Act + result = await operations_service.generate_action_plan( + issue=issue, + constraints=constraints, + context=context, + ) + + # Assert + assert isinstance(result, list) + assert len(result) == 2 + assert result[0]["title"] == "Optimize database queries" + assert result[0]["priority"] == "critical" + assert result[0]["estimatedDuration"] == 40 + assert result[0]["confidence"] == 0.95 + assert len(result[0]["risks"]) > 0 + mock_llm_service.generate_analysis.assert_called_once() + + @pytest.mark.asyncio + async def test_action_plan_with_missing_title_raises_error( + self, + operations_service, + ): + """Test that missing issue title raises ValueError.""" + # Act & Assert + with pytest.raises(ValueError) as exc_info: + await operations_service.generate_action_plan( + issue={"description": "Test"}, + constraints={}, + context={}, + ) + assert "title and description" in str(exc_info.value).lower() + + @pytest.mark.asyncio + async def test_action_plan_uses_correct_temperature( + self, + operations_service, + mock_llm_service, + ): + """Test that action plan generation uses temperature 0.5.""" + # Arrange + issue = { + "title": "Test issue", + "description": "Test description", + "impact": "medium", + } + mock_llm_service.generate_analysis.return_value.content = "[]" + + # Act + await operations_service.generate_action_plan( + issue=issue, + constraints={}, + context={}, + ) + + # Assert + call_args = mock_llm_service.generate_analysis.call_args + assert call_args[1]["temperature"] == 0.5 + + @pytest.mark.asyncio + async def test_action_plan_handles_malformed_json( + self, + operations_service, + mock_llm_service, + ): + """Test fallback when LLM returns malformed JSON.""" + # Arrange + issue = { + "title": "Test issue", + "description": "Test description", + "impact": "medium", + } + mock_llm_service.generate_analysis.return_value.content = "Not JSON" + + # Act + result = await operations_service.generate_action_plan( + issue=issue, + constraints={}, + context={}, + ) + + # Assert - Should return fallback actions + assert isinstance(result, list) + assert len(result) >= 2 + assert result[0]["title"] == "Investigate root cause" + assert result[0]["priority"] == "high" + assert result[0]["confidence"] == 0.8 + assert len(result[0]["risks"]) > 0 diff --git a/coaching/tests/unit/application/analysis/test_strategy_suggestion_service.py b/coaching/tests/unit/application/analysis/test_strategy_suggestion_service.py index 404b1bea..2c937ada 100644 --- a/coaching/tests/unit/application/analysis/test_strategy_suggestion_service.py +++ b/coaching/tests/unit/application/analysis/test_strategy_suggestion_service.py @@ -1,183 +1,184 @@ -from unittest.mock import Mock - -import pytest -from coaching.src.application.analysis.strategy_suggestion_service import StrategySuggestionService -from coaching.src.application.llm.llm_service import LLMApplicationService -from coaching.src.core.constants import AnalysisType - - -class TestStrategySuggestionService: - @pytest.fixture - def mock_llm_service(self): - return Mock(spec=LLMApplicationService) - - @pytest.fixture - def service(self, mock_llm_service): - return StrategySuggestionService(llm_service=mock_llm_service) - - def test_get_analysis_type(self, service): - """Test that the correct analysis type is returned.""" - assert service.get_analysis_type() == AnalysisType.STRATEGY - - def test_build_prompt_full_context(self, service): - """Test building prompt with full context provided.""" - context = { - "goal_intent": "Increase market share by 20%", - "business_context": { - "vision": "To be the best", - "purpose": "Serve customers", - "coreValues": ["Integrity", "Innovation"], - "targetMarket": "SMEs", - "valueProposition": "Affordable quality", - "industry": "Tech", - "businessType": "B2B", - }, - "existing_strategies": ["Social media marketing"], - "constraints": { - "budget": 50000, - "timeline": "6 months", - "resources": ["Marketing team", "External consultant"], - }, - } - - prompt = service.build_prompt(context) - - assert "Increase market share by 20%" in prompt - assert "To be the best" in prompt - assert "Integrity, Innovation" in prompt - assert "Social media marketing" in prompt - assert "Budget: $50,000" in prompt - assert "Timeline: 6 months" in prompt - assert "Marketing team, External consultant" in prompt - - def test_build_prompt_minimal_context(self, service): - """Test building prompt with minimal context.""" - context = {"goal_intent": "Grow revenue"} - - prompt = service.build_prompt(context) - - assert "Grow revenue" in prompt - assert "Vision: Not defined" in prompt - assert "None currently in place" in prompt - # Constraints section should be empty or minimal - assert "Resource Constraints" not in prompt - - def test_parse_response_valid_json(self, service): - """Test parsing a valid JSON response.""" - valid_json = """ - { - "suggestions": [ - { - "title": "Expand Sales Team", - "description": "Hire 5 new sales reps", - "rationale": "Direct sales needed", - "difficulty": "medium", - "timeframe": "3 months", - "expectedImpact": "high", - "prerequisites": ["Budget approval"], - "estimatedCost": 100000, - "requiredResources": ["HR", "Sales Manager"] - } - ], - "confidence": 0.9, - "reasoning": "Solid plan" - } - """ - result = service.parse_response(valid_json) - - assert result["confidence"] == 0.9 - assert len(result["suggestions"]) == 1 - assert result["suggestions"][0]["title"] == "Expand Sales Team" - - def test_parse_response_markdown_json(self, service): - """Test parsing JSON wrapped in markdown code blocks.""" - markdown_json = """ - ```json - { - "suggestions": [ - { - "title": "Strategy A", - "description": "Desc A", - "rationale": "Rationale A", - "difficulty": "low", - "timeframe": "1 month", - "expectedImpact": "medium" - } - ], - "confidence": 0.8, - "reasoning": "Good" - } - ``` - """ - result = service.parse_response(markdown_json) - assert result["confidence"] == 0.8 - assert result["suggestions"][0]["title"] == "Strategy A" - - def test_parse_response_missing_required_field(self, service): - """Test parsing response missing top-level required field.""" - invalid_json = '{"suggestions": []}' - - with pytest.raises(ValueError, match="Missing required field: confidence"): - service.parse_response(invalid_json) - - def test_parse_response_empty_suggestions(self, service): - """Test parsing response with empty suggestions list.""" - invalid_json = """ - { - "suggestions": [], - "confidence": 0.5, - "reasoning": "None" - } - """ - with pytest.raises(ValueError, match="At least one suggestion is required"): - service.parse_response(invalid_json) - - def test_parse_response_invalid_suggestion_structure(self, service): - """Test parsing response with invalid suggestion structure.""" - invalid_json = """ - { - "suggestions": [ - { - "title": "Incomplete Strategy" - } - ], - "confidence": 0.5, - "reasoning": "Bad" - } - """ - with pytest.raises(ValueError, match="Suggestion 0 missing required field"): - service.parse_response(invalid_json) - - def test_parse_response_invalid_json_syntax(self, service): - """Test parsing invalid JSON syntax.""" - invalid_json = "{ not valid json }" - - with pytest.raises(ValueError, match="Invalid JSON response"): - service.parse_response(invalid_json) - - def test_parse_response_defaults_and_validation(self, service): - """Test default values and validation for difficulty/impact.""" - json_response = """ - { - "suggestions": [ - { - "title": "Test Strategy", - "description": "Desc", - "rationale": "Rat", - "difficulty": "invalid_diff", - "timeframe": "1m", - "expectedImpact": "invalid_impact" - } - ], - "confidence": 1.5, - "reasoning": "Reason" - } - """ - result = service.parse_response(json_response) - - suggestion = result["suggestions"][0] - assert suggestion["difficulty"] == "medium" # Defaulted - assert suggestion["expectedImpact"] == "medium" # Defaulted - assert suggestion["prerequisites"] == [] # Defaulted - assert suggestion["estimatedCost"] is None # Defaulted - assert result["confidence"] == 1.0 # Capped +from unittest.mock import Mock + +import pytest + +from coaching.src.application.analysis.strategy_suggestion_service import StrategySuggestionService +from coaching.src.application.llm.llm_service import LLMApplicationService +from coaching.src.core.constants import AnalysisType + + +class TestStrategySuggestionService: + @pytest.fixture + def mock_llm_service(self): + return Mock(spec=LLMApplicationService) + + @pytest.fixture + def service(self, mock_llm_service): + return StrategySuggestionService(llm_service=mock_llm_service) + + def test_get_analysis_type(self, service): + """Test that the correct analysis type is returned.""" + assert service.get_analysis_type() == AnalysisType.STRATEGY + + def test_build_prompt_full_context(self, service): + """Test building prompt with full context provided.""" + context = { + "goal_intent": "Increase market share by 20%", + "business_context": { + "vision": "To be the best", + "purpose": "Serve customers", + "coreValues": ["Integrity", "Innovation"], + "targetMarket": "SMEs", + "valueProposition": "Affordable quality", + "industry": "Tech", + "businessType": "B2B", + }, + "existing_strategies": ["Social media marketing"], + "constraints": { + "budget": 50000, + "timeline": "6 months", + "resources": ["Marketing team", "External consultant"], + }, + } + + prompt = service.build_prompt(context) + + assert "Increase market share by 20%" in prompt + assert "To be the best" in prompt + assert "Integrity, Innovation" in prompt + assert "Social media marketing" in prompt + assert "Budget: $50,000" in prompt + assert "Timeline: 6 months" in prompt + assert "Marketing team, External consultant" in prompt + + def test_build_prompt_minimal_context(self, service): + """Test building prompt with minimal context.""" + context = {"goal_intent": "Grow revenue"} + + prompt = service.build_prompt(context) + + assert "Grow revenue" in prompt + assert "Vision: Not defined" in prompt + assert "None currently in place" in prompt + # Constraints section should be empty or minimal + assert "Resource Constraints" not in prompt + + def test_parse_response_valid_json(self, service): + """Test parsing a valid JSON response.""" + valid_json = """ + { + "suggestions": [ + { + "title": "Expand Sales Team", + "description": "Hire 5 new sales reps", + "rationale": "Direct sales needed", + "difficulty": "medium", + "timeframe": "3 months", + "expectedImpact": "high", + "prerequisites": ["Budget approval"], + "estimatedCost": 100000, + "requiredResources": ["HR", "Sales Manager"] + } + ], + "confidence": 0.9, + "reasoning": "Solid plan" + } + """ + result = service.parse_response(valid_json) + + assert result["confidence"] == 0.9 + assert len(result["suggestions"]) == 1 + assert result["suggestions"][0]["title"] == "Expand Sales Team" + + def test_parse_response_markdown_json(self, service): + """Test parsing JSON wrapped in markdown code blocks.""" + markdown_json = """ + ```json + { + "suggestions": [ + { + "title": "Strategy A", + "description": "Desc A", + "rationale": "Rationale A", + "difficulty": "low", + "timeframe": "1 month", + "expectedImpact": "medium" + } + ], + "confidence": 0.8, + "reasoning": "Good" + } + ``` + """ + result = service.parse_response(markdown_json) + assert result["confidence"] == 0.8 + assert result["suggestions"][0]["title"] == "Strategy A" + + def test_parse_response_missing_required_field(self, service): + """Test parsing response missing top-level required field.""" + invalid_json = '{"suggestions": []}' + + with pytest.raises(ValueError, match="Missing required field: confidence"): + service.parse_response(invalid_json) + + def test_parse_response_empty_suggestions(self, service): + """Test parsing response with empty suggestions list.""" + invalid_json = """ + { + "suggestions": [], + "confidence": 0.5, + "reasoning": "None" + } + """ + with pytest.raises(ValueError, match="At least one suggestion is required"): + service.parse_response(invalid_json) + + def test_parse_response_invalid_suggestion_structure(self, service): + """Test parsing response with invalid suggestion structure.""" + invalid_json = """ + { + "suggestions": [ + { + "title": "Incomplete Strategy" + } + ], + "confidence": 0.5, + "reasoning": "Bad" + } + """ + with pytest.raises(ValueError, match="Suggestion 0 missing required field"): + service.parse_response(invalid_json) + + def test_parse_response_invalid_json_syntax(self, service): + """Test parsing invalid JSON syntax.""" + invalid_json = "{ not valid json }" + + with pytest.raises(ValueError, match="Invalid JSON response"): + service.parse_response(invalid_json) + + def test_parse_response_defaults_and_validation(self, service): + """Test default values and validation for difficulty/impact.""" + json_response = """ + { + "suggestions": [ + { + "title": "Test Strategy", + "description": "Desc", + "rationale": "Rat", + "difficulty": "invalid_diff", + "timeframe": "1m", + "expectedImpact": "invalid_impact" + } + ], + "confidence": 1.5, + "reasoning": "Reason" + } + """ + result = service.parse_response(json_response) + + suggestion = result["suggestions"][0] + assert suggestion["difficulty"] == "medium" # Defaulted + assert suggestion["expectedImpact"] == "medium" # Defaulted + assert suggestion["prerequisites"] == [] # Defaulted + assert suggestion["estimatedCost"] is None # Defaulted + assert result["confidence"] == 1.0 # Capped diff --git a/coaching/tests/unit/application/conversation/test_conversation_application_service.py b/coaching/tests/unit/application/conversation/test_conversation_application_service.py index db861c21..6c9329aa 100644 --- a/coaching/tests/unit/application/conversation/test_conversation_application_service.py +++ b/coaching/tests/unit/application/conversation/test_conversation_application_service.py @@ -1,259 +1,260 @@ -from unittest.mock import AsyncMock - -import pytest -from coaching.src.application.conversation.conversation_service import ( - ConversationApplicationService, -) -from coaching.src.core.constants import CoachingTopic, ConversationStatus, MessageRole -from coaching.src.core.types import ConversationId, TenantId, UserId -from coaching.src.domain.entities.conversation import Conversation -from coaching.src.domain.exceptions.conversation_exceptions import ( - ConversationNotActive, - ConversationNotFound, -) -from coaching.src.domain.ports.conversation_repository_port import ConversationRepositoryPort - - -@pytest.fixture -def mock_repo(): - return AsyncMock(spec=ConversationRepositoryPort) - - -@pytest.fixture -def service(mock_repo): - return ConversationApplicationService(mock_repo) - - -@pytest.fixture -def user_id(): - return UserId("user_123") - - -@pytest.fixture -def tenant_id(): - return TenantId("tenant_456") - - -@pytest.fixture -def conversation_id(): - return ConversationId("conv_123") - - -@pytest.fixture -def sample_conversation(user_id, tenant_id, conversation_id): - return Conversation( - conversation_id=conversation_id, - user_id=user_id, - tenant_id=tenant_id, - topic=CoachingTopic.CORE_VALUES, - metadata={}, - ) - - -@pytest.mark.asyncio -class TestConversationApplicationService: - async def test_start_conversation_success(self, service, mock_repo, user_id, tenant_id): - # Arrange - mock_repo.get_active_count.return_value = 0 - mock_repo.save.return_value = None - - # Act - conversation = await service.start_conversation( - user_id=user_id, - tenant_id=tenant_id, - topic=CoachingTopic.CORE_VALUES, - initial_message_content="Hello", - ) - - # Assert - assert conversation.user_id == user_id - assert conversation.tenant_id == tenant_id - assert conversation.topic == CoachingTopic.CORE_VALUES - assert len(conversation.messages) == 1 - assert conversation.messages[0].role == MessageRole.ASSISTANT - assert conversation.messages[0].content == "Hello" - mock_repo.save.assert_called_once() - - async def test_start_conversation_limit_warning(self, service, mock_repo, user_id, tenant_id): - # Arrange - mock_repo.get_active_count.return_value = 5 - mock_repo.save.return_value = None - - # Act - conversation = await service.start_conversation( - user_id=user_id, - tenant_id=tenant_id, - topic=CoachingTopic.CORE_VALUES, - initial_message_content="Hello", - ) - - # Assert - assert conversation is not None - mock_repo.save.assert_called_once() - - async def test_add_message_success(self, service, mock_repo, sample_conversation): - # Arrange - mock_repo.get_by_id.return_value = sample_conversation - mock_repo.save.return_value = None - - # Act - updated_conversation = await service.add_message( - conversation_id=sample_conversation.conversation_id, - tenant_id=sample_conversation.tenant_id, - role=MessageRole.USER, - content="User message", - ) - - # Assert - assert len(updated_conversation.messages) == 1 - assert updated_conversation.messages[0].role == MessageRole.USER - assert updated_conversation.messages[0].content == "User message" - mock_repo.save.assert_called_once() - - async def test_add_message_not_found(self, service, mock_repo, conversation_id, tenant_id): - # Arrange - mock_repo.get_by_id.return_value = None - - # Act & Assert - with pytest.raises(ConversationNotFound): - await service.add_message( - conversation_id=conversation_id, - tenant_id=tenant_id, - role=MessageRole.USER, - content="User message", - ) - - async def test_add_message_not_active(self, service, mock_repo, sample_conversation): - # Arrange - from coaching.src.core.constants import ConversationPhase - - new_context = sample_conversation.context.model_copy( - update={"current_phase": ConversationPhase.COMPLETION} - ) - object.__setattr__(sample_conversation, "context", new_context) - - sample_conversation.mark_completed() - mock_repo.get_by_id.return_value = sample_conversation - - # Act & Assert - with pytest.raises(ConversationNotActive): - await service.add_message( - conversation_id=sample_conversation.conversation_id, - tenant_id=sample_conversation.tenant_id, - role=MessageRole.USER, - content="User message", - ) - - async def test_get_conversation_success(self, service, mock_repo, sample_conversation): - # Arrange - mock_repo.get_by_id.return_value = sample_conversation - - # Act - conversation = await service.get_conversation( - conversation_id=sample_conversation.conversation_id, - tenant_id=sample_conversation.tenant_id, - ) - - # Assert - assert conversation == sample_conversation - - async def test_get_conversation_not_found(self, service, mock_repo, conversation_id, tenant_id): - # Arrange - mock_repo.get_by_id.return_value = None - - # Act & Assert - with pytest.raises(ConversationNotFound): - await service.get_conversation(conversation_id, tenant_id) - - async def test_list_user_conversations( - self, service, mock_repo, user_id, tenant_id, sample_conversation - ): - # Arrange - mock_repo.get_by_user.return_value = [sample_conversation] - - # Act - conversations = await service.list_user_conversations(user_id=user_id, tenant_id=tenant_id) - - # Assert - assert len(conversations) == 1 - assert conversations[0] == sample_conversation - mock_repo.get_by_user.assert_called_once_with( - user_id=user_id, tenant_id=tenant_id, limit=10, active_only=False - ) - - async def test_pause_conversation(self, service, mock_repo, sample_conversation): - # Arrange - mock_repo.get_by_id.return_value = sample_conversation - mock_repo.save.return_value = None - - # Act - conversation = await service.pause_conversation( - conversation_id=sample_conversation.conversation_id, - tenant_id=sample_conversation.tenant_id, - ) - - # Assert - assert conversation.status == ConversationStatus.PAUSED - mock_repo.save.assert_called_once() - - async def test_resume_conversation(self, service, mock_repo, sample_conversation): - # Arrange - sample_conversation.mark_paused() - mock_repo.get_by_id.return_value = sample_conversation - mock_repo.save.return_value = None - - # Act - conversation = await service.resume_conversation( - conversation_id=sample_conversation.conversation_id, - tenant_id=sample_conversation.tenant_id, - ) - - # Assert - assert conversation.status == ConversationStatus.ACTIVE - mock_repo.save.assert_called_once() - - async def test_complete_conversation(self, service, mock_repo, sample_conversation): - # Arrange - from coaching.src.core.constants import ConversationPhase - - new_context = sample_conversation.context.model_copy( - update={"current_phase": ConversationPhase.COMPLETION} - ) - object.__setattr__(sample_conversation, "context", new_context) - - mock_repo.get_by_id.return_value = sample_conversation - mock_repo.save.return_value = None - - # Act - conversation = await service.complete_conversation( - conversation_id=sample_conversation.conversation_id, - tenant_id=sample_conversation.tenant_id, - ) - - # Assert - assert conversation.status == ConversationStatus.COMPLETED - mock_repo.save.assert_called_once() - - async def test_abandon_conversation(self, service, mock_repo, conversation_id, tenant_id): - # Arrange - mock_repo.delete.return_value = True - - # Act - result = await service.abandon_conversation(conversation_id, tenant_id) - - # Assert - assert result is True - mock_repo.delete.assert_called_once_with(conversation_id, tenant_id) - - async def test_abandon_conversation_not_found( - self, service, mock_repo, conversation_id, tenant_id - ): - # Arrange - mock_repo.delete.return_value = False - - # Act - result = await service.abandon_conversation(conversation_id, tenant_id) - - # Assert - assert result is False - mock_repo.delete.assert_called_once_with(conversation_id, tenant_id) +from unittest.mock import AsyncMock + +import pytest + +from coaching.src.application.conversation.conversation_service import ( + ConversationApplicationService, +) +from coaching.src.core.constants import CoachingTopic, ConversationStatus, MessageRole +from coaching.src.core.types import ConversationId, TenantId, UserId +from coaching.src.domain.entities.conversation import Conversation +from coaching.src.domain.exceptions.conversation_exceptions import ( + ConversationNotActive, + ConversationNotFound, +) +from coaching.src.domain.ports.conversation_repository_port import ConversationRepositoryPort + + +@pytest.fixture +def mock_repo(): + return AsyncMock(spec=ConversationRepositoryPort) + + +@pytest.fixture +def service(mock_repo): + return ConversationApplicationService(mock_repo) + + +@pytest.fixture +def user_id(): + return UserId("user_123") + + +@pytest.fixture +def tenant_id(): + return TenantId("tenant_456") + + +@pytest.fixture +def conversation_id(): + return ConversationId("conv_123") + + +@pytest.fixture +def sample_conversation(user_id, tenant_id, conversation_id): + return Conversation( + conversation_id=conversation_id, + user_id=user_id, + tenant_id=tenant_id, + topic=CoachingTopic.CORE_VALUES, + metadata={}, + ) + + +@pytest.mark.asyncio +class TestConversationApplicationService: + async def test_start_conversation_success(self, service, mock_repo, user_id, tenant_id): + # Arrange + mock_repo.get_active_count.return_value = 0 + mock_repo.save.return_value = None + + # Act + conversation = await service.start_conversation( + user_id=user_id, + tenant_id=tenant_id, + topic=CoachingTopic.CORE_VALUES, + initial_message_content="Hello", + ) + + # Assert + assert conversation.user_id == user_id + assert conversation.tenant_id == tenant_id + assert conversation.topic == CoachingTopic.CORE_VALUES + assert len(conversation.messages) == 1 + assert conversation.messages[0].role == MessageRole.ASSISTANT + assert conversation.messages[0].content == "Hello" + mock_repo.save.assert_called_once() + + async def test_start_conversation_limit_warning(self, service, mock_repo, user_id, tenant_id): + # Arrange + mock_repo.get_active_count.return_value = 5 + mock_repo.save.return_value = None + + # Act + conversation = await service.start_conversation( + user_id=user_id, + tenant_id=tenant_id, + topic=CoachingTopic.CORE_VALUES, + initial_message_content="Hello", + ) + + # Assert + assert conversation is not None + mock_repo.save.assert_called_once() + + async def test_add_message_success(self, service, mock_repo, sample_conversation): + # Arrange + mock_repo.get_by_id.return_value = sample_conversation + mock_repo.save.return_value = None + + # Act + updated_conversation = await service.add_message( + conversation_id=sample_conversation.conversation_id, + tenant_id=sample_conversation.tenant_id, + role=MessageRole.USER, + content="User message", + ) + + # Assert + assert len(updated_conversation.messages) == 1 + assert updated_conversation.messages[0].role == MessageRole.USER + assert updated_conversation.messages[0].content == "User message" + mock_repo.save.assert_called_once() + + async def test_add_message_not_found(self, service, mock_repo, conversation_id, tenant_id): + # Arrange + mock_repo.get_by_id.return_value = None + + # Act & Assert + with pytest.raises(ConversationNotFound): + await service.add_message( + conversation_id=conversation_id, + tenant_id=tenant_id, + role=MessageRole.USER, + content="User message", + ) + + async def test_add_message_not_active(self, service, mock_repo, sample_conversation): + # Arrange + from coaching.src.core.constants import ConversationPhase + + new_context = sample_conversation.context.model_copy( + update={"current_phase": ConversationPhase.COMPLETION} + ) + object.__setattr__(sample_conversation, "context", new_context) + + sample_conversation.mark_completed() + mock_repo.get_by_id.return_value = sample_conversation + + # Act & Assert + with pytest.raises(ConversationNotActive): + await service.add_message( + conversation_id=sample_conversation.conversation_id, + tenant_id=sample_conversation.tenant_id, + role=MessageRole.USER, + content="User message", + ) + + async def test_get_conversation_success(self, service, mock_repo, sample_conversation): + # Arrange + mock_repo.get_by_id.return_value = sample_conversation + + # Act + conversation = await service.get_conversation( + conversation_id=sample_conversation.conversation_id, + tenant_id=sample_conversation.tenant_id, + ) + + # Assert + assert conversation == sample_conversation + + async def test_get_conversation_not_found(self, service, mock_repo, conversation_id, tenant_id): + # Arrange + mock_repo.get_by_id.return_value = None + + # Act & Assert + with pytest.raises(ConversationNotFound): + await service.get_conversation(conversation_id, tenant_id) + + async def test_list_user_conversations( + self, service, mock_repo, user_id, tenant_id, sample_conversation + ): + # Arrange + mock_repo.get_by_user.return_value = [sample_conversation] + + # Act + conversations = await service.list_user_conversations(user_id=user_id, tenant_id=tenant_id) + + # Assert + assert len(conversations) == 1 + assert conversations[0] == sample_conversation + mock_repo.get_by_user.assert_called_once_with( + user_id=user_id, tenant_id=tenant_id, limit=10, active_only=False + ) + + async def test_pause_conversation(self, service, mock_repo, sample_conversation): + # Arrange + mock_repo.get_by_id.return_value = sample_conversation + mock_repo.save.return_value = None + + # Act + conversation = await service.pause_conversation( + conversation_id=sample_conversation.conversation_id, + tenant_id=sample_conversation.tenant_id, + ) + + # Assert + assert conversation.status == ConversationStatus.PAUSED + mock_repo.save.assert_called_once() + + async def test_resume_conversation(self, service, mock_repo, sample_conversation): + # Arrange + sample_conversation.mark_paused() + mock_repo.get_by_id.return_value = sample_conversation + mock_repo.save.return_value = None + + # Act + conversation = await service.resume_conversation( + conversation_id=sample_conversation.conversation_id, + tenant_id=sample_conversation.tenant_id, + ) + + # Assert + assert conversation.status == ConversationStatus.ACTIVE + mock_repo.save.assert_called_once() + + async def test_complete_conversation(self, service, mock_repo, sample_conversation): + # Arrange + from coaching.src.core.constants import ConversationPhase + + new_context = sample_conversation.context.model_copy( + update={"current_phase": ConversationPhase.COMPLETION} + ) + object.__setattr__(sample_conversation, "context", new_context) + + mock_repo.get_by_id.return_value = sample_conversation + mock_repo.save.return_value = None + + # Act + conversation = await service.complete_conversation( + conversation_id=sample_conversation.conversation_id, + tenant_id=sample_conversation.tenant_id, + ) + + # Assert + assert conversation.status == ConversationStatus.COMPLETED + mock_repo.save.assert_called_once() + + async def test_abandon_conversation(self, service, mock_repo, conversation_id, tenant_id): + # Arrange + mock_repo.delete.return_value = True + + # Act + result = await service.abandon_conversation(conversation_id, tenant_id) + + # Assert + assert result is True + mock_repo.delete.assert_called_once_with(conversation_id, tenant_id) + + async def test_abandon_conversation_not_found( + self, service, mock_repo, conversation_id, tenant_id + ): + # Arrange + mock_repo.delete.return_value = False + + # Act + result = await service.abandon_conversation(conversation_id, tenant_id) + + # Assert + assert result is False + mock_repo.delete.assert_called_once_with(conversation_id, tenant_id) diff --git a/coaching/tests/unit/application/llm/test_llm_application_service.py b/coaching/tests/unit/application/llm/test_llm_application_service.py index b80d9914..00213aed 100644 --- a/coaching/tests/unit/application/llm/test_llm_application_service.py +++ b/coaching/tests/unit/application/llm/test_llm_application_service.py @@ -1,157 +1,158 @@ -from unittest.mock import AsyncMock - -import pytest -from coaching.src.application.llm.llm_service import LLMApplicationService -from coaching.src.domain.ports.llm_provider_port import LLMMessage, LLMProviderPort, LLMResponse - - -@pytest.fixture -def mock_provider(): - provider = AsyncMock(spec=LLMProviderPort) - provider.provider_name = "mock_provider" - provider.supported_models = ["model-v1", "model-v2"] - return provider - - -@pytest.fixture -def service(mock_provider): - return LLMApplicationService(mock_provider) - - -@pytest.fixture -def messages(): - return [LLMMessage(role="user", content="Hello")] - - -@pytest.mark.asyncio -class TestLLMApplicationService: - async def test_generate_coaching_response_success(self, service, mock_provider, messages): - # Arrange - mock_provider.validate_model.return_value = True - mock_provider.generate.return_value = LLMResponse( - content="Response", - model="model-v1", - usage={"total_tokens": 10}, - finish_reason="stop", - provider="mock_provider", - ) - - # Act - response = await service.generate_coaching_response( - conversation_history=messages, model="model-v1" - ) - - # Assert - assert response.content == "Response" - mock_provider.validate_model.assert_called_with("model-v1") - mock_provider.generate.assert_called_once() - - async def test_generate_coaching_response_default_model(self, service, mock_provider, messages): - # Arrange - mock_provider.validate_model.return_value = True - mock_provider.generate.return_value = LLMResponse( - content="Response", - model="model-v1", - usage={"total_tokens": 10}, - finish_reason="stop", - provider="mock_provider", - ) - - # Act - await service.generate_coaching_response(conversation_history=messages) - - # Assert - mock_provider.validate_model.assert_called_with("model-v1") - - async def test_generate_coaching_response_invalid_model(self, service, mock_provider, messages): - # Arrange - mock_provider.validate_model.return_value = False - - # Act & Assert - with pytest.raises(ValueError, match="Model invalid-model not supported"): - await service.generate_coaching_response( - conversation_history=messages, model="invalid-model" - ) - - async def test_generate_analysis_success(self, service, mock_provider): - # Arrange - mock_provider.generate.return_value = LLMResponse( - content="Analysis", - model="model-v1", - usage={"total_tokens": 20}, - finish_reason="stop", - provider="mock_provider", - ) - - # Act - response = await service.generate_analysis( - analysis_prompt="Analyze this", context={"key": "value"} - ) - - # Assert - assert response.content == "Analysis" - mock_provider.generate.assert_called_once() - call_args = mock_provider.generate.call_args - assert "Analysis Context:\nkey: value" in call_args.kwargs["system_prompt"] - - async def test_generate_streaming_response(self, service, mock_provider, messages): - # Arrange - async def stream_generator(*args, **kwargs): - yield "chunk1" - yield "chunk2" - - mock_provider.generate_stream.side_effect = stream_generator - - # Act - chunks = [] - async for chunk in service.generate_streaming_response(messages=messages): - chunks.append(chunk) - - # Assert - assert chunks == ["chunk1", "chunk2"] - mock_provider.generate_stream.assert_called_once() - - async def test_count_message_tokens(self, service, mock_provider, messages): - # Arrange - mock_provider.count_tokens.return_value = 5 - - # Act - count = await service.count_message_tokens(messages) - - # Assert - assert count == 5 - mock_provider.count_tokens.assert_called_with("Hello", "model-v1") - - async def test_validate_model_availability(self, service, mock_provider): - # Arrange - mock_provider.validate_model.return_value = True - - # Act - is_valid = await service.validate_model_availability("model-v1") - - # Assert - assert is_valid is True - mock_provider.validate_model.assert_called_with("model-v1") - - def test_get_supported_models(self, service, mock_provider): - # Act - models = service.get_supported_models() - - # Assert - assert models == ["model-v1", "model-v2"] - - def test_get_provider_name(self, service, mock_provider): - # Act - name = service.get_provider_name() - - # Assert - assert name == "mock_provider" - - def test_select_default_model_no_models(self, mock_provider): - # Arrange - mock_provider.supported_models = [] - service = LLMApplicationService(mock_provider) - - # Act & Assert - with pytest.raises(ValueError, match="No models available"): - service._select_default_model() +from unittest.mock import AsyncMock + +import pytest + +from coaching.src.application.llm.llm_service import LLMApplicationService +from coaching.src.domain.ports.llm_provider_port import LLMMessage, LLMProviderPort, LLMResponse + + +@pytest.fixture +def mock_provider(): + provider = AsyncMock(spec=LLMProviderPort) + provider.provider_name = "mock_provider" + provider.supported_models = ["model-v1", "model-v2"] + return provider + + +@pytest.fixture +def service(mock_provider): + return LLMApplicationService(mock_provider) + + +@pytest.fixture +def messages(): + return [LLMMessage(role="user", content="Hello")] + + +@pytest.mark.asyncio +class TestLLMApplicationService: + async def test_generate_coaching_response_success(self, service, mock_provider, messages): + # Arrange + mock_provider.validate_model.return_value = True + mock_provider.generate.return_value = LLMResponse( + content="Response", + model="model-v1", + usage={"total_tokens": 10}, + finish_reason="stop", + provider="mock_provider", + ) + + # Act + response = await service.generate_coaching_response( + conversation_history=messages, model="model-v1" + ) + + # Assert + assert response.content == "Response" + mock_provider.validate_model.assert_called_with("model-v1") + mock_provider.generate.assert_called_once() + + async def test_generate_coaching_response_default_model(self, service, mock_provider, messages): + # Arrange + mock_provider.validate_model.return_value = True + mock_provider.generate.return_value = LLMResponse( + content="Response", + model="model-v1", + usage={"total_tokens": 10}, + finish_reason="stop", + provider="mock_provider", + ) + + # Act + await service.generate_coaching_response(conversation_history=messages) + + # Assert + mock_provider.validate_model.assert_called_with("model-v1") + + async def test_generate_coaching_response_invalid_model(self, service, mock_provider, messages): + # Arrange + mock_provider.validate_model.return_value = False + + # Act & Assert + with pytest.raises(ValueError, match="Model invalid-model not supported"): + await service.generate_coaching_response( + conversation_history=messages, model="invalid-model" + ) + + async def test_generate_analysis_success(self, service, mock_provider): + # Arrange + mock_provider.generate.return_value = LLMResponse( + content="Analysis", + model="model-v1", + usage={"total_tokens": 20}, + finish_reason="stop", + provider="mock_provider", + ) + + # Act + response = await service.generate_analysis( + analysis_prompt="Analyze this", context={"key": "value"} + ) + + # Assert + assert response.content == "Analysis" + mock_provider.generate.assert_called_once() + call_args = mock_provider.generate.call_args + assert "Analysis Context:\nkey: value" in call_args.kwargs["system_prompt"] + + async def test_generate_streaming_response(self, service, mock_provider, messages): + # Arrange + async def stream_generator(*args, **kwargs): + yield "chunk1" + yield "chunk2" + + mock_provider.generate_stream.side_effect = stream_generator + + # Act + chunks = [] + async for chunk in service.generate_streaming_response(messages=messages): + chunks.append(chunk) + + # Assert + assert chunks == ["chunk1", "chunk2"] + mock_provider.generate_stream.assert_called_once() + + async def test_count_message_tokens(self, service, mock_provider, messages): + # Arrange + mock_provider.count_tokens.return_value = 5 + + # Act + count = await service.count_message_tokens(messages) + + # Assert + assert count == 5 + mock_provider.count_tokens.assert_called_with("Hello", "model-v1") + + async def test_validate_model_availability(self, service, mock_provider): + # Arrange + mock_provider.validate_model.return_value = True + + # Act + is_valid = await service.validate_model_availability("model-v1") + + # Assert + assert is_valid is True + mock_provider.validate_model.assert_called_with("model-v1") + + def test_get_supported_models(self, service, mock_provider): + # Act + models = service.get_supported_models() + + # Assert + assert models == ["model-v1", "model-v2"] + + def test_get_provider_name(self, service, mock_provider): + # Act + name = service.get_provider_name() + + # Assert + assert name == "mock_provider" + + def test_select_default_model_no_models(self, mock_provider): + # Arrange + mock_provider.supported_models = [] + service = LLMApplicationService(mock_provider) + + # Act & Assert + with pytest.raises(ValueError, match="No models available"): + service._select_default_model() diff --git a/coaching/tests/unit/application/llm_usage/test_billing_periods.py b/coaching/tests/unit/application/llm_usage/test_billing_periods.py index 8c04bf73..0f442faa 100644 --- a/coaching/tests/unit/application/llm_usage/test_billing_periods.py +++ b/coaching/tests/unit/application/llm_usage/test_billing_periods.py @@ -3,6 +3,7 @@ from datetime import UTC, datetime import pytest + from coaching.src.application.llm_usage.billing_periods import months_in_range pytestmark = pytest.mark.unit diff --git a/coaching/tests/unit/application/prompt/test_prompt_application_service.py b/coaching/tests/unit/application/prompt/test_prompt_application_service.py index ead21dee..a977e8bb 100644 --- a/coaching/tests/unit/application/prompt/test_prompt_application_service.py +++ b/coaching/tests/unit/application/prompt/test_prompt_application_service.py @@ -1,176 +1,177 @@ -from unittest.mock import AsyncMock, Mock - -import pytest -from coaching.src.application.prompt.prompt_service import PromptApplicationService -from coaching.src.core.constants import CoachingTopic -from coaching.src.domain.entities.prompt_template import PromptTemplate -from coaching.src.domain.ports.prompt_repository_port import PromptRepositoryPort - - -class TestPromptApplicationService: - @pytest.fixture - def mock_repository(self): - return Mock(spec=PromptRepositoryPort) - - @pytest.fixture - def service(self, mock_repository): - return PromptApplicationService(prompt_repository=mock_repository) - - @pytest.fixture - def mock_template(self): - template = Mock(spec=PromptTemplate) - template.template_id = "template-123" - template.topic = CoachingTopic.GOALS - return template - - @pytest.mark.asyncio - async def test_get_template_for_topic_found(self, service, mock_repository, mock_template): - """Test retrieving a template for a topic when it exists.""" - mock_repository.get_by_topic = AsyncMock(return_value=mock_template) - - result = await service.get_template_for_topic(CoachingTopic.GOALS, "v1.0") - - assert result == mock_template - mock_repository.get_by_topic.assert_called_once_with(CoachingTopic.GOALS, "v1.0") - - @pytest.mark.asyncio - async def test_get_template_for_topic_not_found(self, service, mock_repository): - """Test retrieving a template for a topic when it does not exist.""" - mock_repository.get_by_topic = AsyncMock(return_value=None) - - result = await service.get_template_for_topic(CoachingTopic.GOALS, "v1.0") - - assert result is None - mock_repository.get_by_topic.assert_called_once_with(CoachingTopic.GOALS, "v1.0") - - @pytest.mark.asyncio - async def test_get_template_by_id_found(self, service, mock_repository, mock_template): - """Test retrieving a template by ID when it exists.""" - mock_repository.get_by_id = AsyncMock(return_value=mock_template) - - result = await service.get_template_by_id("template-123") - - assert result == mock_template - mock_repository.get_by_id.assert_called_once_with("template-123") - - @pytest.mark.asyncio - async def test_get_template_by_id_not_found(self, service, mock_repository): - """Test retrieving a template by ID when it does not exist.""" - mock_repository.get_by_id = AsyncMock(return_value=None) - - result = await service.get_template_by_id("template-123") - - assert result is None - mock_repository.get_by_id.assert_called_once_with("template-123") - - @pytest.mark.asyncio - async def test_list_template_versions(self, service, mock_repository): - """Test listing template versions.""" - versions = ["v2.0", "v1.0"] - mock_repository.list_versions = AsyncMock(return_value=versions) - - result = await service.list_template_versions(CoachingTopic.GOALS) - - assert result == versions - mock_repository.list_versions.assert_called_once_with(CoachingTopic.GOALS) - - @pytest.mark.asyncio - async def test_template_exists(self, service, mock_repository): - """Test checking if a template exists.""" - mock_repository.exists = AsyncMock(return_value=True) - - result = await service.template_exists(CoachingTopic.GOALS, "v1.0") - - assert result is True - mock_repository.exists.assert_called_once_with(CoachingTopic.GOALS, "v1.0") - - @pytest.mark.asyncio - async def test_create_template_success(self, service, mock_repository, mock_template): - """Test creating a new template version successfully.""" - mock_repository.save = AsyncMock() - - await service.create_template(mock_template, "v1.0") - - mock_repository.save.assert_called_once_with(mock_template, "v1.0") - - @pytest.mark.asyncio - async def test_create_template_failure(self, service, mock_repository, mock_template): - """Test creating a new template version failure.""" - mock_repository.save = AsyncMock(side_effect=ValueError("Version exists")) - - with pytest.raises(ValueError, match="Version exists"): - await service.create_template(mock_template, "v1.0") - - @pytest.mark.asyncio - async def test_update_template(self, service, mock_repository, mock_template): - """Test updating a template (creating new version).""" - mock_repository.save = AsyncMock() - - await service.update_template(mock_template, "v2.0") - - mock_repository.save.assert_called_once_with(mock_template, "v2.0") - - @pytest.mark.asyncio - async def test_delete_template_version_success(self, service, mock_repository): - """Test deleting a template version successfully.""" - mock_repository.delete = AsyncMock(return_value=True) - - result = await service.delete_template_version(CoachingTopic.GOALS, "v1.0") - - assert result is True - mock_repository.delete.assert_called_once_with(CoachingTopic.GOALS, "v1.0") - - @pytest.mark.asyncio - async def test_delete_template_version_not_found(self, service, mock_repository): - """Test deleting a non-existent template version.""" - mock_repository.delete = AsyncMock(return_value=False) - - result = await service.delete_template_version(CoachingTopic.GOALS, "v1.0") - - assert result is False - mock_repository.delete.assert_called_once_with(CoachingTopic.GOALS, "v1.0") - - @pytest.mark.asyncio - async def test_delete_template_version_failure(self, service, mock_repository): - """Test deleting a template version failure (e.g. latest).""" - mock_repository.delete = AsyncMock(side_effect=ValueError("Cannot delete latest")) - - with pytest.raises(ValueError, match="Cannot delete latest"): - await service.delete_template_version(CoachingTopic.GOALS, "latest") - - @pytest.mark.asyncio - async def test_set_latest_version_success(self, service, mock_repository): - """Test setting the latest version successfully.""" - mock_repository.set_latest = AsyncMock() - - await service.set_latest_version(CoachingTopic.GOALS, "v2.0") - - mock_repository.set_latest.assert_called_once_with(CoachingTopic.GOALS, "v2.0") - - @pytest.mark.asyncio - async def test_set_latest_version_failure(self, service, mock_repository): - """Test setting the latest version failure.""" - mock_repository.set_latest = AsyncMock(side_effect=ValueError("Version not found")) - - with pytest.raises(ValueError, match="Version not found"): - await service.set_latest_version(CoachingTopic.GOALS, "v2.0") - - @pytest.mark.asyncio - async def test_create_draft_from_version_success(self, service, mock_repository, mock_template): - """Test creating a draft from an existing version successfully.""" - mock_repository.create_new_version = AsyncMock(return_value=mock_template) - - result = await service.create_draft_from_version(CoachingTopic.GOALS, "v1.0", "v1.1-draft") - - assert result == mock_template - mock_repository.create_new_version.assert_called_once_with( - CoachingTopic.GOALS, "v1.0", "v1.1-draft" - ) - - @pytest.mark.asyncio - async def test_create_draft_from_version_failure(self, service, mock_repository): - """Test creating a draft failure.""" - mock_repository.create_new_version = AsyncMock(side_effect=ValueError("Source not found")) - - with pytest.raises(ValueError, match="Source not found"): - await service.create_draft_from_version(CoachingTopic.GOALS, "v1.0", "v1.1-draft") +from unittest.mock import AsyncMock, Mock + +import pytest + +from coaching.src.application.prompt.prompt_service import PromptApplicationService +from coaching.src.core.constants import CoachingTopic +from coaching.src.domain.entities.prompt_template import PromptTemplate +from coaching.src.domain.ports.prompt_repository_port import PromptRepositoryPort + + +class TestPromptApplicationService: + @pytest.fixture + def mock_repository(self): + return Mock(spec=PromptRepositoryPort) + + @pytest.fixture + def service(self, mock_repository): + return PromptApplicationService(prompt_repository=mock_repository) + + @pytest.fixture + def mock_template(self): + template = Mock(spec=PromptTemplate) + template.template_id = "template-123" + template.topic = CoachingTopic.GOALS + return template + + @pytest.mark.asyncio + async def test_get_template_for_topic_found(self, service, mock_repository, mock_template): + """Test retrieving a template for a topic when it exists.""" + mock_repository.get_by_topic = AsyncMock(return_value=mock_template) + + result = await service.get_template_for_topic(CoachingTopic.GOALS, "v1.0") + + assert result == mock_template + mock_repository.get_by_topic.assert_called_once_with(CoachingTopic.GOALS, "v1.0") + + @pytest.mark.asyncio + async def test_get_template_for_topic_not_found(self, service, mock_repository): + """Test retrieving a template for a topic when it does not exist.""" + mock_repository.get_by_topic = AsyncMock(return_value=None) + + result = await service.get_template_for_topic(CoachingTopic.GOALS, "v1.0") + + assert result is None + mock_repository.get_by_topic.assert_called_once_with(CoachingTopic.GOALS, "v1.0") + + @pytest.mark.asyncio + async def test_get_template_by_id_found(self, service, mock_repository, mock_template): + """Test retrieving a template by ID when it exists.""" + mock_repository.get_by_id = AsyncMock(return_value=mock_template) + + result = await service.get_template_by_id("template-123") + + assert result == mock_template + mock_repository.get_by_id.assert_called_once_with("template-123") + + @pytest.mark.asyncio + async def test_get_template_by_id_not_found(self, service, mock_repository): + """Test retrieving a template by ID when it does not exist.""" + mock_repository.get_by_id = AsyncMock(return_value=None) + + result = await service.get_template_by_id("template-123") + + assert result is None + mock_repository.get_by_id.assert_called_once_with("template-123") + + @pytest.mark.asyncio + async def test_list_template_versions(self, service, mock_repository): + """Test listing template versions.""" + versions = ["v2.0", "v1.0"] + mock_repository.list_versions = AsyncMock(return_value=versions) + + result = await service.list_template_versions(CoachingTopic.GOALS) + + assert result == versions + mock_repository.list_versions.assert_called_once_with(CoachingTopic.GOALS) + + @pytest.mark.asyncio + async def test_template_exists(self, service, mock_repository): + """Test checking if a template exists.""" + mock_repository.exists = AsyncMock(return_value=True) + + result = await service.template_exists(CoachingTopic.GOALS, "v1.0") + + assert result is True + mock_repository.exists.assert_called_once_with(CoachingTopic.GOALS, "v1.0") + + @pytest.mark.asyncio + async def test_create_template_success(self, service, mock_repository, mock_template): + """Test creating a new template version successfully.""" + mock_repository.save = AsyncMock() + + await service.create_template(mock_template, "v1.0") + + mock_repository.save.assert_called_once_with(mock_template, "v1.0") + + @pytest.mark.asyncio + async def test_create_template_failure(self, service, mock_repository, mock_template): + """Test creating a new template version failure.""" + mock_repository.save = AsyncMock(side_effect=ValueError("Version exists")) + + with pytest.raises(ValueError, match="Version exists"): + await service.create_template(mock_template, "v1.0") + + @pytest.mark.asyncio + async def test_update_template(self, service, mock_repository, mock_template): + """Test updating a template (creating new version).""" + mock_repository.save = AsyncMock() + + await service.update_template(mock_template, "v2.0") + + mock_repository.save.assert_called_once_with(mock_template, "v2.0") + + @pytest.mark.asyncio + async def test_delete_template_version_success(self, service, mock_repository): + """Test deleting a template version successfully.""" + mock_repository.delete = AsyncMock(return_value=True) + + result = await service.delete_template_version(CoachingTopic.GOALS, "v1.0") + + assert result is True + mock_repository.delete.assert_called_once_with(CoachingTopic.GOALS, "v1.0") + + @pytest.mark.asyncio + async def test_delete_template_version_not_found(self, service, mock_repository): + """Test deleting a non-existent template version.""" + mock_repository.delete = AsyncMock(return_value=False) + + result = await service.delete_template_version(CoachingTopic.GOALS, "v1.0") + + assert result is False + mock_repository.delete.assert_called_once_with(CoachingTopic.GOALS, "v1.0") + + @pytest.mark.asyncio + async def test_delete_template_version_failure(self, service, mock_repository): + """Test deleting a template version failure (e.g. latest).""" + mock_repository.delete = AsyncMock(side_effect=ValueError("Cannot delete latest")) + + with pytest.raises(ValueError, match="Cannot delete latest"): + await service.delete_template_version(CoachingTopic.GOALS, "latest") + + @pytest.mark.asyncio + async def test_set_latest_version_success(self, service, mock_repository): + """Test setting the latest version successfully.""" + mock_repository.set_latest = AsyncMock() + + await service.set_latest_version(CoachingTopic.GOALS, "v2.0") + + mock_repository.set_latest.assert_called_once_with(CoachingTopic.GOALS, "v2.0") + + @pytest.mark.asyncio + async def test_set_latest_version_failure(self, service, mock_repository): + """Test setting the latest version failure.""" + mock_repository.set_latest = AsyncMock(side_effect=ValueError("Version not found")) + + with pytest.raises(ValueError, match="Version not found"): + await service.set_latest_version(CoachingTopic.GOALS, "v2.0") + + @pytest.mark.asyncio + async def test_create_draft_from_version_success(self, service, mock_repository, mock_template): + """Test creating a draft from an existing version successfully.""" + mock_repository.create_new_version = AsyncMock(return_value=mock_template) + + result = await service.create_draft_from_version(CoachingTopic.GOALS, "v1.0", "v1.1-draft") + + assert result == mock_template + mock_repository.create_new_version.assert_called_once_with( + CoachingTopic.GOALS, "v1.0", "v1.1-draft" + ) + + @pytest.mark.asyncio + async def test_create_draft_from_version_failure(self, service, mock_repository): + """Test creating a draft failure.""" + mock_repository.create_new_version = AsyncMock(side_effect=ValueError("Source not found")) + + with pytest.raises(ValueError, match="Source not found"): + await service.create_draft_from_version(CoachingTopic.GOALS, "v1.0", "v1.1-draft") diff --git a/coaching/tests/unit/core/test_deprecation.py b/coaching/tests/unit/core/test_deprecation.py index 0d4507cb..f9b7d39a 100644 --- a/coaching/tests/unit/core/test_deprecation.py +++ b/coaching/tests/unit/core/test_deprecation.py @@ -1,65 +1,66 @@ -import pytest -from coaching.src.core.deprecation import deprecated - -pytestmark = pytest.mark.unit - - -class TestDeprecation: - """Test suite for deprecation utilities.""" - - def test_deprecated_function_warning(self): - """Test that calling a deprecated function issues a warning.""" - - @deprecated("This function is deprecated", alternative="new_func", removal_version="2.0.0") - def old_func(): - return "result" - - with pytest.warns( - DeprecationWarning, match="DEPRECATED: old_func - This function is deprecated" - ): - result = old_func() - assert result == "result" - - def test_deprecated_class_warning(self): - """Test that instantiating a deprecated class issues a warning.""" - - @deprecated("This class is deprecated", alternative="NewClass", removal_version="2.0.0") - class OldClass: - def __init__(self): - self.value = "test" - - with pytest.warns( - DeprecationWarning, match="DEPRECATED: OldClass - This class is deprecated" - ): - obj = OldClass() - assert obj.value == "test" - - def test_deprecated_method_warning(self): - """Test that calling a deprecated method issues a warning.""" - - class MyClass: - @deprecated("This method is deprecated") - def old_method(self): - return "result" - - obj = MyClass() - with pytest.warns( - DeprecationWarning, match="DEPRECATED: old_method - This method is deprecated" - ): - result = obj.old_method() - assert result == "result" - - def test_deprecated_message_formatting(self): - """Test that the deprecation message is formatted correctly.""" - - @deprecated("Reason", alternative="Alternative", removal_version="2.0.0") - def func(): - pass - - with pytest.warns(DeprecationWarning) as record: - func() - - message = str(record[0].message) - assert "DEPRECATED: func - Reason" in message - assert "Use instead: Alternative" in message - assert "Will be removed in version 2.0.0" in message +import pytest + +from coaching.src.core.deprecation import deprecated + +pytestmark = pytest.mark.unit + + +class TestDeprecation: + """Test suite for deprecation utilities.""" + + def test_deprecated_function_warning(self): + """Test that calling a deprecated function issues a warning.""" + + @deprecated("This function is deprecated", alternative="new_func", removal_version="2.0.0") + def old_func(): + return "result" + + with pytest.warns( + DeprecationWarning, match="DEPRECATED: old_func - This function is deprecated" + ): + result = old_func() + assert result == "result" + + def test_deprecated_class_warning(self): + """Test that instantiating a deprecated class issues a warning.""" + + @deprecated("This class is deprecated", alternative="NewClass", removal_version="2.0.0") + class OldClass: + def __init__(self): + self.value = "test" + + with pytest.warns( + DeprecationWarning, match="DEPRECATED: OldClass - This class is deprecated" + ): + obj = OldClass() + assert obj.value == "test" + + def test_deprecated_method_warning(self): + """Test that calling a deprecated method issues a warning.""" + + class MyClass: + @deprecated("This method is deprecated") + def old_method(self): + return "result" + + obj = MyClass() + with pytest.warns( + DeprecationWarning, match="DEPRECATED: old_method - This method is deprecated" + ): + result = obj.old_method() + assert result == "result" + + def test_deprecated_message_formatting(self): + """Test that the deprecation message is formatted correctly.""" + + @deprecated("Reason", alternative="Alternative", removal_version="2.0.0") + def func(): + pass + + with pytest.warns(DeprecationWarning) as record: + func() + + message = str(record[0].message) + assert "DEPRECATED: func - Reason" in message + assert "Use instead: Alternative" in message + assert "Will be removed in version 2.0.0" in message diff --git a/coaching/tests/unit/core/test_interaction_codes.py b/coaching/tests/unit/core/test_interaction_codes.py index 5f87f809..0227c0fd 100644 --- a/coaching/tests/unit/core/test_interaction_codes.py +++ b/coaching/tests/unit/core/test_interaction_codes.py @@ -1,27 +1,28 @@ -import pytest -from coaching.src.core import interaction_codes - -pytestmark = pytest.mark.unit - - -class TestInteractionCodes: - """Test suite for interaction codes.""" - - def test_constants_are_strings(self) -> None: - """Test that all constants are strings.""" - # Get all public attributes that are uppercase (constants) - constants = [ - getattr(interaction_codes, name) - for name in dir(interaction_codes) - if name.isupper() and not name.startswith("_") - ] - - for constant in constants: - assert isinstance(constant, str) - assert len(constant) > 0 - - def test_specific_codes_exist(self) -> None: - """Test that key interaction codes exist.""" - assert interaction_codes.ALIGNMENT_ANALYSIS == "ALIGNMENT_ANALYSIS" - assert interaction_codes.COACHING_RESPONSE == "COACHING_RESPONSE" - assert interaction_codes.STRATEGIC_ALIGNMENT == "STRATEGIC_ALIGNMENT" +import pytest + +from coaching.src.core import interaction_codes + +pytestmark = pytest.mark.unit + + +class TestInteractionCodes: + """Test suite for interaction codes.""" + + def test_constants_are_strings(self) -> None: + """Test that all constants are strings.""" + # Get all public attributes that are uppercase (constants) + constants = [ + getattr(interaction_codes, name) + for name in dir(interaction_codes) + if name.isupper() and not name.startswith("_") + ] + + for constant in constants: + assert isinstance(constant, str) + assert len(constant) > 0 + + def test_specific_codes_exist(self) -> None: + """Test that key interaction codes exist.""" + assert interaction_codes.ALIGNMENT_ANALYSIS == "ALIGNMENT_ANALYSIS" + assert interaction_codes.COACHING_RESPONSE == "COACHING_RESPONSE" + assert interaction_codes.STRATEGIC_ALIGNMENT == "STRATEGIC_ALIGNMENT" diff --git a/coaching/tests/unit/core/test_llm_interactions.py b/coaching/tests/unit/core/test_llm_interactions.py index 50347851..88966ca3 100644 --- a/coaching/tests/unit/core/test_llm_interactions.py +++ b/coaching/tests/unit/core/test_llm_interactions.py @@ -1,176 +1,177 @@ -"""Unit tests for LLM Interactions Registry.""" - -import pytest -from coaching.src.core.llm_interactions import ( - INTERACTION_REGISTRY, - InteractionCategory, - LLMInteraction, - ParameterValidationError, - get_interaction, - list_interactions, - validate_parameters, -) - - -class TestLLMInteraction: - """Tests for LLMInteraction dataclass.""" - - def test_validate_template_parameters_all_required_present(self) -> None: - """Test validation passes when all required parameters present.""" - interaction = LLMInteraction( - code="TEST_INTERACTION", - description="Test interaction", - category=InteractionCategory.ANALYSIS, - required_parameters=["goal_text", "purpose"], - optional_parameters=["context"], - handler_class="TestService", - ) - - # Should not raise - interaction.validate_template_parameters(["goal_text", "purpose"]) - - def test_validate_template_parameters_missing_required(self) -> None: - """Test validation fails when required parameter missing.""" - interaction = LLMInteraction( - code="TEST_INTERACTION", - description="Test interaction", - category=InteractionCategory.ANALYSIS, - required_parameters=["goal_text", "purpose"], - optional_parameters=[], - handler_class="TestService", - ) - - with pytest.raises(ParameterValidationError) as exc_info: - interaction.validate_template_parameters(["goal_text"]) - - assert "missing required parameters" in str(exc_info.value).lower() - assert "purpose" in str(exc_info.value) - - def test_validate_template_parameters_unsupported_used(self) -> None: - """Test validation fails when unsupported parameter used.""" - interaction = LLMInteraction( - code="TEST_INTERACTION", - description="Test interaction", - category=InteractionCategory.ANALYSIS, - required_parameters=["goal_text"], - optional_parameters=[], - handler_class="TestService", - ) - - with pytest.raises(ParameterValidationError) as exc_info: - interaction.validate_template_parameters(["goal_text", "invalid_param"]) - - assert "unsupported parameters" in str(exc_info.value).lower() - assert "invalid_param" in str(exc_info.value) - - def test_validate_template_parameters_optional_allowed(self) -> None: - """Test validation passes with optional parameters.""" - interaction = LLMInteraction( - code="TEST_INTERACTION", - description="Test interaction", - category=InteractionCategory.ANALYSIS, - required_parameters=["goal_text"], - optional_parameters=["context", "constraints"], - handler_class="TestService", - ) - - # Should not raise - optional parameters are allowed - interaction.validate_template_parameters(["goal_text", "context"]) - - def test_get_parameter_schema(self) -> None: - """Test parameter schema generation.""" - interaction = LLMInteraction( - code="TEST_INTERACTION", - description="Test interaction", - category=InteractionCategory.ANALYSIS, - required_parameters=["goal_text", "purpose"], - optional_parameters=["context"], - handler_class="TestService", - ) - - schema = interaction.get_parameter_schema() - - assert schema["required"] == ["goal_text", "purpose"] - assert schema["optional"] == ["context"] - assert schema["all_parameters"] == ["goal_text", "purpose", "context"] - - -class TestInteractionRegistry: - """Tests for interaction registry functions.""" - - def test_get_interaction_exists(self) -> None: - """Test retrieving existing interaction from registry.""" - interaction = get_interaction("ALIGNMENT_ANALYSIS") - - assert interaction.code == "ALIGNMENT_ANALYSIS" - assert interaction.category == InteractionCategory.ANALYSIS - assert len(interaction.required_parameters) > 0 - assert interaction.handler_class == "AlignmentAnalysisService" - - def test_get_interaction_not_found(self) -> None: - """Test helpful error when interaction not in registry.""" - with pytest.raises(ValueError) as exc_info: - get_interaction("INVALID_CODE") - - error_msg = str(exc_info.value) - assert "Unknown interaction code" in error_msg - assert "INVALID_CODE" in error_msg - assert "Available interactions" in error_msg - - def test_list_interactions_all(self) -> None: - """Test listing all interactions.""" - interactions = list_interactions() - - assert len(interactions) > 0 - assert all(isinstance(i, LLMInteraction) for i in interactions) - # Verify some expected interactions exist - codes = [i.code for i in interactions] - assert "ALIGNMENT_ANALYSIS" in codes - assert "COACHING_RESPONSE" in codes - - def test_list_interactions_by_category(self) -> None: - """Test filtering interactions by category.""" - analysis = list_interactions(category=InteractionCategory.ANALYSIS) - - assert len(analysis) > 0 - assert all(i.category == InteractionCategory.ANALYSIS for i in analysis) - - coaching = list_interactions(category=InteractionCategory.COACHING) - assert len(coaching) > 0 - assert all(i.category == InteractionCategory.COACHING for i in coaching) - - def test_validate_parameters_all_required_provided(self) -> None: - """Test parameter validation with all required present.""" - # Should not raise - validate_parameters( - "ALIGNMENT_ANALYSIS", - {"goal_text": "test", "purpose": "test", "values": "test"}, - ) - - def test_validate_parameters_missing_required(self) -> None: - """Test parameter validation with missing required.""" - with pytest.raises(ValueError) as exc_info: - validate_parameters("ALIGNMENT_ANALYSIS", {"goal_text": "test"}) - - error_msg = str(exc_info.value) - assert "Missing required parameters" in error_msg - assert "purpose" in error_msg or "values" in error_msg - - def test_interaction_registry_completeness(self) -> None: - """Test that all interactions in registry have required fields.""" - for code, interaction in INTERACTION_REGISTRY.items(): - # Verify all required fields present - assert interaction.code == code - assert len(interaction.description) > 0 - assert isinstance(interaction.category, InteractionCategory) - assert isinstance(interaction.required_parameters, list) - assert isinstance(interaction.optional_parameters, list) - assert len(interaction.handler_class) > 0 - - def test_interaction_registry_no_duplicate_codes(self) -> None: - """Test that interaction codes are unique.""" - codes = [i.code for i in INTERACTION_REGISTRY.values()] - assert len(codes) == len(set(codes)), "Duplicate interaction codes found" - - -__all__ = [] # Test module, no exports +"""Unit tests for LLM Interactions Registry.""" + +import pytest + +from coaching.src.core.llm_interactions import ( + INTERACTION_REGISTRY, + InteractionCategory, + LLMInteraction, + ParameterValidationError, + get_interaction, + list_interactions, + validate_parameters, +) + + +class TestLLMInteraction: + """Tests for LLMInteraction dataclass.""" + + def test_validate_template_parameters_all_required_present(self) -> None: + """Test validation passes when all required parameters present.""" + interaction = LLMInteraction( + code="TEST_INTERACTION", + description="Test interaction", + category=InteractionCategory.ANALYSIS, + required_parameters=["goal_text", "purpose"], + optional_parameters=["context"], + handler_class="TestService", + ) + + # Should not raise + interaction.validate_template_parameters(["goal_text", "purpose"]) + + def test_validate_template_parameters_missing_required(self) -> None: + """Test validation fails when required parameter missing.""" + interaction = LLMInteraction( + code="TEST_INTERACTION", + description="Test interaction", + category=InteractionCategory.ANALYSIS, + required_parameters=["goal_text", "purpose"], + optional_parameters=[], + handler_class="TestService", + ) + + with pytest.raises(ParameterValidationError) as exc_info: + interaction.validate_template_parameters(["goal_text"]) + + assert "missing required parameters" in str(exc_info.value).lower() + assert "purpose" in str(exc_info.value) + + def test_validate_template_parameters_unsupported_used(self) -> None: + """Test validation fails when unsupported parameter used.""" + interaction = LLMInteraction( + code="TEST_INTERACTION", + description="Test interaction", + category=InteractionCategory.ANALYSIS, + required_parameters=["goal_text"], + optional_parameters=[], + handler_class="TestService", + ) + + with pytest.raises(ParameterValidationError) as exc_info: + interaction.validate_template_parameters(["goal_text", "invalid_param"]) + + assert "unsupported parameters" in str(exc_info.value).lower() + assert "invalid_param" in str(exc_info.value) + + def test_validate_template_parameters_optional_allowed(self) -> None: + """Test validation passes with optional parameters.""" + interaction = LLMInteraction( + code="TEST_INTERACTION", + description="Test interaction", + category=InteractionCategory.ANALYSIS, + required_parameters=["goal_text"], + optional_parameters=["context", "constraints"], + handler_class="TestService", + ) + + # Should not raise - optional parameters are allowed + interaction.validate_template_parameters(["goal_text", "context"]) + + def test_get_parameter_schema(self) -> None: + """Test parameter schema generation.""" + interaction = LLMInteraction( + code="TEST_INTERACTION", + description="Test interaction", + category=InteractionCategory.ANALYSIS, + required_parameters=["goal_text", "purpose"], + optional_parameters=["context"], + handler_class="TestService", + ) + + schema = interaction.get_parameter_schema() + + assert schema["required"] == ["goal_text", "purpose"] + assert schema["optional"] == ["context"] + assert schema["all_parameters"] == ["goal_text", "purpose", "context"] + + +class TestInteractionRegistry: + """Tests for interaction registry functions.""" + + def test_get_interaction_exists(self) -> None: + """Test retrieving existing interaction from registry.""" + interaction = get_interaction("ALIGNMENT_ANALYSIS") + + assert interaction.code == "ALIGNMENT_ANALYSIS" + assert interaction.category == InteractionCategory.ANALYSIS + assert len(interaction.required_parameters) > 0 + assert interaction.handler_class == "AlignmentAnalysisService" + + def test_get_interaction_not_found(self) -> None: + """Test helpful error when interaction not in registry.""" + with pytest.raises(ValueError) as exc_info: + get_interaction("INVALID_CODE") + + error_msg = str(exc_info.value) + assert "Unknown interaction code" in error_msg + assert "INVALID_CODE" in error_msg + assert "Available interactions" in error_msg + + def test_list_interactions_all(self) -> None: + """Test listing all interactions.""" + interactions = list_interactions() + + assert len(interactions) > 0 + assert all(isinstance(i, LLMInteraction) for i in interactions) + # Verify some expected interactions exist + codes = [i.code for i in interactions] + assert "ALIGNMENT_ANALYSIS" in codes + assert "COACHING_RESPONSE" in codes + + def test_list_interactions_by_category(self) -> None: + """Test filtering interactions by category.""" + analysis = list_interactions(category=InteractionCategory.ANALYSIS) + + assert len(analysis) > 0 + assert all(i.category == InteractionCategory.ANALYSIS for i in analysis) + + coaching = list_interactions(category=InteractionCategory.COACHING) + assert len(coaching) > 0 + assert all(i.category == InteractionCategory.COACHING for i in coaching) + + def test_validate_parameters_all_required_provided(self) -> None: + """Test parameter validation with all required present.""" + # Should not raise + validate_parameters( + "ALIGNMENT_ANALYSIS", + {"goal_text": "test", "purpose": "test", "values": "test"}, + ) + + def test_validate_parameters_missing_required(self) -> None: + """Test parameter validation with missing required.""" + with pytest.raises(ValueError) as exc_info: + validate_parameters("ALIGNMENT_ANALYSIS", {"goal_text": "test"}) + + error_msg = str(exc_info.value) + assert "Missing required parameters" in error_msg + assert "purpose" in error_msg or "values" in error_msg + + def test_interaction_registry_completeness(self) -> None: + """Test that all interactions in registry have required fields.""" + for code, interaction in INTERACTION_REGISTRY.items(): + # Verify all required fields present + assert interaction.code == code + assert len(interaction.description) > 0 + assert isinstance(interaction.category, InteractionCategory) + assert isinstance(interaction.required_parameters, list) + assert isinstance(interaction.optional_parameters, list) + assert len(interaction.handler_class) > 0 + + def test_interaction_registry_no_duplicate_codes(self) -> None: + """Test that interaction codes are unique.""" + codes = [i.code for i in INTERACTION_REGISTRY.values()] + assert len(codes) == len(set(codes)), "Duplicate interaction codes found" + + +__all__ = [] # Test module, no exports diff --git a/coaching/tests/unit/core/test_llm_models.py b/coaching/tests/unit/core/test_llm_models.py index bf7de981..3bd86ea1 100644 --- a/coaching/tests/unit/core/test_llm_models.py +++ b/coaching/tests/unit/core/test_llm_models.py @@ -1,6 +1,7 @@ """Unit tests for LLM Models Registry.""" import pytest + from coaching.src.core.llm_models import ( MODEL_REGISTRY, LLMProvider, diff --git a/coaching/tests/unit/core/test_parameter_registry.py b/coaching/tests/unit/core/test_parameter_registry.py index 49503c41..8e787b23 100644 --- a/coaching/tests/unit/core/test_parameter_registry.py +++ b/coaching/tests/unit/core/test_parameter_registry.py @@ -10,6 +10,7 @@ """ import pytest + from coaching.src.core.parameter_registry import ( PARAMETER_REGISTRY, ParameterDefinition, diff --git a/coaching/tests/unit/core/test_retrieval_goal_scoped_enrichment.py b/coaching/tests/unit/core/test_retrieval_goal_scoped_enrichment.py index 3e09ff52..4a137b9f 100644 --- a/coaching/tests/unit/core/test_retrieval_goal_scoped_enrichment.py +++ b/coaching/tests/unit/core/test_retrieval_goal_scoped_enrichment.py @@ -3,6 +3,7 @@ from unittest.mock import AsyncMock import pytest + from coaching.src.core.retrieval_method_registry import ( RetrievalContext, get_all_strategies, diff --git a/coaching/tests/unit/core/test_topic_seed_data.py b/coaching/tests/unit/core/test_topic_seed_data.py index 3271c5b2..9b2242df 100644 --- a/coaching/tests/unit/core/test_topic_seed_data.py +++ b/coaching/tests/unit/core/test_topic_seed_data.py @@ -1,4 +1,5 @@ import pytest + from coaching.src.core.constants import TopicCategory, TopicType from coaching.src.core.topic_seed_data import TOPIC_SEED_DATA, TopicSeedData diff --git a/coaching/tests/unit/core/test_types.py b/coaching/tests/unit/core/test_types.py index 4804c0d5..253f85a7 100644 --- a/coaching/tests/unit/core/test_types.py +++ b/coaching/tests/unit/core/test_types.py @@ -1,338 +1,339 @@ -"""Unit tests for core type system. - -Tests domain ID types and factory functions to ensure type safety -and proper ID generation. -""" - -from uuid import UUID - -import pytest -from coaching.src.core.types import ( - create_analysis_request_id, - create_conversation_id, - create_message_id, - create_session_id, - create_template_id, - create_tenant_id, - create_user_id, -) - - -class TestConversationId: - """Test suite for ConversationId type.""" - - def test_create_conversation_id_generates_unique_ids(self) -> None: - """Test that create_conversation_id generates unique IDs.""" - # Arrange & Act - id1 = create_conversation_id() - id2 = create_conversation_id() - - # Assert - assert id1 != id2 - assert isinstance(id1, str) - assert isinstance(id2, str) - - def test_create_conversation_id_has_correct_prefix(self) -> None: - """Test that conversation IDs have the 'conv_' prefix.""" - # Arrange & Act - conv_id = create_conversation_id() - - # Assert - assert conv_id.startswith("conv_") - - def test_create_conversation_id_contains_valid_uuid(self) -> None: - """Test that conversation ID contains a valid UUID.""" - # Arrange & Act - conv_id = create_conversation_id() - uuid_part = conv_id.split("conv_")[1] - - # Assert - try: - UUID(uuid_part) - assert True - except ValueError: - pytest.fail("Conversation ID does not contain a valid UUID") - - def test_conversation_id_is_string_at_runtime(self) -> None: - """Test that ConversationId behaves as string at runtime.""" - # Arrange & Act - conv_id = create_conversation_id() - - # Assert - assert isinstance(conv_id, str) - assert len(conv_id) > 5 # At least "conv_" + some UUID - - -class TestTemplateId: - """Test suite for TemplateId type.""" - - def test_create_template_id_generates_unique_ids(self) -> None: - """Test that create_template_id generates unique IDs.""" - # Arrange & Act - id1 = create_template_id() - id2 = create_template_id() - - # Assert - assert id1 != id2 - - def test_create_template_id_has_correct_prefix(self) -> None: - """Test that template IDs have the 'tmpl_' prefix.""" - # Arrange & Act - template_id = create_template_id() - - # Assert - assert template_id.startswith("tmpl_") - - def test_create_template_id_contains_valid_uuid(self) -> None: - """Test that template ID contains a valid UUID.""" - # Arrange & Act - template_id = create_template_id() - uuid_part = template_id.split("tmpl_")[1] - - # Assert - UUID(uuid_part) # Will raise ValueError if invalid - - -class TestAnalysisRequestId: - """Test suite for AnalysisRequestId type.""" - - def test_create_analysis_request_id_generates_unique_ids(self) -> None: - """Test that create_analysis_request_id generates unique IDs.""" - # Arrange & Act - id1 = create_analysis_request_id() - id2 = create_analysis_request_id() - - # Assert - assert id1 != id2 - - def test_create_analysis_request_id_has_correct_prefix(self) -> None: - """Test that analysis request IDs have the 'anls_' prefix.""" - # Arrange & Act - analysis_id = create_analysis_request_id() - - # Assert - assert analysis_id.startswith("anls_") - - def test_create_analysis_request_id_contains_valid_uuid(self) -> None: - """Test that analysis request ID contains a valid UUID.""" - # Arrange & Act - analysis_id = create_analysis_request_id() - uuid_part = analysis_id.split("anls_")[1] - - # Assert - UUID(uuid_part) # Will raise ValueError if invalid - - -class TestUserId: - """Test suite for UserId type.""" - - def test_create_user_id_with_valid_string(self) -> None: - """Test creating a user ID from a valid string.""" - # Arrange - raw_id = "user_12345" - - # Act - user_id = create_user_id(raw_id) - - # Assert - assert user_id == raw_id - assert isinstance(user_id, str) - - def test_create_user_id_preserves_input(self) -> None: - """Test that create_user_id preserves the input string.""" - # Arrange - raw_id = "custom_user_identifier_456" - - # Act - user_id = create_user_id(raw_id) - - # Assert - assert user_id == raw_id - - def test_create_user_id_with_empty_string(self) -> None: - """Test creating a user ID with an empty string.""" - # Arrange - raw_id = "" - - # Act - user_id = create_user_id(raw_id) - - # Assert - assert user_id == "" - - -class TestTenantId: - """Test suite for TenantId type.""" - - def test_create_tenant_id_with_valid_string(self) -> None: - """Test creating a tenant ID from a valid string.""" - # Arrange - raw_id = "tenant_789" - - # Act - tenant_id = create_tenant_id(raw_id) - - # Assert - assert tenant_id == raw_id - assert isinstance(tenant_id, str) - - def test_create_tenant_id_preserves_input(self) -> None: - """Test that create_tenant_id preserves the input string.""" - # Arrange - raw_id = "org_xyz_123" - - # Act - tenant_id = create_tenant_id(raw_id) - - # Assert - assert tenant_id == raw_id - - -class TestMessageId: - """Test suite for MessageId type.""" - - def test_create_message_id_generates_unique_ids(self) -> None: - """Test that create_message_id generates unique IDs.""" - # Arrange & Act - id1 = create_message_id() - id2 = create_message_id() - - # Assert - assert id1 != id2 - - def test_create_message_id_has_correct_prefix(self) -> None: - """Test that message IDs have the 'msg_' prefix.""" - # Arrange & Act - message_id = create_message_id() - - # Assert - assert message_id.startswith("msg_") - - def test_create_message_id_contains_valid_uuid(self) -> None: - """Test that message ID contains a valid UUID.""" - # Arrange & Act - message_id = create_message_id() - uuid_part = message_id.split("msg_")[1] - - # Assert - UUID(uuid_part) # Will raise ValueError if invalid - - -class TestSessionId: - """Test suite for SessionId type.""" - - def test_create_session_id_generates_unique_ids(self) -> None: - """Test that create_session_id generates unique IDs.""" - # Arrange & Act - id1 = create_session_id() - id2 = create_session_id() - - # Assert - assert id1 != id2 - - def test_create_session_id_has_correct_prefix(self) -> None: - """Test that session IDs have the 'sess_' prefix.""" - # Arrange & Act - session_id = create_session_id() - - # Assert - assert session_id.startswith("sess_") - - def test_create_session_id_contains_valid_uuid(self) -> None: - """Test that session ID contains a valid UUID.""" - # Arrange & Act - session_id = create_session_id() - uuid_part = session_id.split("sess_")[1] - - # Assert - UUID(uuid_part) # Will raise ValueError if invalid - - -class TestTypeSafety: - """Test suite for type safety across all ID types.""" - - def test_all_id_types_are_strings_at_runtime(self) -> None: - """Test that all ID types behave as strings at runtime.""" - # Arrange & Act - conv_id = create_conversation_id() - template_id = create_template_id() - analysis_id = create_analysis_request_id() - user_id = create_user_id("user_1") - tenant_id = create_tenant_id("tenant_1") - message_id = create_message_id() - session_id = create_session_id() - - # Assert - assert isinstance(conv_id, str) - assert isinstance(template_id, str) - assert isinstance(analysis_id, str) - assert isinstance(user_id, str) - assert isinstance(tenant_id, str) - assert isinstance(message_id, str) - assert isinstance(session_id, str) - - def test_id_types_can_be_used_in_string_operations(self) -> None: - """Test that ID types support string operations.""" - # Arrange - conv_id = create_conversation_id() - - # Act & Assert - assert len(conv_id) > 0 - assert conv_id.upper() == conv_id.upper() - assert conv_id in [conv_id] # Can be used in collections - assert conv_id.startswith("conv_") - assert "_" in conv_id - - def test_id_uniqueness_across_multiple_calls(self) -> None: - """Test that generating many IDs produces unique values.""" - # Arrange & Act - conv_ids = {create_conversation_id() for _ in range(100)} - template_ids = {create_template_id() for _ in range(100)} - - # Assert - assert len(conv_ids) == 100 # All unique - assert len(template_ids) == 100 # All unique - - def test_id_types_can_be_compared(self) -> None: - """Test that ID types support comparison operations.""" - # Arrange - id1 = create_conversation_id() - id2 = create_conversation_id() - id3 = id1 - - # Act & Assert - assert id1 == id3 - assert id1 != id2 - assert (id1 < id2) or (id1 > id2) # One must be true - - def test_id_types_can_be_hashed(self) -> None: - """Test that ID types can be used as dictionary keys.""" - # Arrange - conv_id = create_conversation_id() - test_dict = {conv_id: "test_value"} - - # Act & Assert - assert test_dict[conv_id] == "test_value" - assert conv_id in test_dict - - def test_user_id_with_special_characters(self) -> None: - """Test user ID creation with various formats.""" - # Arrange & Act - id_with_dash = create_user_id("user-123-abc") - id_with_underscore = create_user_id("user_123_abc") - id_with_mixed = create_user_id("org#user@123") - - # Assert - assert id_with_dash == "user-123-abc" - assert id_with_underscore == "user_123_abc" - assert id_with_mixed == "org#user@123" - - def test_tenant_id_with_special_characters(self) -> None: - """Test tenant ID creation with various formats.""" - # Arrange & Act - id_with_dash = create_tenant_id("tenant-xyz-789") - id_with_underscore = create_tenant_id("org_name_123") - - # Assert - assert id_with_dash == "tenant-xyz-789" - assert id_with_underscore == "org_name_123" +"""Unit tests for core type system. + +Tests domain ID types and factory functions to ensure type safety +and proper ID generation. +""" + +from uuid import UUID + +import pytest + +from coaching.src.core.types import ( + create_analysis_request_id, + create_conversation_id, + create_message_id, + create_session_id, + create_template_id, + create_tenant_id, + create_user_id, +) + + +class TestConversationId: + """Test suite for ConversationId type.""" + + def test_create_conversation_id_generates_unique_ids(self) -> None: + """Test that create_conversation_id generates unique IDs.""" + # Arrange & Act + id1 = create_conversation_id() + id2 = create_conversation_id() + + # Assert + assert id1 != id2 + assert isinstance(id1, str) + assert isinstance(id2, str) + + def test_create_conversation_id_has_correct_prefix(self) -> None: + """Test that conversation IDs have the 'conv_' prefix.""" + # Arrange & Act + conv_id = create_conversation_id() + + # Assert + assert conv_id.startswith("conv_") + + def test_create_conversation_id_contains_valid_uuid(self) -> None: + """Test that conversation ID contains a valid UUID.""" + # Arrange & Act + conv_id = create_conversation_id() + uuid_part = conv_id.split("conv_")[1] + + # Assert + try: + UUID(uuid_part) + assert True + except ValueError: + pytest.fail("Conversation ID does not contain a valid UUID") + + def test_conversation_id_is_string_at_runtime(self) -> None: + """Test that ConversationId behaves as string at runtime.""" + # Arrange & Act + conv_id = create_conversation_id() + + # Assert + assert isinstance(conv_id, str) + assert len(conv_id) > 5 # At least "conv_" + some UUID + + +class TestTemplateId: + """Test suite for TemplateId type.""" + + def test_create_template_id_generates_unique_ids(self) -> None: + """Test that create_template_id generates unique IDs.""" + # Arrange & Act + id1 = create_template_id() + id2 = create_template_id() + + # Assert + assert id1 != id2 + + def test_create_template_id_has_correct_prefix(self) -> None: + """Test that template IDs have the 'tmpl_' prefix.""" + # Arrange & Act + template_id = create_template_id() + + # Assert + assert template_id.startswith("tmpl_") + + def test_create_template_id_contains_valid_uuid(self) -> None: + """Test that template ID contains a valid UUID.""" + # Arrange & Act + template_id = create_template_id() + uuid_part = template_id.split("tmpl_")[1] + + # Assert + UUID(uuid_part) # Will raise ValueError if invalid + + +class TestAnalysisRequestId: + """Test suite for AnalysisRequestId type.""" + + def test_create_analysis_request_id_generates_unique_ids(self) -> None: + """Test that create_analysis_request_id generates unique IDs.""" + # Arrange & Act + id1 = create_analysis_request_id() + id2 = create_analysis_request_id() + + # Assert + assert id1 != id2 + + def test_create_analysis_request_id_has_correct_prefix(self) -> None: + """Test that analysis request IDs have the 'anls_' prefix.""" + # Arrange & Act + analysis_id = create_analysis_request_id() + + # Assert + assert analysis_id.startswith("anls_") + + def test_create_analysis_request_id_contains_valid_uuid(self) -> None: + """Test that analysis request ID contains a valid UUID.""" + # Arrange & Act + analysis_id = create_analysis_request_id() + uuid_part = analysis_id.split("anls_")[1] + + # Assert + UUID(uuid_part) # Will raise ValueError if invalid + + +class TestUserId: + """Test suite for UserId type.""" + + def test_create_user_id_with_valid_string(self) -> None: + """Test creating a user ID from a valid string.""" + # Arrange + raw_id = "user_12345" + + # Act + user_id = create_user_id(raw_id) + + # Assert + assert user_id == raw_id + assert isinstance(user_id, str) + + def test_create_user_id_preserves_input(self) -> None: + """Test that create_user_id preserves the input string.""" + # Arrange + raw_id = "custom_user_identifier_456" + + # Act + user_id = create_user_id(raw_id) + + # Assert + assert user_id == raw_id + + def test_create_user_id_with_empty_string(self) -> None: + """Test creating a user ID with an empty string.""" + # Arrange + raw_id = "" + + # Act + user_id = create_user_id(raw_id) + + # Assert + assert user_id == "" + + +class TestTenantId: + """Test suite for TenantId type.""" + + def test_create_tenant_id_with_valid_string(self) -> None: + """Test creating a tenant ID from a valid string.""" + # Arrange + raw_id = "tenant_789" + + # Act + tenant_id = create_tenant_id(raw_id) + + # Assert + assert tenant_id == raw_id + assert isinstance(tenant_id, str) + + def test_create_tenant_id_preserves_input(self) -> None: + """Test that create_tenant_id preserves the input string.""" + # Arrange + raw_id = "org_xyz_123" + + # Act + tenant_id = create_tenant_id(raw_id) + + # Assert + assert tenant_id == raw_id + + +class TestMessageId: + """Test suite for MessageId type.""" + + def test_create_message_id_generates_unique_ids(self) -> None: + """Test that create_message_id generates unique IDs.""" + # Arrange & Act + id1 = create_message_id() + id2 = create_message_id() + + # Assert + assert id1 != id2 + + def test_create_message_id_has_correct_prefix(self) -> None: + """Test that message IDs have the 'msg_' prefix.""" + # Arrange & Act + message_id = create_message_id() + + # Assert + assert message_id.startswith("msg_") + + def test_create_message_id_contains_valid_uuid(self) -> None: + """Test that message ID contains a valid UUID.""" + # Arrange & Act + message_id = create_message_id() + uuid_part = message_id.split("msg_")[1] + + # Assert + UUID(uuid_part) # Will raise ValueError if invalid + + +class TestSessionId: + """Test suite for SessionId type.""" + + def test_create_session_id_generates_unique_ids(self) -> None: + """Test that create_session_id generates unique IDs.""" + # Arrange & Act + id1 = create_session_id() + id2 = create_session_id() + + # Assert + assert id1 != id2 + + def test_create_session_id_has_correct_prefix(self) -> None: + """Test that session IDs have the 'sess_' prefix.""" + # Arrange & Act + session_id = create_session_id() + + # Assert + assert session_id.startswith("sess_") + + def test_create_session_id_contains_valid_uuid(self) -> None: + """Test that session ID contains a valid UUID.""" + # Arrange & Act + session_id = create_session_id() + uuid_part = session_id.split("sess_")[1] + + # Assert + UUID(uuid_part) # Will raise ValueError if invalid + + +class TestTypeSafety: + """Test suite for type safety across all ID types.""" + + def test_all_id_types_are_strings_at_runtime(self) -> None: + """Test that all ID types behave as strings at runtime.""" + # Arrange & Act + conv_id = create_conversation_id() + template_id = create_template_id() + analysis_id = create_analysis_request_id() + user_id = create_user_id("user_1") + tenant_id = create_tenant_id("tenant_1") + message_id = create_message_id() + session_id = create_session_id() + + # Assert + assert isinstance(conv_id, str) + assert isinstance(template_id, str) + assert isinstance(analysis_id, str) + assert isinstance(user_id, str) + assert isinstance(tenant_id, str) + assert isinstance(message_id, str) + assert isinstance(session_id, str) + + def test_id_types_can_be_used_in_string_operations(self) -> None: + """Test that ID types support string operations.""" + # Arrange + conv_id = create_conversation_id() + + # Act & Assert + assert len(conv_id) > 0 + assert conv_id.upper() == conv_id.upper() + assert conv_id in [conv_id] # Can be used in collections + assert conv_id.startswith("conv_") + assert "_" in conv_id + + def test_id_uniqueness_across_multiple_calls(self) -> None: + """Test that generating many IDs produces unique values.""" + # Arrange & Act + conv_ids = {create_conversation_id() for _ in range(100)} + template_ids = {create_template_id() for _ in range(100)} + + # Assert + assert len(conv_ids) == 100 # All unique + assert len(template_ids) == 100 # All unique + + def test_id_types_can_be_compared(self) -> None: + """Test that ID types support comparison operations.""" + # Arrange + id1 = create_conversation_id() + id2 = create_conversation_id() + id3 = id1 + + # Act & Assert + assert id1 == id3 + assert id1 != id2 + assert (id1 < id2) or (id1 > id2) # One must be true + + def test_id_types_can_be_hashed(self) -> None: + """Test that ID types can be used as dictionary keys.""" + # Arrange + conv_id = create_conversation_id() + test_dict = {conv_id: "test_value"} + + # Act & Assert + assert test_dict[conv_id] == "test_value" + assert conv_id in test_dict + + def test_user_id_with_special_characters(self) -> None: + """Test user ID creation with various formats.""" + # Arrange & Act + id_with_dash = create_user_id("user-123-abc") + id_with_underscore = create_user_id("user_123_abc") + id_with_mixed = create_user_id("org#user@123") + + # Assert + assert id_with_dash == "user-123-abc" + assert id_with_underscore == "user_123_abc" + assert id_with_mixed == "org#user@123" + + def test_tenant_id_with_special_characters(self) -> None: + """Test tenant ID creation with various formats.""" + # Arrange & Act + id_with_dash = create_tenant_id("tenant-xyz-789") + id_with_underscore = create_tenant_id("org_name_123") + + # Assert + assert id_with_dash == "tenant-xyz-789" + assert id_with_underscore == "org_name_123" diff --git a/coaching/tests/unit/domain/entities/test_ai_job.py b/coaching/tests/unit/domain/entities/test_ai_job.py index 6cb43a53..58195d88 100644 --- a/coaching/tests/unit/domain/entities/test_ai_job.py +++ b/coaching/tests/unit/domain/entities/test_ai_job.py @@ -3,6 +3,7 @@ from datetime import UTC, datetime import pytest + from coaching.src.domain.entities.ai_job import ( AIJob, AIJobErrorCode, diff --git a/coaching/tests/unit/domain/entities/test_analysis_request.py b/coaching/tests/unit/domain/entities/test_analysis_request.py index 646f0d16..0b4a353b 100644 --- a/coaching/tests/unit/domain/entities/test_analysis_request.py +++ b/coaching/tests/unit/domain/entities/test_analysis_request.py @@ -1,6 +1,8 @@ """Unit tests for AnalysisRequest value object.""" import pytest +from pydantic import ValidationError + from coaching.src.core.constants import AnalysisType from coaching.src.core.types import ( create_analysis_request_id, @@ -8,7 +10,6 @@ create_user_id, ) from coaching.src.domain.entities.analysis_request import AnalysisRequest -from pydantic import ValidationError class TestAnalysisRequestCreation: diff --git a/coaching/tests/unit/domain/entities/test_coaching_session.py b/coaching/tests/unit/domain/entities/test_coaching_session.py index 0922c50e..60ac8e94 100644 --- a/coaching/tests/unit/domain/entities/test_coaching_session.py +++ b/coaching/tests/unit/domain/entities/test_coaching_session.py @@ -1,412 +1,413 @@ -"""Unit tests for CoachingSession entity. - -Tests for the CoachingSession aggregate root, including state management, -message handling, and lifecycle operations. -""" - -from datetime import UTC, datetime - -import pytest -from coaching.src.core.constants import ConversationStatus, MessageRole -from coaching.src.core.types import TenantId, UserId -from coaching.src.domain.entities.coaching_session import ( - CoachingMessage, - CoachingSession, -) -from coaching.src.domain.exceptions import ( - SessionNotActiveError, -) - - -class TestCoachingMessage: - """Tests for CoachingMessage value object.""" - - def test_create_message_with_defaults(self) -> None: - """Test creating a message with default values.""" - message = CoachingMessage( - role=MessageRole.USER, - content="Hello, coach!", - ) - - assert message.role == MessageRole.USER - assert message.content == "Hello, coach!" - assert message.metadata == {} - assert message.timestamp is not None - - def test_create_message_with_metadata(self) -> None: - """Test creating a message with custom metadata.""" - metadata = {"source": "web", "client_version": "1.0.0"} - message = CoachingMessage( - role=MessageRole.ASSISTANT, - content="How can I help you?", - metadata=metadata, - ) - - assert message.metadata == metadata - - def test_message_is_frozen(self) -> None: - """Test that messages are immutable.""" - message = CoachingMessage( - role=MessageRole.USER, - content="Test", - ) - - with pytest.raises(Exception): # ValidationError or similar - message.content = "Modified" # type: ignore[misc] - - -class TestCoachingSessionCreation: - """Tests for CoachingSession creation and initialization.""" - - def test_create_session_with_factory(self) -> None: - """Test creating a session using the factory method.""" - session = CoachingSession.create( - tenant_id="tenant_123", - topic_id="core_values", - user_id="user_456", - ) - - assert session.tenant_id == TenantId("tenant_123") - assert session.topic_id == "core_values" - assert session.user_id == UserId("user_456") - assert session.status == ConversationStatus.ACTIVE - assert session.messages == [] - assert session.context == {} - assert session.extracted_result is None - assert session.completed_at is None - - def test_create_session_with_context(self) -> None: - """Test creating a session with initial context.""" - context = {"enriched_param": "value", "user_name": "John"} - session = CoachingSession.create( - tenant_id="tenant_123", - topic_id="purpose", - user_id="user_456", - context=context, - ) - - assert session.context == context - - def test_session_has_valid_timestamps(self) -> None: - """Test that sessions have valid timestamps on creation.""" - before = datetime.now(UTC) - session = CoachingSession.create( - tenant_id="tenant_123", - topic_id="vision", - user_id="user_456", - ) - after = datetime.now(UTC) - - assert before <= session.created_at <= after - assert before <= session.updated_at <= after - assert before <= session.last_activity_at <= after - - def test_session_has_unique_id(self) -> None: - """Test that each session gets a unique ID.""" - session1 = CoachingSession.create( - tenant_id="tenant_123", - topic_id="core_values", - user_id="user_456", - ) - session2 = CoachingSession.create( - tenant_id="tenant_123", - topic_id="core_values", - user_id="user_456", - ) - - assert session1.session_id != session2.session_id - assert str(session1.session_id).startswith("sess_") - - -class TestCoachingSessionStateQueries: - """Tests for session state query methods.""" - - @pytest.fixture - def active_session(self) -> CoachingSession: - """Create an active session for testing.""" - return CoachingSession.create( - tenant_id="tenant_123", - topic_id="core_values", - user_id="user_456", - ) - - def test_is_active_returns_true_for_active_session( - self, active_session: CoachingSession - ) -> None: - """Test is_active() returns True for active sessions.""" - assert active_session.is_active() is True - assert active_session.is_paused() is False - assert active_session.is_completed() is False - assert active_session.is_cancelled() is False - - def test_can_accept_messages_when_active(self, active_session: CoachingSession) -> None: - """Test that active sessions can accept messages.""" - assert active_session.can_accept_messages() is True - - def test_message_counts_start_at_zero(self, active_session: CoachingSession) -> None: - """Test that new sessions have zero messages.""" - assert active_session.get_message_count() == 0 - assert active_session.get_user_message_count() == 0 - assert active_session.get_assistant_message_count() == 0 - - -class TestCoachingSessionMessageHandling: - """Tests for adding and managing messages.""" - - @pytest.fixture - def session(self) -> CoachingSession: - """Create a session for testing.""" - return CoachingSession.create( - tenant_id="tenant_123", - topic_id="core_values", - user_id="user_456", - ) - - def test_add_user_message(self, session: CoachingSession) -> None: - """Test adding a user message.""" - message = session.add_user_message("What are my core values?") - - assert message.role == MessageRole.USER - assert message.content == "What are my core values?" - assert session.get_message_count() == 1 - assert session.get_user_message_count() == 1 - - def test_add_assistant_message(self, session: CoachingSession) -> None: - """Test adding an assistant message.""" - message = session.add_assistant_message("Let's explore your values together.") - - assert message.role == MessageRole.ASSISTANT - assert message.content == "Let's explore your values together." - assert session.get_message_count() == 1 - assert session.get_assistant_message_count() == 1 - - def test_add_multiple_messages(self, session: CoachingSession) -> None: - """Test adding multiple messages in sequence.""" - session.add_user_message("First message") - session.add_assistant_message("First response") - session.add_user_message("Second message") - session.add_assistant_message("Second response") - - assert session.get_message_count() == 4 - assert session.get_user_message_count() == 2 - assert session.get_assistant_message_count() == 2 - - def test_add_message_updates_last_activity_for_user(self, session: CoachingSession) -> None: - """Test that user messages update last_activity_at.""" - initial_activity = session.last_activity_at - session.add_user_message("Test message") - - assert session.last_activity_at >= initial_activity - - def test_add_message_to_paused_session_raises_error(self, session: CoachingSession) -> None: - """Test that paused sessions reject new messages.""" - session.pause() - - with pytest.raises(SessionNotActiveError): - session.add_user_message("Should fail") - - def test_add_message_to_completed_session_raises_error(self, session: CoachingSession) -> None: - """Test that completed sessions reject new messages.""" - session.complete({"core_values": []}) - - with pytest.raises(SessionNotActiveError): - session.add_user_message("Should fail") - - -class TestCoachingSessionStateTransitions: - """Tests for session lifecycle state transitions.""" - - @pytest.fixture - def session(self) -> CoachingSession: - """Create a session for testing.""" - return CoachingSession.create( - tenant_id="tenant_123", - topic_id="core_values", - user_id="user_456", - ) - - def test_pause_active_session(self, session: CoachingSession) -> None: - """Test pausing an active session.""" - session.pause() - - assert session.is_paused() is True - assert session.is_active() is False - assert session.can_accept_messages() is False - - def test_resume_paused_session(self, session: CoachingSession) -> None: - """Test resuming a paused session.""" - session.pause() - session.resume() - - assert session.is_active() is True - assert session.is_paused() is False - assert session.can_accept_messages() is True - - def test_resume_updates_last_activity(self, session: CoachingSession) -> None: - """Test that resume updates last_activity_at.""" - session.pause() - initial_activity = session.last_activity_at - session.resume() - - assert session.last_activity_at >= initial_activity - - def test_complete_session_with_result(self, session: CoachingSession) -> None: - """Test completing a session with a result.""" - result = { - "core_values": [ - {"name": "Integrity", "description": "Being honest and ethical"}, - ] - } - session.complete(result) - - assert session.is_completed() is True - assert session.extracted_result == result - assert session.completed_at is not None - assert session.can_accept_messages() is False - - def test_cancel_active_session(self, session: CoachingSession) -> None: - """Test cancelling an active session.""" - session.cancel() - - assert session.is_cancelled() is True - assert session.can_accept_messages() is False - - def test_cancel_paused_session(self, session: CoachingSession) -> None: - """Test cancelling a paused session.""" - session.pause() - session.cancel() - - assert session.is_cancelled() is True - - def test_cannot_pause_paused_session(self, session: CoachingSession) -> None: - """Test that pausing a paused session raises error.""" - session.pause() - - with pytest.raises(ValueError, match="Cannot pause"): - session.pause() - - def test_cannot_resume_active_session(self, session: CoachingSession) -> None: - """Test that resuming an active session raises error.""" - with pytest.raises(ValueError, match="Cannot resume"): - session.resume() - - def test_cannot_complete_paused_session(self, session: CoachingSession) -> None: - """Test that completing a paused session raises error.""" - session.pause() - - with pytest.raises(ValueError, match="Cannot complete"): - session.complete({"result": "test"}) - - def test_cannot_cancel_completed_session(self, session: CoachingSession) -> None: - """Test that cancelling a completed session raises error.""" - session.complete({"result": "test"}) - - with pytest.raises(ValueError, match="Cannot cancel"): - session.cancel() - - def test_mark_abandoned_from_paused(self, session: CoachingSession) -> None: - """Test marking a paused session as abandoned.""" - session.pause() - session.mark_abandoned() - - assert session.status == ConversationStatus.ABANDONED - - def test_cannot_abandon_active_session(self, session: CoachingSession) -> None: - """Test that active sessions cannot be directly abandoned.""" - with pytest.raises(ValueError, match="Cannot abandon"): - session.mark_abandoned() - - -class TestCoachingSessionLLMFormatting: - """Tests for LLM context formatting.""" - - @pytest.fixture - def session_with_messages(self) -> CoachingSession: - """Create a session with multiple messages.""" - session = CoachingSession.create( - tenant_id="tenant_123", - topic_id="core_values", - user_id="user_456", - ) - session.add_user_message("First user message") - session.add_assistant_message("First assistant response") - session.add_user_message("Second user message") - session.add_assistant_message("Second assistant response") - return session - - def test_get_messages_for_llm_format(self, session_with_messages: CoachingSession) -> None: - """Test that messages are formatted correctly for LLM.""" - messages = session_with_messages.get_messages_for_llm() - - assert len(messages) == 4 - assert messages[0] == {"role": "user", "content": "First user message"} - assert messages[1] == {"role": "assistant", "content": "First assistant response"} - - def test_get_messages_for_llm_sliding_window(self) -> None: - """Test that sliding window limits message count.""" - session = CoachingSession.create( - tenant_id="tenant_123", - topic_id="core_values", - user_id="user_456", - ) - - # Add 35 messages - for i in range(35): - if i % 2 == 0: - session.add_user_message(f"User message {i}") - else: - session.add_assistant_message(f"Assistant message {i}") - - # Request max 10 messages - messages = session.get_messages_for_llm(max_messages=10) - - assert len(messages) == 10 - # Should be the last 10 messages - assert "34" in messages[-1]["content"] # Last message index - - -class TestCoachingSessionCompletionEstimate: - """Tests for completion estimation.""" - - def test_calculate_completion_with_no_messages(self) -> None: - """Test completion estimate with no messages.""" - session = CoachingSession.create( - tenant_id="tenant_123", - topic_id="core_values", - user_id="user_456", - ) - - estimate = session.calculate_estimated_completion(estimated_total=20) - assert estimate == 0.0 - - def test_calculate_completion_partial(self) -> None: - """Test completion estimate with some messages.""" - session = CoachingSession.create( - tenant_id="tenant_123", - topic_id="core_values", - user_id="user_456", - ) - - # Add 5 user messages - for _ in range(5): - session.add_user_message("Test") - session.add_assistant_message("Response") - - estimate = session.calculate_estimated_completion(estimated_total=20) - assert estimate == 0.25 # 5/20 - - def test_calculate_completion_caps_at_100_percent(self) -> None: - """Test that completion estimate doesn't exceed 1.0.""" - session = CoachingSession.create( - tenant_id="tenant_123", - topic_id="core_values", - user_id="user_456", - max_turns=50, # Allow more turns for this test - ) - - # Add more messages than estimated total - for _ in range(30): - session.add_user_message("Test") - - estimate = session.calculate_estimated_completion(estimated_total=20) - assert estimate == 1.0 +"""Unit tests for CoachingSession entity. + +Tests for the CoachingSession aggregate root, including state management, +message handling, and lifecycle operations. +""" + +from datetime import UTC, datetime + +import pytest + +from coaching.src.core.constants import ConversationStatus, MessageRole +from coaching.src.core.types import TenantId, UserId +from coaching.src.domain.entities.coaching_session import ( + CoachingMessage, + CoachingSession, +) +from coaching.src.domain.exceptions import ( + SessionNotActiveError, +) + + +class TestCoachingMessage: + """Tests for CoachingMessage value object.""" + + def test_create_message_with_defaults(self) -> None: + """Test creating a message with default values.""" + message = CoachingMessage( + role=MessageRole.USER, + content="Hello, coach!", + ) + + assert message.role == MessageRole.USER + assert message.content == "Hello, coach!" + assert message.metadata == {} + assert message.timestamp is not None + + def test_create_message_with_metadata(self) -> None: + """Test creating a message with custom metadata.""" + metadata = {"source": "web", "client_version": "1.0.0"} + message = CoachingMessage( + role=MessageRole.ASSISTANT, + content="How can I help you?", + metadata=metadata, + ) + + assert message.metadata == metadata + + def test_message_is_frozen(self) -> None: + """Test that messages are immutable.""" + message = CoachingMessage( + role=MessageRole.USER, + content="Test", + ) + + with pytest.raises(Exception): # ValidationError or similar + message.content = "Modified" # type: ignore[misc] + + +class TestCoachingSessionCreation: + """Tests for CoachingSession creation and initialization.""" + + def test_create_session_with_factory(self) -> None: + """Test creating a session using the factory method.""" + session = CoachingSession.create( + tenant_id="tenant_123", + topic_id="core_values", + user_id="user_456", + ) + + assert session.tenant_id == TenantId("tenant_123") + assert session.topic_id == "core_values" + assert session.user_id == UserId("user_456") + assert session.status == ConversationStatus.ACTIVE + assert session.messages == [] + assert session.context == {} + assert session.extracted_result is None + assert session.completed_at is None + + def test_create_session_with_context(self) -> None: + """Test creating a session with initial context.""" + context = {"enriched_param": "value", "user_name": "John"} + session = CoachingSession.create( + tenant_id="tenant_123", + topic_id="purpose", + user_id="user_456", + context=context, + ) + + assert session.context == context + + def test_session_has_valid_timestamps(self) -> None: + """Test that sessions have valid timestamps on creation.""" + before = datetime.now(UTC) + session = CoachingSession.create( + tenant_id="tenant_123", + topic_id="vision", + user_id="user_456", + ) + after = datetime.now(UTC) + + assert before <= session.created_at <= after + assert before <= session.updated_at <= after + assert before <= session.last_activity_at <= after + + def test_session_has_unique_id(self) -> None: + """Test that each session gets a unique ID.""" + session1 = CoachingSession.create( + tenant_id="tenant_123", + topic_id="core_values", + user_id="user_456", + ) + session2 = CoachingSession.create( + tenant_id="tenant_123", + topic_id="core_values", + user_id="user_456", + ) + + assert session1.session_id != session2.session_id + assert str(session1.session_id).startswith("sess_") + + +class TestCoachingSessionStateQueries: + """Tests for session state query methods.""" + + @pytest.fixture + def active_session(self) -> CoachingSession: + """Create an active session for testing.""" + return CoachingSession.create( + tenant_id="tenant_123", + topic_id="core_values", + user_id="user_456", + ) + + def test_is_active_returns_true_for_active_session( + self, active_session: CoachingSession + ) -> None: + """Test is_active() returns True for active sessions.""" + assert active_session.is_active() is True + assert active_session.is_paused() is False + assert active_session.is_completed() is False + assert active_session.is_cancelled() is False + + def test_can_accept_messages_when_active(self, active_session: CoachingSession) -> None: + """Test that active sessions can accept messages.""" + assert active_session.can_accept_messages() is True + + def test_message_counts_start_at_zero(self, active_session: CoachingSession) -> None: + """Test that new sessions have zero messages.""" + assert active_session.get_message_count() == 0 + assert active_session.get_user_message_count() == 0 + assert active_session.get_assistant_message_count() == 0 + + +class TestCoachingSessionMessageHandling: + """Tests for adding and managing messages.""" + + @pytest.fixture + def session(self) -> CoachingSession: + """Create a session for testing.""" + return CoachingSession.create( + tenant_id="tenant_123", + topic_id="core_values", + user_id="user_456", + ) + + def test_add_user_message(self, session: CoachingSession) -> None: + """Test adding a user message.""" + message = session.add_user_message("What are my core values?") + + assert message.role == MessageRole.USER + assert message.content == "What are my core values?" + assert session.get_message_count() == 1 + assert session.get_user_message_count() == 1 + + def test_add_assistant_message(self, session: CoachingSession) -> None: + """Test adding an assistant message.""" + message = session.add_assistant_message("Let's explore your values together.") + + assert message.role == MessageRole.ASSISTANT + assert message.content == "Let's explore your values together." + assert session.get_message_count() == 1 + assert session.get_assistant_message_count() == 1 + + def test_add_multiple_messages(self, session: CoachingSession) -> None: + """Test adding multiple messages in sequence.""" + session.add_user_message("First message") + session.add_assistant_message("First response") + session.add_user_message("Second message") + session.add_assistant_message("Second response") + + assert session.get_message_count() == 4 + assert session.get_user_message_count() == 2 + assert session.get_assistant_message_count() == 2 + + def test_add_message_updates_last_activity_for_user(self, session: CoachingSession) -> None: + """Test that user messages update last_activity_at.""" + initial_activity = session.last_activity_at + session.add_user_message("Test message") + + assert session.last_activity_at >= initial_activity + + def test_add_message_to_paused_session_raises_error(self, session: CoachingSession) -> None: + """Test that paused sessions reject new messages.""" + session.pause() + + with pytest.raises(SessionNotActiveError): + session.add_user_message("Should fail") + + def test_add_message_to_completed_session_raises_error(self, session: CoachingSession) -> None: + """Test that completed sessions reject new messages.""" + session.complete({"core_values": []}) + + with pytest.raises(SessionNotActiveError): + session.add_user_message("Should fail") + + +class TestCoachingSessionStateTransitions: + """Tests for session lifecycle state transitions.""" + + @pytest.fixture + def session(self) -> CoachingSession: + """Create a session for testing.""" + return CoachingSession.create( + tenant_id="tenant_123", + topic_id="core_values", + user_id="user_456", + ) + + def test_pause_active_session(self, session: CoachingSession) -> None: + """Test pausing an active session.""" + session.pause() + + assert session.is_paused() is True + assert session.is_active() is False + assert session.can_accept_messages() is False + + def test_resume_paused_session(self, session: CoachingSession) -> None: + """Test resuming a paused session.""" + session.pause() + session.resume() + + assert session.is_active() is True + assert session.is_paused() is False + assert session.can_accept_messages() is True + + def test_resume_updates_last_activity(self, session: CoachingSession) -> None: + """Test that resume updates last_activity_at.""" + session.pause() + initial_activity = session.last_activity_at + session.resume() + + assert session.last_activity_at >= initial_activity + + def test_complete_session_with_result(self, session: CoachingSession) -> None: + """Test completing a session with a result.""" + result = { + "core_values": [ + {"name": "Integrity", "description": "Being honest and ethical"}, + ] + } + session.complete(result) + + assert session.is_completed() is True + assert session.extracted_result == result + assert session.completed_at is not None + assert session.can_accept_messages() is False + + def test_cancel_active_session(self, session: CoachingSession) -> None: + """Test cancelling an active session.""" + session.cancel() + + assert session.is_cancelled() is True + assert session.can_accept_messages() is False + + def test_cancel_paused_session(self, session: CoachingSession) -> None: + """Test cancelling a paused session.""" + session.pause() + session.cancel() + + assert session.is_cancelled() is True + + def test_cannot_pause_paused_session(self, session: CoachingSession) -> None: + """Test that pausing a paused session raises error.""" + session.pause() + + with pytest.raises(ValueError, match="Cannot pause"): + session.pause() + + def test_cannot_resume_active_session(self, session: CoachingSession) -> None: + """Test that resuming an active session raises error.""" + with pytest.raises(ValueError, match="Cannot resume"): + session.resume() + + def test_cannot_complete_paused_session(self, session: CoachingSession) -> None: + """Test that completing a paused session raises error.""" + session.pause() + + with pytest.raises(ValueError, match="Cannot complete"): + session.complete({"result": "test"}) + + def test_cannot_cancel_completed_session(self, session: CoachingSession) -> None: + """Test that cancelling a completed session raises error.""" + session.complete({"result": "test"}) + + with pytest.raises(ValueError, match="Cannot cancel"): + session.cancel() + + def test_mark_abandoned_from_paused(self, session: CoachingSession) -> None: + """Test marking a paused session as abandoned.""" + session.pause() + session.mark_abandoned() + + assert session.status == ConversationStatus.ABANDONED + + def test_cannot_abandon_active_session(self, session: CoachingSession) -> None: + """Test that active sessions cannot be directly abandoned.""" + with pytest.raises(ValueError, match="Cannot abandon"): + session.mark_abandoned() + + +class TestCoachingSessionLLMFormatting: + """Tests for LLM context formatting.""" + + @pytest.fixture + def session_with_messages(self) -> CoachingSession: + """Create a session with multiple messages.""" + session = CoachingSession.create( + tenant_id="tenant_123", + topic_id="core_values", + user_id="user_456", + ) + session.add_user_message("First user message") + session.add_assistant_message("First assistant response") + session.add_user_message("Second user message") + session.add_assistant_message("Second assistant response") + return session + + def test_get_messages_for_llm_format(self, session_with_messages: CoachingSession) -> None: + """Test that messages are formatted correctly for LLM.""" + messages = session_with_messages.get_messages_for_llm() + + assert len(messages) == 4 + assert messages[0] == {"role": "user", "content": "First user message"} + assert messages[1] == {"role": "assistant", "content": "First assistant response"} + + def test_get_messages_for_llm_sliding_window(self) -> None: + """Test that sliding window limits message count.""" + session = CoachingSession.create( + tenant_id="tenant_123", + topic_id="core_values", + user_id="user_456", + ) + + # Add 35 messages + for i in range(35): + if i % 2 == 0: + session.add_user_message(f"User message {i}") + else: + session.add_assistant_message(f"Assistant message {i}") + + # Request max 10 messages + messages = session.get_messages_for_llm(max_messages=10) + + assert len(messages) == 10 + # Should be the last 10 messages + assert "34" in messages[-1]["content"] # Last message index + + +class TestCoachingSessionCompletionEstimate: + """Tests for completion estimation.""" + + def test_calculate_completion_with_no_messages(self) -> None: + """Test completion estimate with no messages.""" + session = CoachingSession.create( + tenant_id="tenant_123", + topic_id="core_values", + user_id="user_456", + ) + + estimate = session.calculate_estimated_completion(estimated_total=20) + assert estimate == 0.0 + + def test_calculate_completion_partial(self) -> None: + """Test completion estimate with some messages.""" + session = CoachingSession.create( + tenant_id="tenant_123", + topic_id="core_values", + user_id="user_456", + ) + + # Add 5 user messages + for _ in range(5): + session.add_user_message("Test") + session.add_assistant_message("Response") + + estimate = session.calculate_estimated_completion(estimated_total=20) + assert estimate == 0.25 # 5/20 + + def test_calculate_completion_caps_at_100_percent(self) -> None: + """Test that completion estimate doesn't exceed 1.0.""" + session = CoachingSession.create( + tenant_id="tenant_123", + topic_id="core_values", + user_id="user_456", + max_turns=50, # Allow more turns for this test + ) + + # Add more messages than estimated total + for _ in range(30): + session.add_user_message("Test") + + estimate = session.calculate_estimated_completion(estimated_total=20) + assert estimate == 1.0 diff --git a/coaching/tests/unit/domain/entities/test_conversation.py b/coaching/tests/unit/domain/entities/test_conversation.py index 5d16fc89..662703bd 100644 --- a/coaching/tests/unit/domain/entities/test_conversation.py +++ b/coaching/tests/unit/domain/entities/test_conversation.py @@ -1,436 +1,437 @@ -"""Unit tests for Conversation aggregate root.""" - -import pytest -from coaching.src.core.constants import ( - CoachingTopic, - ConversationPhase, - ConversationStatus, - MessageRole, -) -from coaching.src.core.types import ( - create_conversation_id, - create_tenant_id, - create_user_id, -) -from coaching.src.domain.entities.conversation import Conversation - -pytestmark = pytest.mark.unit - - -class TestConversationCreation: - """Test suite for Conversation creation.""" - - def test_create_conversation_with_required_fields(self) -> None: - """Test creating conversation with required fields.""" - # Arrange & Act - conversation = Conversation( - conversation_id=create_conversation_id(), - user_id=create_user_id("user_123"), - tenant_id=create_tenant_id("tenant_456"), - topic=CoachingTopic.CORE_VALUES, - ) - - # Assert - assert conversation.conversation_id is not None - assert conversation.user_id == "user_123" - assert conversation.tenant_id == "tenant_456" - assert conversation.topic == CoachingTopic.CORE_VALUES - assert conversation.status == ConversationStatus.ACTIVE - assert len(conversation.messages) == 0 - assert conversation.context.current_phase == ConversationPhase.INTRODUCTION - - def test_create_conversation_initializes_timestamps(self) -> None: - """Test that timestamps are auto-initialized.""" - # Arrange & Act - conversation = Conversation( - conversation_id=create_conversation_id(), - user_id=create_user_id("user_123"), - tenant_id=create_tenant_id("tenant_456"), - topic=CoachingTopic.PURPOSE, - ) - - # Assert - assert conversation.created_at is not None - assert conversation.updated_at is not None - assert conversation.completed_at is None - - -class TestConversationMessageManagement: - """Test suite for message management.""" - - @pytest.fixture - def conversation(self) -> Conversation: - """Fixture providing a test conversation.""" - return Conversation( - conversation_id=create_conversation_id(), - user_id=create_user_id("user_123"), - tenant_id=create_tenant_id("tenant_456"), - topic=CoachingTopic.CORE_VALUES, - ) - - def test_add_message_to_active_conversation(self, conversation: Conversation) -> None: - """Test adding message to active conversation.""" - # Arrange - initial_count = len(conversation.messages) - - # Act - conversation.add_message(role=MessageRole.USER, content="What are my values?") - - # Assert - assert len(conversation.messages) == initial_count + 1 - assert conversation.messages[0].role == MessageRole.USER - assert conversation.messages[0].content == "What are my values?" - - def test_add_user_message_increments_response_count(self, conversation: Conversation) -> None: - """Test that user messages increment response count.""" - # Arrange - initial_count = conversation.context.response_count - - # Act - conversation.add_message(role=MessageRole.USER, content="Test message") - - # Assert - assert conversation.context.response_count == initial_count + 1 - - def test_add_assistant_message_does_not_increment_response_count( - self, conversation: Conversation - ) -> None: - """Test that assistant messages don't increment response count.""" - # Arrange - initial_count = conversation.context.response_count - - # Act - conversation.add_message(role=MessageRole.ASSISTANT, content="Response message") - - # Assert - assert conversation.context.response_count == initial_count - - def test_add_message_updates_timestamp(self, conversation: Conversation) -> None: - """Test that adding message updates conversation timestamp.""" - # Arrange - original_timestamp = conversation.updated_at - - # Act - conversation.add_message(role=MessageRole.USER, content="Test") - - # Assert - assert conversation.updated_at >= original_timestamp - - def test_add_message_to_completed_conversation_raises_error( - self, conversation: Conversation - ) -> None: - """Test that adding message to completed conversation raises error.""" - # Arrange - conversation.transition_to_phase(ConversationPhase.VALIDATION) - conversation.mark_completed() - - # Act & Assert - with pytest.raises(ValueError, match="Cannot add message to completed"): - conversation.add_message(role=MessageRole.USER, content="Test") - - def test_add_message_to_paused_conversation_raises_error( - self, conversation: Conversation - ) -> None: - """Test that adding message to paused conversation raises error.""" - # Arrange - conversation.mark_paused() - - # Act & Assert - with pytest.raises(ValueError, match="Cannot add message to paused"): - conversation.add_message(role=MessageRole.USER, content="Test") - - def test_add_message_with_metadata(self, conversation: Conversation) -> None: - """Test adding message with metadata.""" - # Arrange - metadata = {"source": "web", "timestamp": "2024-01-01"} - - # Act - conversation.add_message(role=MessageRole.USER, content="Test", metadata=metadata) - - # Assert - assert conversation.messages[0].metadata == metadata - - -class TestConversationPhaseTransitions: - """Test suite for phase transition business rules.""" - - @pytest.fixture - def conversation(self) -> Conversation: - """Fixture providing a test conversation.""" - return Conversation( - conversation_id=create_conversation_id(), - user_id=create_user_id("user_123"), - tenant_id=create_tenant_id("tenant_456"), - topic=CoachingTopic.CORE_VALUES, - ) - - def test_transition_to_next_phase(self, conversation: Conversation) -> None: - """Test transitioning to next phase.""" - # Arrange - assert conversation.context.current_phase == ConversationPhase.INTRODUCTION - - # Act - conversation.transition_to_phase(ConversationPhase.EXPLORATION) - - # Assert - assert conversation.context.current_phase == ConversationPhase.EXPLORATION - - def test_transition_updates_progress_percentage(self, conversation: Conversation) -> None: - """Test that phase transition updates progress.""" - # Arrange & Act - conversation.transition_to_phase(ConversationPhase.DEEPENING) - - # Assert - assert conversation.context.progress_percentage == 50.0 - - def test_transition_to_same_phase_succeeds(self, conversation: Conversation) -> None: - """Test that staying in same phase is allowed.""" - # Arrange - conversation.transition_to_phase(ConversationPhase.EXPLORATION) - - # Act - conversation.transition_to_phase(ConversationPhase.EXPLORATION) - - # Assert - assert conversation.context.current_phase == ConversationPhase.EXPLORATION - - def test_transition_backward_raises_error(self, conversation: Conversation) -> None: - """Test that backward phase transition is not allowed.""" - # Arrange - conversation.transition_to_phase(ConversationPhase.DEEPENING) - - # Act & Assert - with pytest.raises(ValueError, match="Cannot move backward"): - conversation.transition_to_phase(ConversationPhase.EXPLORATION) - - def test_transition_paused_conversation_raises_error(self, conversation: Conversation) -> None: - """Test that paused conversations cannot transition.""" - # Arrange - conversation.mark_paused() - - # Act & Assert - with pytest.raises(ValueError, match="Cannot transition paused"): - conversation.transition_to_phase(ConversationPhase.EXPLORATION) - - def test_transition_updates_timestamp(self, conversation: Conversation) -> None: - """Test that phase transition updates timestamp.""" - # Arrange - original_timestamp = conversation.updated_at - - # Act - conversation.transition_to_phase(ConversationPhase.EXPLORATION) - - # Assert - assert conversation.updated_at >= original_timestamp - - -class TestConversationInsights: - """Test suite for insight management.""" - - @pytest.fixture - def conversation(self) -> Conversation: - """Fixture providing a test conversation.""" - return Conversation( - conversation_id=create_conversation_id(), - user_id=create_user_id("user_123"), - tenant_id=create_tenant_id("tenant_456"), - topic=CoachingTopic.CORE_VALUES, - ) - - def test_add_insight(self, conversation: Conversation) -> None: - """Test adding insight to conversation.""" - # Arrange & Act - conversation.add_insight("Values autonomy highly") - - # Assert - assert len(conversation.context.insights) == 1 - assert conversation.context.insights[0] == "Values autonomy highly" - - def test_add_multiple_insights(self, conversation: Conversation) -> None: - """Test adding multiple insights.""" - # Arrange & Act - conversation.add_insight("First insight") - conversation.add_insight("Second insight") - - # Assert - assert len(conversation.context.insights) == 2 - - def test_add_insight_strips_whitespace(self, conversation: Conversation) -> None: - """Test that insight whitespace is stripped.""" - # Arrange & Act - conversation.add_insight(" Insight with spaces ") - - # Assert - assert conversation.context.insights[0] == "Insight with spaces" - - def test_add_empty_insight_raises_error(self, conversation: Conversation) -> None: - """Test that empty insight raises error.""" - # Arrange & Act & Assert - with pytest.raises(ValueError, match="Insight cannot be empty"): - conversation.add_insight("") - - def test_add_whitespace_only_insight_raises_error(self, conversation: Conversation) -> None: - """Test that whitespace-only insight raises error.""" - # Arrange & Act & Assert - with pytest.raises(ValueError, match="Insight cannot be empty"): - conversation.add_insight(" ") - - -class TestConversationStatusTransitions: - """Test suite for status transition business rules.""" - - @pytest.fixture - def conversation(self) -> Conversation: - """Fixture providing a test conversation.""" - return Conversation( - conversation_id=create_conversation_id(), - user_id=create_user_id("user_123"), - tenant_id=create_tenant_id("tenant_456"), - topic=CoachingTopic.CORE_VALUES, - ) - - def test_mark_completed_in_validation_phase(self, conversation: Conversation) -> None: - """Test marking conversation completed in validation phase.""" - # Arrange - conversation.transition_to_phase(ConversationPhase.VALIDATION) - - # Act - conversation.mark_completed() - - # Assert - assert conversation.status == ConversationStatus.COMPLETED - assert conversation.completed_at is not None - - def test_mark_completed_in_completion_phase(self, conversation: Conversation) -> None: - """Test marking conversation completed in completion phase.""" - # Arrange - conversation.transition_to_phase(ConversationPhase.COMPLETION) - - # Act - conversation.mark_completed() - - # Assert - assert conversation.status == ConversationStatus.COMPLETED - - def test_mark_completed_in_early_phase_raises_error(self, conversation: Conversation) -> None: - """Test that completing early phase conversation raises error.""" - # Arrange - conversation.transition_to_phase(ConversationPhase.EXPLORATION) - - # Act & Assert - with pytest.raises(ValueError, match="Cannot complete conversation in exploration"): - conversation.mark_completed() - - def test_mark_paused(self, conversation: Conversation) -> None: - """Test pausing active conversation.""" - # Arrange & Act - conversation.mark_paused() - - # Assert - assert conversation.status == ConversationStatus.PAUSED - - def test_mark_paused_when_already_paused_raises_error(self, conversation: Conversation) -> None: - """Test that pausing paused conversation raises error.""" - # Arrange - conversation.mark_paused() - - # Act & Assert - with pytest.raises(ValueError, match="Cannot pause paused"): - conversation.mark_paused() - - def test_resume_paused_conversation(self, conversation: Conversation) -> None: - """Test resuming paused conversation.""" - # Arrange - conversation.mark_paused() - - # Act - conversation.resume() - - # Assert - assert conversation.status == ConversationStatus.ACTIVE - - def test_resume_active_conversation_raises_error(self, conversation: Conversation) -> None: - """Test that resuming active conversation raises error.""" - # Arrange & Act & Assert - with pytest.raises(ValueError, match="Cannot resume active"): - conversation.resume() - - -class TestConversationUtilityMethods: - """Test suite for utility methods.""" - - @pytest.fixture - def conversation(self) -> Conversation: - """Fixture providing a test conversation with messages.""" - conv = Conversation( - conversation_id=create_conversation_id(), - user_id=create_user_id("user_123"), - tenant_id=create_tenant_id("tenant_456"), - topic=CoachingTopic.CORE_VALUES, - ) - conv.add_message(role=MessageRole.USER, content="Message 1") - conv.add_message(role=MessageRole.ASSISTANT, content="Response 1") - conv.add_message(role=MessageRole.USER, content="Message 2") - return conv - - def test_is_active_returns_true_for_active(self, conversation: Conversation) -> None: - """Test is_active returns True for active conversation.""" - # Assert - assert conversation.is_active() is True - - def test_is_completed_returns_false_for_active(self, conversation: Conversation) -> None: - """Test is_completed returns False for active conversation.""" - # Assert - assert conversation.is_completed() is False - - def test_get_message_count(self, conversation: Conversation) -> None: - """Test getting total message count.""" - # Act - count = conversation.get_message_count() - - # Assert - assert count == 3 - - def test_get_user_message_count(self, conversation: Conversation) -> None: - """Test getting user message count.""" - # Act - count = conversation.get_user_message_count() - - # Assert - assert count == 2 - - def test_get_assistant_message_count(self, conversation: Conversation) -> None: - """Test getting assistant message count.""" - # Act - count = conversation.get_assistant_message_count() - - # Assert - assert count == 1 - - def test_calculate_progress_percentage(self, conversation: Conversation) -> None: - """Test calculating progress percentage.""" - # Act - conversation.transition_to_phase(ConversationPhase.SYNTHESIS) - progress = conversation.calculate_progress_percentage() - - # Assert - assert progress == 70.0 - - def test_get_conversation_history(self, conversation: Conversation) -> None: - """Test getting conversation history.""" - # Act - history = conversation.get_conversation_history() - - # Assert - assert len(history) == 3 - assert history[0]["role"] == "user" - assert history[0]["content"] == "Message 1" - - def test_get_conversation_history_with_max_messages(self, conversation: Conversation) -> None: - """Test getting limited conversation history.""" - # Act - history = conversation.get_conversation_history(max_messages=2) - - # Assert - assert len(history) == 2 - assert history[0]["content"] == "Response 1" +"""Unit tests for Conversation aggregate root.""" + +import pytest + +from coaching.src.core.constants import ( + CoachingTopic, + ConversationPhase, + ConversationStatus, + MessageRole, +) +from coaching.src.core.types import ( + create_conversation_id, + create_tenant_id, + create_user_id, +) +from coaching.src.domain.entities.conversation import Conversation + +pytestmark = pytest.mark.unit + + +class TestConversationCreation: + """Test suite for Conversation creation.""" + + def test_create_conversation_with_required_fields(self) -> None: + """Test creating conversation with required fields.""" + # Arrange & Act + conversation = Conversation( + conversation_id=create_conversation_id(), + user_id=create_user_id("user_123"), + tenant_id=create_tenant_id("tenant_456"), + topic=CoachingTopic.CORE_VALUES, + ) + + # Assert + assert conversation.conversation_id is not None + assert conversation.user_id == "user_123" + assert conversation.tenant_id == "tenant_456" + assert conversation.topic == CoachingTopic.CORE_VALUES + assert conversation.status == ConversationStatus.ACTIVE + assert len(conversation.messages) == 0 + assert conversation.context.current_phase == ConversationPhase.INTRODUCTION + + def test_create_conversation_initializes_timestamps(self) -> None: + """Test that timestamps are auto-initialized.""" + # Arrange & Act + conversation = Conversation( + conversation_id=create_conversation_id(), + user_id=create_user_id("user_123"), + tenant_id=create_tenant_id("tenant_456"), + topic=CoachingTopic.PURPOSE, + ) + + # Assert + assert conversation.created_at is not None + assert conversation.updated_at is not None + assert conversation.completed_at is None + + +class TestConversationMessageManagement: + """Test suite for message management.""" + + @pytest.fixture + def conversation(self) -> Conversation: + """Fixture providing a test conversation.""" + return Conversation( + conversation_id=create_conversation_id(), + user_id=create_user_id("user_123"), + tenant_id=create_tenant_id("tenant_456"), + topic=CoachingTopic.CORE_VALUES, + ) + + def test_add_message_to_active_conversation(self, conversation: Conversation) -> None: + """Test adding message to active conversation.""" + # Arrange + initial_count = len(conversation.messages) + + # Act + conversation.add_message(role=MessageRole.USER, content="What are my values?") + + # Assert + assert len(conversation.messages) == initial_count + 1 + assert conversation.messages[0].role == MessageRole.USER + assert conversation.messages[0].content == "What are my values?" + + def test_add_user_message_increments_response_count(self, conversation: Conversation) -> None: + """Test that user messages increment response count.""" + # Arrange + initial_count = conversation.context.response_count + + # Act + conversation.add_message(role=MessageRole.USER, content="Test message") + + # Assert + assert conversation.context.response_count == initial_count + 1 + + def test_add_assistant_message_does_not_increment_response_count( + self, conversation: Conversation + ) -> None: + """Test that assistant messages don't increment response count.""" + # Arrange + initial_count = conversation.context.response_count + + # Act + conversation.add_message(role=MessageRole.ASSISTANT, content="Response message") + + # Assert + assert conversation.context.response_count == initial_count + + def test_add_message_updates_timestamp(self, conversation: Conversation) -> None: + """Test that adding message updates conversation timestamp.""" + # Arrange + original_timestamp = conversation.updated_at + + # Act + conversation.add_message(role=MessageRole.USER, content="Test") + + # Assert + assert conversation.updated_at >= original_timestamp + + def test_add_message_to_completed_conversation_raises_error( + self, conversation: Conversation + ) -> None: + """Test that adding message to completed conversation raises error.""" + # Arrange + conversation.transition_to_phase(ConversationPhase.VALIDATION) + conversation.mark_completed() + + # Act & Assert + with pytest.raises(ValueError, match="Cannot add message to completed"): + conversation.add_message(role=MessageRole.USER, content="Test") + + def test_add_message_to_paused_conversation_raises_error( + self, conversation: Conversation + ) -> None: + """Test that adding message to paused conversation raises error.""" + # Arrange + conversation.mark_paused() + + # Act & Assert + with pytest.raises(ValueError, match="Cannot add message to paused"): + conversation.add_message(role=MessageRole.USER, content="Test") + + def test_add_message_with_metadata(self, conversation: Conversation) -> None: + """Test adding message with metadata.""" + # Arrange + metadata = {"source": "web", "timestamp": "2024-01-01"} + + # Act + conversation.add_message(role=MessageRole.USER, content="Test", metadata=metadata) + + # Assert + assert conversation.messages[0].metadata == metadata + + +class TestConversationPhaseTransitions: + """Test suite for phase transition business rules.""" + + @pytest.fixture + def conversation(self) -> Conversation: + """Fixture providing a test conversation.""" + return Conversation( + conversation_id=create_conversation_id(), + user_id=create_user_id("user_123"), + tenant_id=create_tenant_id("tenant_456"), + topic=CoachingTopic.CORE_VALUES, + ) + + def test_transition_to_next_phase(self, conversation: Conversation) -> None: + """Test transitioning to next phase.""" + # Arrange + assert conversation.context.current_phase == ConversationPhase.INTRODUCTION + + # Act + conversation.transition_to_phase(ConversationPhase.EXPLORATION) + + # Assert + assert conversation.context.current_phase == ConversationPhase.EXPLORATION + + def test_transition_updates_progress_percentage(self, conversation: Conversation) -> None: + """Test that phase transition updates progress.""" + # Arrange & Act + conversation.transition_to_phase(ConversationPhase.DEEPENING) + + # Assert + assert conversation.context.progress_percentage == 50.0 + + def test_transition_to_same_phase_succeeds(self, conversation: Conversation) -> None: + """Test that staying in same phase is allowed.""" + # Arrange + conversation.transition_to_phase(ConversationPhase.EXPLORATION) + + # Act + conversation.transition_to_phase(ConversationPhase.EXPLORATION) + + # Assert + assert conversation.context.current_phase == ConversationPhase.EXPLORATION + + def test_transition_backward_raises_error(self, conversation: Conversation) -> None: + """Test that backward phase transition is not allowed.""" + # Arrange + conversation.transition_to_phase(ConversationPhase.DEEPENING) + + # Act & Assert + with pytest.raises(ValueError, match="Cannot move backward"): + conversation.transition_to_phase(ConversationPhase.EXPLORATION) + + def test_transition_paused_conversation_raises_error(self, conversation: Conversation) -> None: + """Test that paused conversations cannot transition.""" + # Arrange + conversation.mark_paused() + + # Act & Assert + with pytest.raises(ValueError, match="Cannot transition paused"): + conversation.transition_to_phase(ConversationPhase.EXPLORATION) + + def test_transition_updates_timestamp(self, conversation: Conversation) -> None: + """Test that phase transition updates timestamp.""" + # Arrange + original_timestamp = conversation.updated_at + + # Act + conversation.transition_to_phase(ConversationPhase.EXPLORATION) + + # Assert + assert conversation.updated_at >= original_timestamp + + +class TestConversationInsights: + """Test suite for insight management.""" + + @pytest.fixture + def conversation(self) -> Conversation: + """Fixture providing a test conversation.""" + return Conversation( + conversation_id=create_conversation_id(), + user_id=create_user_id("user_123"), + tenant_id=create_tenant_id("tenant_456"), + topic=CoachingTopic.CORE_VALUES, + ) + + def test_add_insight(self, conversation: Conversation) -> None: + """Test adding insight to conversation.""" + # Arrange & Act + conversation.add_insight("Values autonomy highly") + + # Assert + assert len(conversation.context.insights) == 1 + assert conversation.context.insights[0] == "Values autonomy highly" + + def test_add_multiple_insights(self, conversation: Conversation) -> None: + """Test adding multiple insights.""" + # Arrange & Act + conversation.add_insight("First insight") + conversation.add_insight("Second insight") + + # Assert + assert len(conversation.context.insights) == 2 + + def test_add_insight_strips_whitespace(self, conversation: Conversation) -> None: + """Test that insight whitespace is stripped.""" + # Arrange & Act + conversation.add_insight(" Insight with spaces ") + + # Assert + assert conversation.context.insights[0] == "Insight with spaces" + + def test_add_empty_insight_raises_error(self, conversation: Conversation) -> None: + """Test that empty insight raises error.""" + # Arrange & Act & Assert + with pytest.raises(ValueError, match="Insight cannot be empty"): + conversation.add_insight("") + + def test_add_whitespace_only_insight_raises_error(self, conversation: Conversation) -> None: + """Test that whitespace-only insight raises error.""" + # Arrange & Act & Assert + with pytest.raises(ValueError, match="Insight cannot be empty"): + conversation.add_insight(" ") + + +class TestConversationStatusTransitions: + """Test suite for status transition business rules.""" + + @pytest.fixture + def conversation(self) -> Conversation: + """Fixture providing a test conversation.""" + return Conversation( + conversation_id=create_conversation_id(), + user_id=create_user_id("user_123"), + tenant_id=create_tenant_id("tenant_456"), + topic=CoachingTopic.CORE_VALUES, + ) + + def test_mark_completed_in_validation_phase(self, conversation: Conversation) -> None: + """Test marking conversation completed in validation phase.""" + # Arrange + conversation.transition_to_phase(ConversationPhase.VALIDATION) + + # Act + conversation.mark_completed() + + # Assert + assert conversation.status == ConversationStatus.COMPLETED + assert conversation.completed_at is not None + + def test_mark_completed_in_completion_phase(self, conversation: Conversation) -> None: + """Test marking conversation completed in completion phase.""" + # Arrange + conversation.transition_to_phase(ConversationPhase.COMPLETION) + + # Act + conversation.mark_completed() + + # Assert + assert conversation.status == ConversationStatus.COMPLETED + + def test_mark_completed_in_early_phase_raises_error(self, conversation: Conversation) -> None: + """Test that completing early phase conversation raises error.""" + # Arrange + conversation.transition_to_phase(ConversationPhase.EXPLORATION) + + # Act & Assert + with pytest.raises(ValueError, match="Cannot complete conversation in exploration"): + conversation.mark_completed() + + def test_mark_paused(self, conversation: Conversation) -> None: + """Test pausing active conversation.""" + # Arrange & Act + conversation.mark_paused() + + # Assert + assert conversation.status == ConversationStatus.PAUSED + + def test_mark_paused_when_already_paused_raises_error(self, conversation: Conversation) -> None: + """Test that pausing paused conversation raises error.""" + # Arrange + conversation.mark_paused() + + # Act & Assert + with pytest.raises(ValueError, match="Cannot pause paused"): + conversation.mark_paused() + + def test_resume_paused_conversation(self, conversation: Conversation) -> None: + """Test resuming paused conversation.""" + # Arrange + conversation.mark_paused() + + # Act + conversation.resume() + + # Assert + assert conversation.status == ConversationStatus.ACTIVE + + def test_resume_active_conversation_raises_error(self, conversation: Conversation) -> None: + """Test that resuming active conversation raises error.""" + # Arrange & Act & Assert + with pytest.raises(ValueError, match="Cannot resume active"): + conversation.resume() + + +class TestConversationUtilityMethods: + """Test suite for utility methods.""" + + @pytest.fixture + def conversation(self) -> Conversation: + """Fixture providing a test conversation with messages.""" + conv = Conversation( + conversation_id=create_conversation_id(), + user_id=create_user_id("user_123"), + tenant_id=create_tenant_id("tenant_456"), + topic=CoachingTopic.CORE_VALUES, + ) + conv.add_message(role=MessageRole.USER, content="Message 1") + conv.add_message(role=MessageRole.ASSISTANT, content="Response 1") + conv.add_message(role=MessageRole.USER, content="Message 2") + return conv + + def test_is_active_returns_true_for_active(self, conversation: Conversation) -> None: + """Test is_active returns True for active conversation.""" + # Assert + assert conversation.is_active() is True + + def test_is_completed_returns_false_for_active(self, conversation: Conversation) -> None: + """Test is_completed returns False for active conversation.""" + # Assert + assert conversation.is_completed() is False + + def test_get_message_count(self, conversation: Conversation) -> None: + """Test getting total message count.""" + # Act + count = conversation.get_message_count() + + # Assert + assert count == 3 + + def test_get_user_message_count(self, conversation: Conversation) -> None: + """Test getting user message count.""" + # Act + count = conversation.get_user_message_count() + + # Assert + assert count == 2 + + def test_get_assistant_message_count(self, conversation: Conversation) -> None: + """Test getting assistant message count.""" + # Act + count = conversation.get_assistant_message_count() + + # Assert + assert count == 1 + + def test_calculate_progress_percentage(self, conversation: Conversation) -> None: + """Test calculating progress percentage.""" + # Act + conversation.transition_to_phase(ConversationPhase.SYNTHESIS) + progress = conversation.calculate_progress_percentage() + + # Assert + assert progress == 70.0 + + def test_get_conversation_history(self, conversation: Conversation) -> None: + """Test getting conversation history.""" + # Act + history = conversation.get_conversation_history() + + # Assert + assert len(history) == 3 + assert history[0]["role"] == "user" + assert history[0]["content"] == "Message 1" + + def test_get_conversation_history_with_max_messages(self, conversation: Conversation) -> None: + """Test getting limited conversation history.""" + # Act + history = conversation.get_conversation_history(max_messages=2) + + # Assert + assert len(history) == 2 + assert history[0]["content"] == "Response 1" diff --git a/coaching/tests/unit/domain/entities/test_llm_topic.py b/coaching/tests/unit/domain/entities/test_llm_topic.py index 7292a637..2fed879a 100644 --- a/coaching/tests/unit/domain/entities/test_llm_topic.py +++ b/coaching/tests/unit/domain/entities/test_llm_topic.py @@ -3,6 +3,7 @@ from datetime import UTC, datetime import pytest + from coaching.src.domain.entities.llm_topic import ( LLMTopic, ParameterDefinition, diff --git a/coaching/tests/unit/domain/entities/test_prompt_template.py b/coaching/tests/unit/domain/entities/test_prompt_template.py index 104a50a6..88cd0ca1 100644 --- a/coaching/tests/unit/domain/entities/test_prompt_template.py +++ b/coaching/tests/unit/domain/entities/test_prompt_template.py @@ -1,289 +1,290 @@ -"""Unit tests for PromptTemplate aggregate root.""" - -import pytest -from coaching.src.core.constants import CoachingTopic, ConversationPhase -from coaching.src.core.types import create_template_id -from coaching.src.domain.entities.prompt_template import PromptTemplate -from pydantic import ValidationError - -pytestmark = pytest.mark.unit - - -class TestPromptTemplateCreation: - """Test suite for PromptTemplate creation.""" - - def test_create_template_with_required_fields(self) -> None: - """Test creating template with required fields.""" - # Arrange & Act - template = PromptTemplate( - template_id=create_template_id(), - name="Core Values Introduction", - topic=CoachingTopic.CORE_VALUES, - phase=ConversationPhase.INTRODUCTION, - system_prompt="You are an AI coaching assistant.", - template_text="Hello {name}, let's explore your core values.", - variables=["name"], - ) - - # Assert - assert template.name == "Core Values Introduction" - assert template.topic == CoachingTopic.CORE_VALUES - assert template.phase == ConversationPhase.INTRODUCTION - assert template.variables == ["name"] - assert template.version == 1 - assert template.is_active is True - - def test_create_template_with_no_variables(self) -> None: - """Test creating template without variables.""" - # Arrange & Act - template = PromptTemplate( - template_id=create_template_id(), - name="Static Template", - topic=CoachingTopic.PURPOSE, - phase=ConversationPhase.EXPLORATION, - system_prompt="You are an AI coaching assistant.", - template_text="This is a static template with no variables.", - ) - - # Assert - assert template.variables == [] - - -class TestPromptTemplateValidation: - """Test suite for template validation.""" - - def test_create_template_with_empty_name_raises_error(self) -> None: - """Test that empty name raises error.""" - # Arrange & Act & Assert - with pytest.raises(ValidationError): - PromptTemplate( - template_id=create_template_id(), - name="", - topic=CoachingTopic.CORE_VALUES, - phase=ConversationPhase.INTRODUCTION, - system_prompt="You are an AI coaching assistant.", - template_text="Test template", - ) - - def test_create_template_with_whitespace_only_name_raises_error( - self, - ) -> None: - """Test that whitespace-only name raises error.""" - # Arrange & Act & Assert - with pytest.raises(ValidationError): - PromptTemplate( - template_id=create_template_id(), - name=" ", - topic=CoachingTopic.CORE_VALUES, - phase=ConversationPhase.INTRODUCTION, - system_prompt="You are an AI coaching assistant.", - template_text="Test template", - ) - - def test_create_template_with_invalid_variable_name_raises_error( - self, - ) -> None: - """Test that invalid variable names raise error.""" - # Arrange & Act & Assert - with pytest.raises(ValidationError, match="not a valid Python identifier"): - PromptTemplate( - template_id=create_template_id(), - name="Test Template", - topic=CoachingTopic.CORE_VALUES, - phase=ConversationPhase.INTRODUCTION, - system_prompt="You are an AI coaching assistant.", - template_text="Hello {user-name}", - variables=["user-name"], # Invalid identifier - ) - - -class TestPromptTemplateRendering: - """Test suite for template rendering.""" - - @pytest.fixture - def template(self) -> PromptTemplate: - """Fixture providing a test template.""" - return PromptTemplate( - template_id=create_template_id(), - name="Personalized Greeting", - topic=CoachingTopic.CORE_VALUES, - phase=ConversationPhase.INTRODUCTION, - system_prompt="You are an AI coaching assistant.", - template_text="Hello {name}, welcome to {topic} coaching!", - variables=["name", "topic"], - ) - - def test_render_template_with_all_variables(self, template: PromptTemplate) -> None: - """Test rendering template with all variables provided.""" - # Act - result = template.render(name="John", topic="Core Values") - - # Assert - assert result == "Hello John, welcome to Core Values coaching!" - - def test_render_template_with_missing_variables_raises_error( - self, template: PromptTemplate - ) -> None: - """Test that missing variables raise error.""" - # Act & Assert - with pytest.raises(ValueError, match="Missing required variables"): - template.render(name="John") # Missing 'topic' - - def test_render_template_with_no_variables(self) -> None: - """Test rendering template without variables.""" - # Arrange - template = PromptTemplate( - template_id=create_template_id(), - name="Static", - topic=CoachingTopic.PURPOSE, - phase=ConversationPhase.EXPLORATION, - system_prompt="You are an AI coaching assistant.", - template_text="This has no variables.", - ) - - # Act - result = template.render() - - # Assert - assert result == "This has no variables." - - -class TestPromptTemplateActivation: - """Test suite for template activation.""" - - @pytest.fixture - def template(self) -> PromptTemplate: - """Fixture providing a test template.""" - return PromptTemplate( - template_id=create_template_id(), - name="Test Template", - topic=CoachingTopic.CORE_VALUES, - phase=ConversationPhase.INTRODUCTION, - system_prompt="You are an AI coaching assistant.", - template_text="Test {var}", - variables=["var"], - ) - - def test_deactivate_active_template(self, template: PromptTemplate) -> None: - """Test deactivating an active template.""" - # Arrange - assert template.is_active is True - - # Act - template.deactivate() - - # Assert - assert template.is_active is False - - def test_deactivate_inactive_template_raises_error(self, template: PromptTemplate) -> None: - """Test that deactivating inactive template raises error.""" - # Arrange - template.deactivate() - - # Act & Assert - with pytest.raises(ValueError, match="already inactive"): - template.deactivate() - - def test_activate_inactive_template(self, template: PromptTemplate) -> None: - """Test activating an inactive template.""" - # Arrange - template.deactivate() - - # Act - template.activate() - - # Assert - assert template.is_active is True - - def test_activate_active_template_raises_error(self, template: PromptTemplate) -> None: - """Test that activating active template raises error.""" - # Act & Assert - with pytest.raises(ValueError, match="already active"): - template.activate() - - -class TestPromptTemplateVersioning: - """Test suite for template versioning.""" - - @pytest.fixture - def template(self) -> PromptTemplate: - """Fixture providing a test template.""" - return PromptTemplate( - template_id=create_template_id(), - name="Versioned Template", - topic=CoachingTopic.GOALS, - phase=ConversationPhase.DEEPENING, - system_prompt="You are an AI coaching assistant.", - template_text="Original text with {var}", - variables=["var"], - ) - - def test_create_new_version_increments_version_number(self, template: PromptTemplate) -> None: - """Test that creating new version increments version.""" - # Arrange - assert template.version == 1 - - # Act - template.create_new_version("Updated text with {var}", ["var"]) - - # Assert - assert template.version == 2 - - def test_create_new_version_updates_template_text(self, template: PromptTemplate) -> None: - """Test that new version updates template text.""" - # Act - new_text = "New text with {var} and {other}" - template.create_new_version(new_text, ["var", "other"]) - - # Assert - assert template.template_text == new_text - assert template.variables == ["var", "other"] - - def test_create_new_version_with_empty_text_raises_error( - self, template: PromptTemplate - ) -> None: - """Test that empty new text raises error.""" - # Act & Assert - with pytest.raises(ValueError, match="cannot be empty"): - template.create_new_version("", []) - - -class TestPromptTemplateUtilityMethods: - """Test suite for utility methods.""" - - def test_get_variable_placeholders(self) -> None: - """Test getting variable placeholders.""" - # Arrange - template = PromptTemplate( - template_id=create_template_id(), - name="Test", - topic=CoachingTopic.VISION, - phase=ConversationPhase.SYNTHESIS, - system_prompt="You are an AI coaching assistant.", - template_text="Test {name} and {value}", - variables=["name", "value"], - ) - - # Act - placeholders = template.get_variable_placeholders() - - # Assert - assert placeholders == ["{name}", "{value}"] - - def test_get_variable_placeholders_empty(self) -> None: - """Test getting placeholders for template with no variables.""" - # Arrange - template = PromptTemplate( - template_id=create_template_id(), - name="Static", - topic=CoachingTopic.PURPOSE, - phase=ConversationPhase.EXPLORATION, - system_prompt="You are an AI coaching assistant.", - template_text="No variables here", - ) - - # Act - placeholders = template.get_variable_placeholders() - - # Assert - assert placeholders == [] +"""Unit tests for PromptTemplate aggregate root.""" + +import pytest +from pydantic import ValidationError + +from coaching.src.core.constants import CoachingTopic, ConversationPhase +from coaching.src.core.types import create_template_id +from coaching.src.domain.entities.prompt_template import PromptTemplate + +pytestmark = pytest.mark.unit + + +class TestPromptTemplateCreation: + """Test suite for PromptTemplate creation.""" + + def test_create_template_with_required_fields(self) -> None: + """Test creating template with required fields.""" + # Arrange & Act + template = PromptTemplate( + template_id=create_template_id(), + name="Core Values Introduction", + topic=CoachingTopic.CORE_VALUES, + phase=ConversationPhase.INTRODUCTION, + system_prompt="You are an AI coaching assistant.", + template_text="Hello {name}, let's explore your core values.", + variables=["name"], + ) + + # Assert + assert template.name == "Core Values Introduction" + assert template.topic == CoachingTopic.CORE_VALUES + assert template.phase == ConversationPhase.INTRODUCTION + assert template.variables == ["name"] + assert template.version == 1 + assert template.is_active is True + + def test_create_template_with_no_variables(self) -> None: + """Test creating template without variables.""" + # Arrange & Act + template = PromptTemplate( + template_id=create_template_id(), + name="Static Template", + topic=CoachingTopic.PURPOSE, + phase=ConversationPhase.EXPLORATION, + system_prompt="You are an AI coaching assistant.", + template_text="This is a static template with no variables.", + ) + + # Assert + assert template.variables == [] + + +class TestPromptTemplateValidation: + """Test suite for template validation.""" + + def test_create_template_with_empty_name_raises_error(self) -> None: + """Test that empty name raises error.""" + # Arrange & Act & Assert + with pytest.raises(ValidationError): + PromptTemplate( + template_id=create_template_id(), + name="", + topic=CoachingTopic.CORE_VALUES, + phase=ConversationPhase.INTRODUCTION, + system_prompt="You are an AI coaching assistant.", + template_text="Test template", + ) + + def test_create_template_with_whitespace_only_name_raises_error( + self, + ) -> None: + """Test that whitespace-only name raises error.""" + # Arrange & Act & Assert + with pytest.raises(ValidationError): + PromptTemplate( + template_id=create_template_id(), + name=" ", + topic=CoachingTopic.CORE_VALUES, + phase=ConversationPhase.INTRODUCTION, + system_prompt="You are an AI coaching assistant.", + template_text="Test template", + ) + + def test_create_template_with_invalid_variable_name_raises_error( + self, + ) -> None: + """Test that invalid variable names raise error.""" + # Arrange & Act & Assert + with pytest.raises(ValidationError, match="not a valid Python identifier"): + PromptTemplate( + template_id=create_template_id(), + name="Test Template", + topic=CoachingTopic.CORE_VALUES, + phase=ConversationPhase.INTRODUCTION, + system_prompt="You are an AI coaching assistant.", + template_text="Hello {user-name}", + variables=["user-name"], # Invalid identifier + ) + + +class TestPromptTemplateRendering: + """Test suite for template rendering.""" + + @pytest.fixture + def template(self) -> PromptTemplate: + """Fixture providing a test template.""" + return PromptTemplate( + template_id=create_template_id(), + name="Personalized Greeting", + topic=CoachingTopic.CORE_VALUES, + phase=ConversationPhase.INTRODUCTION, + system_prompt="You are an AI coaching assistant.", + template_text="Hello {name}, welcome to {topic} coaching!", + variables=["name", "topic"], + ) + + def test_render_template_with_all_variables(self, template: PromptTemplate) -> None: + """Test rendering template with all variables provided.""" + # Act + result = template.render(name="John", topic="Core Values") + + # Assert + assert result == "Hello John, welcome to Core Values coaching!" + + def test_render_template_with_missing_variables_raises_error( + self, template: PromptTemplate + ) -> None: + """Test that missing variables raise error.""" + # Act & Assert + with pytest.raises(ValueError, match="Missing required variables"): + template.render(name="John") # Missing 'topic' + + def test_render_template_with_no_variables(self) -> None: + """Test rendering template without variables.""" + # Arrange + template = PromptTemplate( + template_id=create_template_id(), + name="Static", + topic=CoachingTopic.PURPOSE, + phase=ConversationPhase.EXPLORATION, + system_prompt="You are an AI coaching assistant.", + template_text="This has no variables.", + ) + + # Act + result = template.render() + + # Assert + assert result == "This has no variables." + + +class TestPromptTemplateActivation: + """Test suite for template activation.""" + + @pytest.fixture + def template(self) -> PromptTemplate: + """Fixture providing a test template.""" + return PromptTemplate( + template_id=create_template_id(), + name="Test Template", + topic=CoachingTopic.CORE_VALUES, + phase=ConversationPhase.INTRODUCTION, + system_prompt="You are an AI coaching assistant.", + template_text="Test {var}", + variables=["var"], + ) + + def test_deactivate_active_template(self, template: PromptTemplate) -> None: + """Test deactivating an active template.""" + # Arrange + assert template.is_active is True + + # Act + template.deactivate() + + # Assert + assert template.is_active is False + + def test_deactivate_inactive_template_raises_error(self, template: PromptTemplate) -> None: + """Test that deactivating inactive template raises error.""" + # Arrange + template.deactivate() + + # Act & Assert + with pytest.raises(ValueError, match="already inactive"): + template.deactivate() + + def test_activate_inactive_template(self, template: PromptTemplate) -> None: + """Test activating an inactive template.""" + # Arrange + template.deactivate() + + # Act + template.activate() + + # Assert + assert template.is_active is True + + def test_activate_active_template_raises_error(self, template: PromptTemplate) -> None: + """Test that activating active template raises error.""" + # Act & Assert + with pytest.raises(ValueError, match="already active"): + template.activate() + + +class TestPromptTemplateVersioning: + """Test suite for template versioning.""" + + @pytest.fixture + def template(self) -> PromptTemplate: + """Fixture providing a test template.""" + return PromptTemplate( + template_id=create_template_id(), + name="Versioned Template", + topic=CoachingTopic.GOALS, + phase=ConversationPhase.DEEPENING, + system_prompt="You are an AI coaching assistant.", + template_text="Original text with {var}", + variables=["var"], + ) + + def test_create_new_version_increments_version_number(self, template: PromptTemplate) -> None: + """Test that creating new version increments version.""" + # Arrange + assert template.version == 1 + + # Act + template.create_new_version("Updated text with {var}", ["var"]) + + # Assert + assert template.version == 2 + + def test_create_new_version_updates_template_text(self, template: PromptTemplate) -> None: + """Test that new version updates template text.""" + # Act + new_text = "New text with {var} and {other}" + template.create_new_version(new_text, ["var", "other"]) + + # Assert + assert template.template_text == new_text + assert template.variables == ["var", "other"] + + def test_create_new_version_with_empty_text_raises_error( + self, template: PromptTemplate + ) -> None: + """Test that empty new text raises error.""" + # Act & Assert + with pytest.raises(ValueError, match="cannot be empty"): + template.create_new_version("", []) + + +class TestPromptTemplateUtilityMethods: + """Test suite for utility methods.""" + + def test_get_variable_placeholders(self) -> None: + """Test getting variable placeholders.""" + # Arrange + template = PromptTemplate( + template_id=create_template_id(), + name="Test", + topic=CoachingTopic.VISION, + phase=ConversationPhase.SYNTHESIS, + system_prompt="You are an AI coaching assistant.", + template_text="Test {name} and {value}", + variables=["name", "value"], + ) + + # Act + placeholders = template.get_variable_placeholders() + + # Assert + assert placeholders == ["{name}", "{value}"] + + def test_get_variable_placeholders_empty(self) -> None: + """Test getting placeholders for template with no variables.""" + # Arrange + template = PromptTemplate( + template_id=create_template_id(), + name="Static", + topic=CoachingTopic.PURPOSE, + phase=ConversationPhase.EXPLORATION, + system_prompt="You are an AI coaching assistant.", + template_text="No variables here", + ) + + # Act + placeholders = template.get_variable_placeholders() + + # Assert + assert placeholders == [] diff --git a/coaching/tests/unit/domain/events/test_analysis_events.py b/coaching/tests/unit/domain/events/test_analysis_events.py index 65132bfd..96625c92 100644 --- a/coaching/tests/unit/domain/events/test_analysis_events.py +++ b/coaching/tests/unit/domain/events/test_analysis_events.py @@ -1,6 +1,7 @@ """Unit tests for analysis domain events.""" import pytest + from coaching.src.core.constants import AnalysisType from coaching.src.domain.events.analysis_events import ( AnalysisCompleted, diff --git a/coaching/tests/unit/domain/events/test_base_event.py b/coaching/tests/unit/domain/events/test_base_event.py index 6b34c53a..0db9658c 100644 --- a/coaching/tests/unit/domain/events/test_base_event.py +++ b/coaching/tests/unit/domain/events/test_base_event.py @@ -1,218 +1,219 @@ -"""Unit tests for base DomainEvent.""" - -from datetime import UTC, datetime - -import pytest -from coaching.src.domain.events.base_event import DomainEvent - - -class TestDomainEventCreation: - """Test suite for DomainEvent creation and initialization.""" - - def test_create_event_with_required_fields(self) -> None: - """Test creating event with only required fields.""" - event = DomainEvent( - event_type="TestEvent", - aggregate_id="test-123", - aggregate_type="TestAggregate", - ) - - assert event.event_type == "TestEvent" - assert event.aggregate_id == "test-123" - assert event.aggregate_type == "TestAggregate" - assert event.event_id is not None # Auto-generated UUID - assert isinstance(event.occurred_at, datetime) - assert event.correlation_id is None - assert event.causation_id is None - assert event.metadata == {} - - def test_create_event_with_all_fields(self) -> None: - """Test creating event with all fields.""" - occurred_at = datetime.now(UTC) - event = DomainEvent( - event_id="custom-event-id", - event_type="TestEvent", - aggregate_id="test-123", - aggregate_type="TestAggregate", - occurred_at=occurred_at, - correlation_id="corr-456", - causation_id="cause-789", - metadata={"key": "value"}, - ) - - assert event.event_id == "custom-event-id" - assert event.correlation_id == "corr-456" - assert event.causation_id == "cause-789" - assert event.metadata == {"key": "value"} - assert event.occurred_at == occurred_at - - def test_event_id_auto_generation(self) -> None: - """Test that event_id is auto-generated as UUID.""" - event1 = DomainEvent( - event_type="TestEvent", - aggregate_id="test-123", - aggregate_type="TestAggregate", - ) - event2 = DomainEvent( - event_type="TestEvent", - aggregate_id="test-456", - aggregate_type="TestAggregate", - ) - - assert event1.event_id != event2.event_id - assert len(event1.event_id) == 36 # UUID format - - -class TestDomainEventImmutability: - """Test suite for event immutability.""" - - def test_event_is_frozen(self) -> None: - """Test that events cannot be modified after creation.""" - event = DomainEvent( - event_type="TestEvent", - aggregate_id="test-123", - aggregate_type="TestAggregate", - ) - - with pytest.raises(Exception): # Pydantic raises ValidationError for frozen models - event.event_type = "ModifiedEvent" # type: ignore - - def test_metadata_is_not_frozen(self) -> None: - """Test that metadata dict can be modified (mutable field).""" - event = DomainEvent( - event_type="TestEvent", - aggregate_id="test-123", - aggregate_type="TestAggregate", - metadata={"initial": "value"}, - ) - - # Metadata is a dict, so it can be modified (not frozen) - event.metadata["new_key"] = "new_value" - assert event.metadata["new_key"] == "new_value" - - -class TestDomainEventSerialization: - """Test suite for event serialization and deserialization.""" - - def test_to_dict(self) -> None: - """Test converting event to dictionary.""" - event = DomainEvent( - event_type="TestEvent", - aggregate_id="test-123", - aggregate_type="TestAggregate", - correlation_id="corr-456", - metadata={"key": "value"}, - ) - - data = event.to_dict() - - assert data["event_type"] == "TestEvent" - assert data["aggregate_id"] == "test-123" - assert data["aggregate_type"] == "TestAggregate" - assert data["correlation_id"] == "corr-456" - assert data["metadata"] == {"key": "value"} - assert "occurred_at" in data - assert isinstance(data["occurred_at"], str) # ISO format - - def test_to_json(self) -> None: - """Test converting event to JSON string.""" - event = DomainEvent( - event_type="TestEvent", - aggregate_id="test-123", - aggregate_type="TestAggregate", - ) - - json_str = event.to_json() - - assert isinstance(json_str, str) - assert "TestEvent" in json_str - assert "test-123" in json_str - - def test_from_dict(self) -> None: - """Test reconstructing event from dictionary.""" - original = DomainEvent( - event_id="event-123", - event_type="TestEvent", - aggregate_id="agg-456", - aggregate_type="TestAggregate", - correlation_id="corr-789", - ) - - data = original.to_dict() - reconstructed = DomainEvent.from_dict(data) - - assert reconstructed.event_id == original.event_id - assert reconstructed.event_type == original.event_type - assert reconstructed.aggregate_id == original.aggregate_id - assert reconstructed.correlation_id == original.correlation_id - - def test_from_dict_with_iso_timestamp(self) -> None: - """Test deserializing event with ISO format timestamp.""" - data = { - "event_id": "event-123", - "event_type": "TestEvent", - "aggregate_id": "agg-456", - "aggregate_type": "TestAggregate", - "occurred_at": "2025-10-09T17:00:00+00:00", - "correlation_id": None, - "causation_id": None, - "metadata": {}, - } - - event = DomainEvent.from_dict(data) - - assert isinstance(event.occurred_at, datetime) - assert event.occurred_at.tzinfo is not None # Has timezone info - - -class TestDomainEventTraceability: - """Test suite for correlation and causation tracking.""" - - def test_correlation_id_propagation(self) -> None: - """Test that correlation_id can be set for tracing.""" - correlation_id = "trace-correlate-123" - - event1 = DomainEvent( - event_type="Event1", - aggregate_id="agg-1", - aggregate_type="Aggregate", - correlation_id=correlation_id, - ) - - event2 = DomainEvent( - event_type="Event2", - aggregate_id="agg-2", - aggregate_type="Aggregate", - correlation_id=correlation_id, - causation_id=event1.event_id, - ) - - assert event1.correlation_id == correlation_id - assert event2.correlation_id == correlation_id - assert event2.causation_id == event1.event_id - - def test_causation_chain(self) -> None: - """Test tracking causation chain across events.""" - event1 = DomainEvent( - event_type="InitialEvent", - aggregate_id="agg-1", - aggregate_type="Aggregate", - ) - - event2 = DomainEvent( - event_type="DerivedEvent", - aggregate_id="agg-1", - aggregate_type="Aggregate", - causation_id=event1.event_id, - ) - - event3 = DomainEvent( - event_type="FinalEvent", - aggregate_id="agg-1", - aggregate_type="Aggregate", - causation_id=event2.event_id, - ) - - # Chain: event1 -> event2 -> event3 - assert event2.causation_id == event1.event_id - assert event3.causation_id == event2.event_id +"""Unit tests for base DomainEvent.""" + +from datetime import UTC, datetime + +import pytest + +from coaching.src.domain.events.base_event import DomainEvent + + +class TestDomainEventCreation: + """Test suite for DomainEvent creation and initialization.""" + + def test_create_event_with_required_fields(self) -> None: + """Test creating event with only required fields.""" + event = DomainEvent( + event_type="TestEvent", + aggregate_id="test-123", + aggregate_type="TestAggregate", + ) + + assert event.event_type == "TestEvent" + assert event.aggregate_id == "test-123" + assert event.aggregate_type == "TestAggregate" + assert event.event_id is not None # Auto-generated UUID + assert isinstance(event.occurred_at, datetime) + assert event.correlation_id is None + assert event.causation_id is None + assert event.metadata == {} + + def test_create_event_with_all_fields(self) -> None: + """Test creating event with all fields.""" + occurred_at = datetime.now(UTC) + event = DomainEvent( + event_id="custom-event-id", + event_type="TestEvent", + aggregate_id="test-123", + aggregate_type="TestAggregate", + occurred_at=occurred_at, + correlation_id="corr-456", + causation_id="cause-789", + metadata={"key": "value"}, + ) + + assert event.event_id == "custom-event-id" + assert event.correlation_id == "corr-456" + assert event.causation_id == "cause-789" + assert event.metadata == {"key": "value"} + assert event.occurred_at == occurred_at + + def test_event_id_auto_generation(self) -> None: + """Test that event_id is auto-generated as UUID.""" + event1 = DomainEvent( + event_type="TestEvent", + aggregate_id="test-123", + aggregate_type="TestAggregate", + ) + event2 = DomainEvent( + event_type="TestEvent", + aggregate_id="test-456", + aggregate_type="TestAggregate", + ) + + assert event1.event_id != event2.event_id + assert len(event1.event_id) == 36 # UUID format + + +class TestDomainEventImmutability: + """Test suite for event immutability.""" + + def test_event_is_frozen(self) -> None: + """Test that events cannot be modified after creation.""" + event = DomainEvent( + event_type="TestEvent", + aggregate_id="test-123", + aggregate_type="TestAggregate", + ) + + with pytest.raises(Exception): # Pydantic raises ValidationError for frozen models + event.event_type = "ModifiedEvent" # type: ignore + + def test_metadata_is_not_frozen(self) -> None: + """Test that metadata dict can be modified (mutable field).""" + event = DomainEvent( + event_type="TestEvent", + aggregate_id="test-123", + aggregate_type="TestAggregate", + metadata={"initial": "value"}, + ) + + # Metadata is a dict, so it can be modified (not frozen) + event.metadata["new_key"] = "new_value" + assert event.metadata["new_key"] == "new_value" + + +class TestDomainEventSerialization: + """Test suite for event serialization and deserialization.""" + + def test_to_dict(self) -> None: + """Test converting event to dictionary.""" + event = DomainEvent( + event_type="TestEvent", + aggregate_id="test-123", + aggregate_type="TestAggregate", + correlation_id="corr-456", + metadata={"key": "value"}, + ) + + data = event.to_dict() + + assert data["event_type"] == "TestEvent" + assert data["aggregate_id"] == "test-123" + assert data["aggregate_type"] == "TestAggregate" + assert data["correlation_id"] == "corr-456" + assert data["metadata"] == {"key": "value"} + assert "occurred_at" in data + assert isinstance(data["occurred_at"], str) # ISO format + + def test_to_json(self) -> None: + """Test converting event to JSON string.""" + event = DomainEvent( + event_type="TestEvent", + aggregate_id="test-123", + aggregate_type="TestAggregate", + ) + + json_str = event.to_json() + + assert isinstance(json_str, str) + assert "TestEvent" in json_str + assert "test-123" in json_str + + def test_from_dict(self) -> None: + """Test reconstructing event from dictionary.""" + original = DomainEvent( + event_id="event-123", + event_type="TestEvent", + aggregate_id="agg-456", + aggregate_type="TestAggregate", + correlation_id="corr-789", + ) + + data = original.to_dict() + reconstructed = DomainEvent.from_dict(data) + + assert reconstructed.event_id == original.event_id + assert reconstructed.event_type == original.event_type + assert reconstructed.aggregate_id == original.aggregate_id + assert reconstructed.correlation_id == original.correlation_id + + def test_from_dict_with_iso_timestamp(self) -> None: + """Test deserializing event with ISO format timestamp.""" + data = { + "event_id": "event-123", + "event_type": "TestEvent", + "aggregate_id": "agg-456", + "aggregate_type": "TestAggregate", + "occurred_at": "2025-10-09T17:00:00+00:00", + "correlation_id": None, + "causation_id": None, + "metadata": {}, + } + + event = DomainEvent.from_dict(data) + + assert isinstance(event.occurred_at, datetime) + assert event.occurred_at.tzinfo is not None # Has timezone info + + +class TestDomainEventTraceability: + """Test suite for correlation and causation tracking.""" + + def test_correlation_id_propagation(self) -> None: + """Test that correlation_id can be set for tracing.""" + correlation_id = "trace-correlate-123" + + event1 = DomainEvent( + event_type="Event1", + aggregate_id="agg-1", + aggregate_type="Aggregate", + correlation_id=correlation_id, + ) + + event2 = DomainEvent( + event_type="Event2", + aggregate_id="agg-2", + aggregate_type="Aggregate", + correlation_id=correlation_id, + causation_id=event1.event_id, + ) + + assert event1.correlation_id == correlation_id + assert event2.correlation_id == correlation_id + assert event2.causation_id == event1.event_id + + def test_causation_chain(self) -> None: + """Test tracking causation chain across events.""" + event1 = DomainEvent( + event_type="InitialEvent", + aggregate_id="agg-1", + aggregate_type="Aggregate", + ) + + event2 = DomainEvent( + event_type="DerivedEvent", + aggregate_id="agg-1", + aggregate_type="Aggregate", + causation_id=event1.event_id, + ) + + event3 = DomainEvent( + event_type="FinalEvent", + aggregate_id="agg-1", + aggregate_type="Aggregate", + causation_id=event2.event_id, + ) + + # Chain: event1 -> event2 -> event3 + assert event2.causation_id == event1.event_id + assert event3.causation_id == event2.event_id diff --git a/coaching/tests/unit/domain/events/test_conversation_events.py b/coaching/tests/unit/domain/events/test_conversation_events.py index 6048ad87..2d05cf21 100644 --- a/coaching/tests/unit/domain/events/test_conversation_events.py +++ b/coaching/tests/unit/domain/events/test_conversation_events.py @@ -1,232 +1,233 @@ -"""Unit tests for conversation domain events.""" - -from datetime import UTC, datetime - -import pytest -from coaching.src.core.constants import CoachingTopic, ConversationPhase, MessageRole -from coaching.src.domain.events.conversation_events import ( - ConversationCompleted, - ConversationInitiated, - ConversationPaused, - ConversationResumed, - MessageAdded, - PhaseTransitioned, -) - -pytestmark = pytest.mark.unit - - -class TestConversationInitiated: - """Tests for ConversationInitiated event.""" - - def test_create_conversation_initiated_event(self) -> None: - """Test creating conversation initiated event.""" - event = ConversationInitiated( - aggregate_id="conv-123", - user_id="user-456", - tenant_id="tenant-789", - topic=CoachingTopic.CORE_VALUES, - initial_phase=ConversationPhase.INTRODUCTION, - ) - - assert event.event_type == "ConversationInitiated" - assert event.aggregate_type == "Conversation" - assert event.user_id == "user-456" - assert event.tenant_id == "tenant-789" - assert event.topic == CoachingTopic.CORE_VALUES - assert event.initial_phase == ConversationPhase.INTRODUCTION - - def test_event_is_immutable(self) -> None: - """Test that event cannot be modified.""" - event = ConversationInitiated( - aggregate_id="conv-123", - user_id="user-456", - tenant_id="tenant-789", - topic=CoachingTopic.PURPOSE, - initial_phase=ConversationPhase.INTRODUCTION, - ) - - with pytest.raises(Exception): - event.user_id = "different-user" # type: ignore - - def test_serialization(self) -> None: - """Test event can be serialized and deserialized.""" - event = ConversationInitiated( - aggregate_id="conv-123", - user_id="user-456", - tenant_id="tenant-789", - topic=CoachingTopic.VISION, - initial_phase=ConversationPhase.INTRODUCTION, - ) - - data = event.to_dict() - assert data["topic"] == "vision" - assert data["initial_phase"] == "introduction" - - -class TestMessageAdded: - """Tests for MessageAdded event.""" - - def test_create_user_message_event(self) -> None: - """Test creating message added event for user message.""" - event = MessageAdded( - aggregate_id="conv-123", - role=MessageRole.USER, - content_length=150, - message_index=5, - phase=ConversationPhase.EXPLORATION, - ) - - assert event.role == MessageRole.USER - assert event.content_length == 150 - assert event.message_index == 5 - assert event.phase == ConversationPhase.EXPLORATION - - def test_create_assistant_message_event(self) -> None: - """Test creating message added event for assistant message.""" - event = MessageAdded( - aggregate_id="conv-123", - role=MessageRole.ASSISTANT, - content_length=300, - message_index=6, - phase=ConversationPhase.EXPLORATION, - ) - - assert event.role == MessageRole.ASSISTANT - assert event.content_length == 300 - - def test_message_index_validation(self) -> None: - """Test that message_index must be non-negative.""" - with pytest.raises(Exception): # Pydantic validation error - MessageAdded( - aggregate_id="conv-123", - role=MessageRole.USER, - content_length=100, - message_index=-1, # Invalid - phase=ConversationPhase.INTRODUCTION, - ) - - -class TestPhaseTransitioned: - """Tests for PhaseTransitioned event.""" - - def test_create_phase_transition_event(self) -> None: - """Test creating phase transition event.""" - event = PhaseTransitioned( - aggregate_id="conv-123", - from_phase=ConversationPhase.INTRODUCTION, - to_phase=ConversationPhase.EXPLORATION, - reason="User provided initial values", - progress_percentage=20.0, - ) - - assert event.from_phase == ConversationPhase.INTRODUCTION - assert event.to_phase == ConversationPhase.EXPLORATION - assert event.reason == "User provided initial values" - assert event.progress_percentage == 20.0 - - def test_transition_without_reason(self) -> None: - """Test transition event without specific reason.""" - event = PhaseTransitioned( - aggregate_id="conv-123", - from_phase=ConversationPhase.EXPLORATION, - to_phase=ConversationPhase.DEEPENING, - progress_percentage=40.0, - ) - - assert event.reason is None - - def test_progress_validation(self) -> None: - """Test that progress must be within 0-100.""" - with pytest.raises(Exception): # Pydantic validation - PhaseTransitioned( - aggregate_id="conv-123", - from_phase=ConversationPhase.INTRODUCTION, - to_phase=ConversationPhase.EXPLORATION, - progress_percentage=150.0, # Invalid - ) - - -class TestConversationCompleted: - """Tests for ConversationCompleted event.""" - - def test_create_completed_event(self) -> None: - """Test creating conversation completed event.""" - event = ConversationCompleted( - aggregate_id="conv-123", - topic=CoachingTopic.GOALS, - total_messages=25, - duration_seconds=1800.5, - insights_count=12, - final_phase=ConversationPhase.COMPLETION, - ) - - assert event.topic == CoachingTopic.GOALS - assert event.total_messages == 25 - assert event.duration_seconds == 1800.5 - assert event.insights_count == 12 - assert event.final_phase == ConversationPhase.COMPLETION - - def test_message_count_validation(self) -> None: - """Test that total_messages must be non-negative.""" - with pytest.raises(Exception): - ConversationCompleted( - aggregate_id="conv-123", - topic=CoachingTopic.PURPOSE, - total_messages=-5, # Invalid - duration_seconds=1000.0, - insights_count=10, - final_phase=ConversationPhase.COMPLETION, - ) - - -class TestConversationPaused: - """Tests for ConversationPaused event.""" - - def test_create_paused_event(self) -> None: - """Test creating conversation paused event.""" - event = ConversationPaused( - aggregate_id="conv-123", - reason="User requested pause", - current_phase=ConversationPhase.DEEPENING, - message_count=15, - can_resume=True, - ) - - assert event.reason == "User requested pause" - assert event.current_phase == ConversationPhase.DEEPENING - assert event.message_count == 15 - assert event.can_resume is True - - def test_cannot_resume_scenario(self) -> None: - """Test paused event where resumption is not allowed.""" - event = ConversationPaused( - aggregate_id="conv-123", - reason="Session timeout", - current_phase=ConversationPhase.INTRODUCTION, - message_count=2, - can_resume=False, - ) - - assert event.can_resume is False - - -class TestConversationResumed: - """Tests for ConversationResumed event.""" - - def test_create_resumed_event(self) -> None: - """Test creating conversation resumed event.""" - paused_time = datetime(2025, 10, 9, 12, 0, 0, tzinfo=UTC) - - event = ConversationResumed( - aggregate_id="conv-123", - paused_at=paused_time, - paused_duration_seconds=3600.0, - resume_phase=ConversationPhase.DEEPENING, - message_count=15, - ) - - assert event.paused_at == paused_time - assert event.paused_duration_seconds == 3600.0 - assert event.resume_phase == ConversationPhase.DEEPENING - assert event.message_count == 15 +"""Unit tests for conversation domain events.""" + +from datetime import UTC, datetime + +import pytest + +from coaching.src.core.constants import CoachingTopic, ConversationPhase, MessageRole +from coaching.src.domain.events.conversation_events import ( + ConversationCompleted, + ConversationInitiated, + ConversationPaused, + ConversationResumed, + MessageAdded, + PhaseTransitioned, +) + +pytestmark = pytest.mark.unit + + +class TestConversationInitiated: + """Tests for ConversationInitiated event.""" + + def test_create_conversation_initiated_event(self) -> None: + """Test creating conversation initiated event.""" + event = ConversationInitiated( + aggregate_id="conv-123", + user_id="user-456", + tenant_id="tenant-789", + topic=CoachingTopic.CORE_VALUES, + initial_phase=ConversationPhase.INTRODUCTION, + ) + + assert event.event_type == "ConversationInitiated" + assert event.aggregate_type == "Conversation" + assert event.user_id == "user-456" + assert event.tenant_id == "tenant-789" + assert event.topic == CoachingTopic.CORE_VALUES + assert event.initial_phase == ConversationPhase.INTRODUCTION + + def test_event_is_immutable(self) -> None: + """Test that event cannot be modified.""" + event = ConversationInitiated( + aggregate_id="conv-123", + user_id="user-456", + tenant_id="tenant-789", + topic=CoachingTopic.PURPOSE, + initial_phase=ConversationPhase.INTRODUCTION, + ) + + with pytest.raises(Exception): + event.user_id = "different-user" # type: ignore + + def test_serialization(self) -> None: + """Test event can be serialized and deserialized.""" + event = ConversationInitiated( + aggregate_id="conv-123", + user_id="user-456", + tenant_id="tenant-789", + topic=CoachingTopic.VISION, + initial_phase=ConversationPhase.INTRODUCTION, + ) + + data = event.to_dict() + assert data["topic"] == "vision" + assert data["initial_phase"] == "introduction" + + +class TestMessageAdded: + """Tests for MessageAdded event.""" + + def test_create_user_message_event(self) -> None: + """Test creating message added event for user message.""" + event = MessageAdded( + aggregate_id="conv-123", + role=MessageRole.USER, + content_length=150, + message_index=5, + phase=ConversationPhase.EXPLORATION, + ) + + assert event.role == MessageRole.USER + assert event.content_length == 150 + assert event.message_index == 5 + assert event.phase == ConversationPhase.EXPLORATION + + def test_create_assistant_message_event(self) -> None: + """Test creating message added event for assistant message.""" + event = MessageAdded( + aggregate_id="conv-123", + role=MessageRole.ASSISTANT, + content_length=300, + message_index=6, + phase=ConversationPhase.EXPLORATION, + ) + + assert event.role == MessageRole.ASSISTANT + assert event.content_length == 300 + + def test_message_index_validation(self) -> None: + """Test that message_index must be non-negative.""" + with pytest.raises(Exception): # Pydantic validation error + MessageAdded( + aggregate_id="conv-123", + role=MessageRole.USER, + content_length=100, + message_index=-1, # Invalid + phase=ConversationPhase.INTRODUCTION, + ) + + +class TestPhaseTransitioned: + """Tests for PhaseTransitioned event.""" + + def test_create_phase_transition_event(self) -> None: + """Test creating phase transition event.""" + event = PhaseTransitioned( + aggregate_id="conv-123", + from_phase=ConversationPhase.INTRODUCTION, + to_phase=ConversationPhase.EXPLORATION, + reason="User provided initial values", + progress_percentage=20.0, + ) + + assert event.from_phase == ConversationPhase.INTRODUCTION + assert event.to_phase == ConversationPhase.EXPLORATION + assert event.reason == "User provided initial values" + assert event.progress_percentage == 20.0 + + def test_transition_without_reason(self) -> None: + """Test transition event without specific reason.""" + event = PhaseTransitioned( + aggregate_id="conv-123", + from_phase=ConversationPhase.EXPLORATION, + to_phase=ConversationPhase.DEEPENING, + progress_percentage=40.0, + ) + + assert event.reason is None + + def test_progress_validation(self) -> None: + """Test that progress must be within 0-100.""" + with pytest.raises(Exception): # Pydantic validation + PhaseTransitioned( + aggregate_id="conv-123", + from_phase=ConversationPhase.INTRODUCTION, + to_phase=ConversationPhase.EXPLORATION, + progress_percentage=150.0, # Invalid + ) + + +class TestConversationCompleted: + """Tests for ConversationCompleted event.""" + + def test_create_completed_event(self) -> None: + """Test creating conversation completed event.""" + event = ConversationCompleted( + aggregate_id="conv-123", + topic=CoachingTopic.GOALS, + total_messages=25, + duration_seconds=1800.5, + insights_count=12, + final_phase=ConversationPhase.COMPLETION, + ) + + assert event.topic == CoachingTopic.GOALS + assert event.total_messages == 25 + assert event.duration_seconds == 1800.5 + assert event.insights_count == 12 + assert event.final_phase == ConversationPhase.COMPLETION + + def test_message_count_validation(self) -> None: + """Test that total_messages must be non-negative.""" + with pytest.raises(Exception): + ConversationCompleted( + aggregate_id="conv-123", + topic=CoachingTopic.PURPOSE, + total_messages=-5, # Invalid + duration_seconds=1000.0, + insights_count=10, + final_phase=ConversationPhase.COMPLETION, + ) + + +class TestConversationPaused: + """Tests for ConversationPaused event.""" + + def test_create_paused_event(self) -> None: + """Test creating conversation paused event.""" + event = ConversationPaused( + aggregate_id="conv-123", + reason="User requested pause", + current_phase=ConversationPhase.DEEPENING, + message_count=15, + can_resume=True, + ) + + assert event.reason == "User requested pause" + assert event.current_phase == ConversationPhase.DEEPENING + assert event.message_count == 15 + assert event.can_resume is True + + def test_cannot_resume_scenario(self) -> None: + """Test paused event where resumption is not allowed.""" + event = ConversationPaused( + aggregate_id="conv-123", + reason="Session timeout", + current_phase=ConversationPhase.INTRODUCTION, + message_count=2, + can_resume=False, + ) + + assert event.can_resume is False + + +class TestConversationResumed: + """Tests for ConversationResumed event.""" + + def test_create_resumed_event(self) -> None: + """Test creating conversation resumed event.""" + paused_time = datetime(2025, 10, 9, 12, 0, 0, tzinfo=UTC) + + event = ConversationResumed( + aggregate_id="conv-123", + paused_at=paused_time, + paused_duration_seconds=3600.0, + resume_phase=ConversationPhase.DEEPENING, + message_count=15, + ) + + assert event.paused_at == paused_time + assert event.paused_duration_seconds == 3600.0 + assert event.resume_phase == ConversationPhase.DEEPENING + assert event.message_count == 15 diff --git a/coaching/tests/unit/domain/exceptions/test_base_exception.py b/coaching/tests/unit/domain/exceptions/test_base_exception.py index d396a744..b6f0b589 100644 --- a/coaching/tests/unit/domain/exceptions/test_base_exception.py +++ b/coaching/tests/unit/domain/exceptions/test_base_exception.py @@ -1,162 +1,163 @@ -"""Unit tests for base DomainException.""" - -import pytest -from coaching.src.domain.exceptions.base_exception import DomainException - - -class TestDomainExceptionCreation: - """Test suite for DomainException creation.""" - - def test_create_with_all_parameters(self) -> None: - """Test creating exception with all parameters.""" - exc = DomainException( - message="Test error occurred", - code="TEST_ERROR", - context={"key": "value", "count": 42}, - ) - - assert exc.message == "Test error occurred" - assert exc.code == "TEST_ERROR" - assert exc.context == {"key": "value", "count": 42} - - def test_create_without_context(self) -> None: - """Test creating exception without context.""" - exc = DomainException(message="Simple error", code="SIMPLE_ERROR") - - assert exc.message == "Simple error" - assert exc.code == "SIMPLE_ERROR" - assert exc.context == {} - - def test_exception_inherits_from_exception(self) -> None: - """Test that DomainException inherits from Exception.""" - exc = DomainException(message="Test", code="TEST") - - assert isinstance(exc, Exception) - assert isinstance(exc, DomainException) - - -class TestDomainExceptionStringRepresentation: - """Test suite for exception string representations.""" - - def test_str_without_context(self) -> None: - """Test __str__ without context.""" - exc = DomainException(message="Error message", code="ERROR_CODE") - - result = str(exc) - - assert result == "[ERROR_CODE] Error message" - - def test_str_with_context(self) -> None: - """Test __str__ with context.""" - exc = DomainException(message="Error message", code="ERROR_CODE", context={"id": "123"}) - - result = str(exc) - - assert "[ERROR_CODE]" in result - assert "Error message" in result - assert "Context:" in result - assert "'id': '123'" in result - - def test_repr(self) -> None: - """Test __repr__ for debugging.""" - exc = DomainException(message="Test error", code="TEST", context={"data": "value"}) - - result = repr(exc) - - assert "DomainException" in result or "DomainError" in result - assert "message='Test error'" in result - assert "code='TEST'" in result - assert "context=" in result - - -class TestDomainExceptionSerialization: - """Test suite for exception serialization.""" - - def test_to_dict(self) -> None: - """Test converting exception to dictionary.""" - exc = DomainException( - message="Test error", code="TEST_ERROR", context={"user_id": "user-123"} - ) - - data = exc.to_dict() - - assert data["error_type"] in ("DomainException", "DomainError") - assert data["message"] == "Test error" - assert data["code"] == "TEST_ERROR" - assert data["context"] == {"user_id": "user-123"} - - def test_to_dict_structure(self) -> None: - """Test that to_dict returns correct structure.""" - exc = DomainException(message="Error", code="ERR") - - data = exc.to_dict() - - assert "error_type" in data - assert "message" in data - assert "code" in data - assert "context" in data - - -class TestDomainExceptionRaising: - """Test suite for raising exceptions.""" - - def test_raise_and_catch(self) -> None: - """Test raising and catching domain exception.""" - with pytest.raises(DomainException) as exc_info: - raise DomainException(message="Test error", code="TEST") - - exc = exc_info.value - assert exc.message == "Test error" - assert exc.code == "TEST" - - def test_raise_with_context(self) -> None: - """Test raising exception with context data.""" - with pytest.raises(DomainException) as exc_info: - raise DomainException( - message="Validation failed", - code="VALIDATION_ERROR", - context={"field": "email", "reason": "invalid format"}, - ) - - exc = exc_info.value - assert exc.context["field"] == "email" - assert exc.context["reason"] == "invalid format" - - def test_catch_as_base_exception(self) -> None: - """Test catching domain exception as base Exception.""" - with pytest.raises(Exception): - raise DomainException(message="Error", code="ERR") - - -class TestDomainExceptionSubclassing: - """Test suite for creating exception subclasses.""" - - def test_create_custom_exception_subclass(self) -> None: - """Test creating custom exception that inherits from DomainException.""" - - class CustomException(DomainException): - def __init__(self, resource_id: str) -> None: - super().__init__( - message=f"Resource {resource_id} not found", - code="RESOURCE_NOT_FOUND", - context={"resource_id": resource_id}, - ) - - exc = CustomException(resource_id="res-123") - - assert exc.message == "Resource res-123 not found" - assert exc.code == "RESOURCE_NOT_FOUND" - assert exc.context["resource_id"] == "res-123" - assert isinstance(exc, DomainException) - - def test_subclass_to_dict_includes_type(self) -> None: - """Test that subclass type is included in to_dict.""" - - class SpecificError(DomainException): - def __init__(self) -> None: - super().__init__(message="Specific error", code="SPECIFIC") - - exc = SpecificError() - data = exc.to_dict() - - assert data["error_type"] == "SpecificError" +"""Unit tests for base DomainException.""" + +import pytest + +from coaching.src.domain.exceptions.base_exception import DomainException + + +class TestDomainExceptionCreation: + """Test suite for DomainException creation.""" + + def test_create_with_all_parameters(self) -> None: + """Test creating exception with all parameters.""" + exc = DomainException( + message="Test error occurred", + code="TEST_ERROR", + context={"key": "value", "count": 42}, + ) + + assert exc.message == "Test error occurred" + assert exc.code == "TEST_ERROR" + assert exc.context == {"key": "value", "count": 42} + + def test_create_without_context(self) -> None: + """Test creating exception without context.""" + exc = DomainException(message="Simple error", code="SIMPLE_ERROR") + + assert exc.message == "Simple error" + assert exc.code == "SIMPLE_ERROR" + assert exc.context == {} + + def test_exception_inherits_from_exception(self) -> None: + """Test that DomainException inherits from Exception.""" + exc = DomainException(message="Test", code="TEST") + + assert isinstance(exc, Exception) + assert isinstance(exc, DomainException) + + +class TestDomainExceptionStringRepresentation: + """Test suite for exception string representations.""" + + def test_str_without_context(self) -> None: + """Test __str__ without context.""" + exc = DomainException(message="Error message", code="ERROR_CODE") + + result = str(exc) + + assert result == "[ERROR_CODE] Error message" + + def test_str_with_context(self) -> None: + """Test __str__ with context.""" + exc = DomainException(message="Error message", code="ERROR_CODE", context={"id": "123"}) + + result = str(exc) + + assert "[ERROR_CODE]" in result + assert "Error message" in result + assert "Context:" in result + assert "'id': '123'" in result + + def test_repr(self) -> None: + """Test __repr__ for debugging.""" + exc = DomainException(message="Test error", code="TEST", context={"data": "value"}) + + result = repr(exc) + + assert "DomainException" in result or "DomainError" in result + assert "message='Test error'" in result + assert "code='TEST'" in result + assert "context=" in result + + +class TestDomainExceptionSerialization: + """Test suite for exception serialization.""" + + def test_to_dict(self) -> None: + """Test converting exception to dictionary.""" + exc = DomainException( + message="Test error", code="TEST_ERROR", context={"user_id": "user-123"} + ) + + data = exc.to_dict() + + assert data["error_type"] in ("DomainException", "DomainError") + assert data["message"] == "Test error" + assert data["code"] == "TEST_ERROR" + assert data["context"] == {"user_id": "user-123"} + + def test_to_dict_structure(self) -> None: + """Test that to_dict returns correct structure.""" + exc = DomainException(message="Error", code="ERR") + + data = exc.to_dict() + + assert "error_type" in data + assert "message" in data + assert "code" in data + assert "context" in data + + +class TestDomainExceptionRaising: + """Test suite for raising exceptions.""" + + def test_raise_and_catch(self) -> None: + """Test raising and catching domain exception.""" + with pytest.raises(DomainException) as exc_info: + raise DomainException(message="Test error", code="TEST") + + exc = exc_info.value + assert exc.message == "Test error" + assert exc.code == "TEST" + + def test_raise_with_context(self) -> None: + """Test raising exception with context data.""" + with pytest.raises(DomainException) as exc_info: + raise DomainException( + message="Validation failed", + code="VALIDATION_ERROR", + context={"field": "email", "reason": "invalid format"}, + ) + + exc = exc_info.value + assert exc.context["field"] == "email" + assert exc.context["reason"] == "invalid format" + + def test_catch_as_base_exception(self) -> None: + """Test catching domain exception as base Exception.""" + with pytest.raises(Exception): + raise DomainException(message="Error", code="ERR") + + +class TestDomainExceptionSubclassing: + """Test suite for creating exception subclasses.""" + + def test_create_custom_exception_subclass(self) -> None: + """Test creating custom exception that inherits from DomainException.""" + + class CustomException(DomainException): + def __init__(self, resource_id: str) -> None: + super().__init__( + message=f"Resource {resource_id} not found", + code="RESOURCE_NOT_FOUND", + context={"resource_id": resource_id}, + ) + + exc = CustomException(resource_id="res-123") + + assert exc.message == "Resource res-123 not found" + assert exc.code == "RESOURCE_NOT_FOUND" + assert exc.context["resource_id"] == "res-123" + assert isinstance(exc, DomainException) + + def test_subclass_to_dict_includes_type(self) -> None: + """Test that subclass type is included in to_dict.""" + + class SpecificError(DomainException): + def __init__(self) -> None: + super().__init__(message="Specific error", code="SPECIFIC") + + exc = SpecificError() + data = exc.to_dict() + + assert data["error_type"] == "SpecificError" diff --git a/coaching/tests/unit/domain/exceptions/test_conversation_exceptions.py b/coaching/tests/unit/domain/exceptions/test_conversation_exceptions.py index 050a01c2..3777e02f 100644 --- a/coaching/tests/unit/domain/exceptions/test_conversation_exceptions.py +++ b/coaching/tests/unit/domain/exceptions/test_conversation_exceptions.py @@ -1,219 +1,220 @@ -"""Unit tests for conversation domain exceptions.""" - -import pytest -from coaching.src.core.constants import ConversationPhase, ConversationStatus -from coaching.src.domain.exceptions.conversation_exceptions import ( - ConversationCompletionError, - ConversationNotActive, - ConversationNotFound, - ConversationTTLExpired, - InvalidMessageContent, - InvalidPhaseTransition, -) - -pytestmark = pytest.mark.unit - - -class TestConversationNotFound: - """Tests for ConversationNotFound exception.""" - - def test_create_with_conversation_id_only(self) -> None: - """Test creating exception with just conversation ID.""" - exc = ConversationNotFound(conversation_id="conv-123") - - assert exc.message == "Conversation 'conv-123' not found" - assert exc.code == "CONVERSATION_NOT_FOUND" - assert exc.context["conversation_id"] == "conv-123" - assert "tenant_id" not in exc.context - - def test_create_with_tenant_id(self) -> None: - """Test creating exception with tenant context.""" - exc = ConversationNotFound(conversation_id="conv-123", tenant_id="tenant-456") - - assert exc.context["conversation_id"] == "conv-123" - assert exc.context["tenant_id"] == "tenant-456" - - def test_raise_and_catch(self) -> None: - """Test raising and catching the exception.""" - with pytest.raises(ConversationNotFound) as exc_info: - raise ConversationNotFound(conversation_id="conv-999") - - exc = exc_info.value - assert "conv-999" in exc.message - - -class TestInvalidPhaseTransition: - """Tests for InvalidPhaseTransition exception.""" - - def test_create_with_basic_info(self) -> None: - """Test creating exception with basic transition info.""" - exc = InvalidPhaseTransition( - conversation_id="conv-123", - current_phase=ConversationPhase.INTRODUCTION, - target_phase=ConversationPhase.COMPLETION, - ) - - assert "introduction" in exc.message.lower() - assert "completion" in exc.message.lower() - assert exc.code == "INVALID_PHASE_TRANSITION" - assert exc.context["current_phase"] == "introduction" - assert exc.context["target_phase"] == "completion" - - def test_create_with_reason(self) -> None: - """Test creating exception with specific reason.""" - exc = InvalidPhaseTransition( - conversation_id="conv-123", - current_phase=ConversationPhase.EXPLORATION, - target_phase=ConversationPhase.SYNTHESIS, - reason="Not enough insights gathered", - ) - - assert "Not enough insights gathered" in exc.message - assert exc.context["reason"] == "Not enough insights gathered" - - def test_backward_transition_scenario(self) -> None: - """Test exception for backward phase transition.""" - exc = InvalidPhaseTransition( - conversation_id="conv-123", - current_phase=ConversationPhase.DEEPENING, - target_phase=ConversationPhase.EXPLORATION, - reason="Backward transitions not allowed", - ) - - assert "deepening" in exc.message.lower() - assert "exploration" in exc.message.lower() - - -class TestConversationNotActive: - """Tests for ConversationNotActive exception.""" - - def test_create_for_completed_conversation(self) -> None: - """Test exception when trying to modify completed conversation.""" - exc = ConversationNotActive( - conversation_id="conv-123", - current_status=ConversationStatus.COMPLETED, - operation="add message", - ) - - assert "add message" in exc.message - assert "completed" in exc.message.lower() - assert exc.code == "CONVERSATION_NOT_ACTIVE" - assert exc.context["current_status"] == "completed" - assert exc.context["operation"] == "add message" - - def test_create_for_paused_conversation(self) -> None: - """Test exception when trying to modify paused conversation.""" - exc = ConversationNotActive( - conversation_id="conv-123", - current_status=ConversationStatus.PAUSED, - operation="transition phase", - ) - - assert "paused" in exc.message.lower() - assert exc.context["current_status"] == "paused" - - def test_create_for_abandoned_conversation(self) -> None: - """Test exception for abandoned conversation.""" - exc = ConversationNotActive( - conversation_id="conv-123", - current_status=ConversationStatus.ABANDONED, - operation="resume", - ) - - assert exc.context["current_status"] == "abandoned" - - -class TestInvalidMessageContent: - """Tests for InvalidMessageContent exception.""" - - def test_create_with_single_validation_error(self) -> None: - """Test exception with single validation error.""" - exc = InvalidMessageContent( - conversation_id="conv-123", - validation_errors=["Message too short"], - content_length=5, - ) - - assert "Message too short" in exc.message - assert exc.code == "INVALID_MESSAGE_CONTENT" - assert exc.context["validation_errors"] == ["Message too short"] - assert exc.context["content_length"] == 5 - - def test_create_with_multiple_validation_errors(self) -> None: - """Test exception with multiple validation errors.""" - errors = ["Message too short", "Contains profanity", "Invalid characters"] - exc = InvalidMessageContent( - conversation_id="conv-123", validation_errors=errors, content_length=10 - ) - - assert "Message too short" in exc.message - assert "Contains profanity" in exc.message - assert len(exc.context["validation_errors"]) == 3 - - def test_create_without_content_length(self) -> None: - """Test exception without content length.""" - exc = InvalidMessageContent( - conversation_id="conv-123", validation_errors=["Invalid format"] - ) - - assert exc.context["content_length"] == 0 - - -class TestConversationTTLExpired: - """Tests for ConversationTTLExpired exception.""" - - def test_create_with_expiry_time(self) -> None: - """Test creating exception with expiry timestamp.""" - exc = ConversationTTLExpired(conversation_id="conv-123", expired_at="2025-10-09T10:00:00Z") - - assert "2025-10-09T10:00:00Z" in exc.message - assert exc.code == "CONVERSATION_TTL_EXPIRED" - assert exc.context["conversation_id"] == "conv-123" - assert exc.context["expired_at"] == "2025-10-09T10:00:00Z" - - def test_message_includes_expiry_time(self) -> None: - """Test that message clearly states when conversation expired.""" - expired_time = "2025-10-01T00:00:00Z" - exc = ConversationTTLExpired(conversation_id="conv-456", expired_at=expired_time) - - assert "expired" in exc.message.lower() - assert expired_time in exc.message - - -class TestConversationCompletionError: - """Tests for ConversationCompletionError exception.""" - - def test_create_with_single_missing_requirement(self) -> None: - """Test exception with single missing requirement.""" - exc = ConversationCompletionError( - conversation_id="conv-123", - missing_requirements=["Not in completion phase"], - progress=75.0, - ) - - assert "Not in completion phase" in exc.message - assert exc.code == "CONVERSATION_COMPLETION_ERROR" - assert exc.context["progress_percentage"] == 75.0 - - def test_create_with_multiple_missing_requirements(self) -> None: - """Test exception with multiple missing requirements.""" - missing = [ - "Minimum 10 messages required", - "Minimum 5 insights required", - "Must be in validation phase", - ] - exc = ConversationCompletionError( - conversation_id="conv-123", missing_requirements=missing, progress=50.0 - ) - - assert "Minimum 10 messages required" in exc.message - assert len(exc.context["missing_requirements"]) == 3 - assert exc.context["progress_percentage"] == 50.0 - - def test_create_without_progress(self) -> None: - """Test exception without progress percentage.""" - exc = ConversationCompletionError( - conversation_id="conv-123", missing_requirements=["Not ready"] - ) - - assert exc.context["progress_percentage"] == 0.0 +"""Unit tests for conversation domain exceptions.""" + +import pytest + +from coaching.src.core.constants import ConversationPhase, ConversationStatus +from coaching.src.domain.exceptions.conversation_exceptions import ( + ConversationCompletionError, + ConversationNotActive, + ConversationNotFound, + ConversationTTLExpired, + InvalidMessageContent, + InvalidPhaseTransition, +) + +pytestmark = pytest.mark.unit + + +class TestConversationNotFound: + """Tests for ConversationNotFound exception.""" + + def test_create_with_conversation_id_only(self) -> None: + """Test creating exception with just conversation ID.""" + exc = ConversationNotFound(conversation_id="conv-123") + + assert exc.message == "Conversation 'conv-123' not found" + assert exc.code == "CONVERSATION_NOT_FOUND" + assert exc.context["conversation_id"] == "conv-123" + assert "tenant_id" not in exc.context + + def test_create_with_tenant_id(self) -> None: + """Test creating exception with tenant context.""" + exc = ConversationNotFound(conversation_id="conv-123", tenant_id="tenant-456") + + assert exc.context["conversation_id"] == "conv-123" + assert exc.context["tenant_id"] == "tenant-456" + + def test_raise_and_catch(self) -> None: + """Test raising and catching the exception.""" + with pytest.raises(ConversationNotFound) as exc_info: + raise ConversationNotFound(conversation_id="conv-999") + + exc = exc_info.value + assert "conv-999" in exc.message + + +class TestInvalidPhaseTransition: + """Tests for InvalidPhaseTransition exception.""" + + def test_create_with_basic_info(self) -> None: + """Test creating exception with basic transition info.""" + exc = InvalidPhaseTransition( + conversation_id="conv-123", + current_phase=ConversationPhase.INTRODUCTION, + target_phase=ConversationPhase.COMPLETION, + ) + + assert "introduction" in exc.message.lower() + assert "completion" in exc.message.lower() + assert exc.code == "INVALID_PHASE_TRANSITION" + assert exc.context["current_phase"] == "introduction" + assert exc.context["target_phase"] == "completion" + + def test_create_with_reason(self) -> None: + """Test creating exception with specific reason.""" + exc = InvalidPhaseTransition( + conversation_id="conv-123", + current_phase=ConversationPhase.EXPLORATION, + target_phase=ConversationPhase.SYNTHESIS, + reason="Not enough insights gathered", + ) + + assert "Not enough insights gathered" in exc.message + assert exc.context["reason"] == "Not enough insights gathered" + + def test_backward_transition_scenario(self) -> None: + """Test exception for backward phase transition.""" + exc = InvalidPhaseTransition( + conversation_id="conv-123", + current_phase=ConversationPhase.DEEPENING, + target_phase=ConversationPhase.EXPLORATION, + reason="Backward transitions not allowed", + ) + + assert "deepening" in exc.message.lower() + assert "exploration" in exc.message.lower() + + +class TestConversationNotActive: + """Tests for ConversationNotActive exception.""" + + def test_create_for_completed_conversation(self) -> None: + """Test exception when trying to modify completed conversation.""" + exc = ConversationNotActive( + conversation_id="conv-123", + current_status=ConversationStatus.COMPLETED, + operation="add message", + ) + + assert "add message" in exc.message + assert "completed" in exc.message.lower() + assert exc.code == "CONVERSATION_NOT_ACTIVE" + assert exc.context["current_status"] == "completed" + assert exc.context["operation"] == "add message" + + def test_create_for_paused_conversation(self) -> None: + """Test exception when trying to modify paused conversation.""" + exc = ConversationNotActive( + conversation_id="conv-123", + current_status=ConversationStatus.PAUSED, + operation="transition phase", + ) + + assert "paused" in exc.message.lower() + assert exc.context["current_status"] == "paused" + + def test_create_for_abandoned_conversation(self) -> None: + """Test exception for abandoned conversation.""" + exc = ConversationNotActive( + conversation_id="conv-123", + current_status=ConversationStatus.ABANDONED, + operation="resume", + ) + + assert exc.context["current_status"] == "abandoned" + + +class TestInvalidMessageContent: + """Tests for InvalidMessageContent exception.""" + + def test_create_with_single_validation_error(self) -> None: + """Test exception with single validation error.""" + exc = InvalidMessageContent( + conversation_id="conv-123", + validation_errors=["Message too short"], + content_length=5, + ) + + assert "Message too short" in exc.message + assert exc.code == "INVALID_MESSAGE_CONTENT" + assert exc.context["validation_errors"] == ["Message too short"] + assert exc.context["content_length"] == 5 + + def test_create_with_multiple_validation_errors(self) -> None: + """Test exception with multiple validation errors.""" + errors = ["Message too short", "Contains profanity", "Invalid characters"] + exc = InvalidMessageContent( + conversation_id="conv-123", validation_errors=errors, content_length=10 + ) + + assert "Message too short" in exc.message + assert "Contains profanity" in exc.message + assert len(exc.context["validation_errors"]) == 3 + + def test_create_without_content_length(self) -> None: + """Test exception without content length.""" + exc = InvalidMessageContent( + conversation_id="conv-123", validation_errors=["Invalid format"] + ) + + assert exc.context["content_length"] == 0 + + +class TestConversationTTLExpired: + """Tests for ConversationTTLExpired exception.""" + + def test_create_with_expiry_time(self) -> None: + """Test creating exception with expiry timestamp.""" + exc = ConversationTTLExpired(conversation_id="conv-123", expired_at="2025-10-09T10:00:00Z") + + assert "2025-10-09T10:00:00Z" in exc.message + assert exc.code == "CONVERSATION_TTL_EXPIRED" + assert exc.context["conversation_id"] == "conv-123" + assert exc.context["expired_at"] == "2025-10-09T10:00:00Z" + + def test_message_includes_expiry_time(self) -> None: + """Test that message clearly states when conversation expired.""" + expired_time = "2025-10-01T00:00:00Z" + exc = ConversationTTLExpired(conversation_id="conv-456", expired_at=expired_time) + + assert "expired" in exc.message.lower() + assert expired_time in exc.message + + +class TestConversationCompletionError: + """Tests for ConversationCompletionError exception.""" + + def test_create_with_single_missing_requirement(self) -> None: + """Test exception with single missing requirement.""" + exc = ConversationCompletionError( + conversation_id="conv-123", + missing_requirements=["Not in completion phase"], + progress=75.0, + ) + + assert "Not in completion phase" in exc.message + assert exc.code == "CONVERSATION_COMPLETION_ERROR" + assert exc.context["progress_percentage"] == 75.0 + + def test_create_with_multiple_missing_requirements(self) -> None: + """Test exception with multiple missing requirements.""" + missing = [ + "Minimum 10 messages required", + "Minimum 5 insights required", + "Must be in validation phase", + ] + exc = ConversationCompletionError( + conversation_id="conv-123", missing_requirements=missing, progress=50.0 + ) + + assert "Minimum 10 messages required" in exc.message + assert len(exc.context["missing_requirements"]) == 3 + assert exc.context["progress_percentage"] == 50.0 + + def test_create_without_progress(self) -> None: + """Test exception without progress percentage.""" + exc = ConversationCompletionError( + conversation_id="conv-123", missing_requirements=["Not ready"] + ) + + assert exc.context["progress_percentage"] == 0.0 diff --git a/coaching/tests/unit/domain/services/test_alignment_calculator.py b/coaching/tests/unit/domain/services/test_alignment_calculator.py index 192735f0..27731186 100644 --- a/coaching/tests/unit/domain/services/test_alignment_calculator.py +++ b/coaching/tests/unit/domain/services/test_alignment_calculator.py @@ -1,375 +1,376 @@ -"""Unit tests for AlignmentCalculator domain service.""" - -import pytest -from coaching.src.domain.services.alignment_calculator import AlignmentCalculator - -pytestmark = pytest.mark.unit - - -class TestAlignmentCalculatorBasics: - """Test suite for basic alignment calculation.""" - - @pytest.fixture - def calculator(self) -> AlignmentCalculator: - """Fixture providing calculator instance.""" - return AlignmentCalculator() - - @pytest.fixture - def complete_business_context(self) -> dict: - """Fixture with complete business context.""" - return { - "purpose": "Transform healthcare delivery", - "values": ["integrity", "innovation", "compassion"], - "mission": "Provide accessible healthcare", - "vision": "World-class healthcare for all", - "strategy": "Patient-centered digital transformation", - "purpose_clarity": 90.0, - "mission_clarity": 85.0, - } - - @pytest.fixture - def complete_current_state(self) -> dict: - """Fixture with complete current state.""" - return { - "vision_clarity": 80.0, - "vision_adoption": 75.0, - "strategy_clarity": 85.0, - "execution_level": 80.0, - "operational_efficiency": 70.0, - "operational_effectiveness": 75.0, - "culture_values_match": 85.0, - "employee_engagement": 80.0, - } - - def test_calculate_alignment_with_complete_data( - self, - calculator: AlignmentCalculator, - complete_business_context: dict, - complete_current_state: dict, - ) -> None: - """Test alignment calculation with complete data.""" - # Act - result = calculator.calculate_alignment( - business_context=complete_business_context, - current_state=complete_current_state, - explanation="Strong alignment across all dimensions", - ) - - # Assert - assert result is not None - assert hasattr(result, "overall_score") - assert 0 <= result.overall_score <= 100 - assert result.confidence_level > 80 # Should be high with complete data - - def test_calculate_alignment_returns_valid_component_scores( - self, - calculator: AlignmentCalculator, - complete_business_context: dict, - complete_current_state: dict, - ) -> None: - """Test that component scores are valid.""" - # Act - result = calculator.calculate_alignment( - business_context=complete_business_context, - current_state=complete_current_state, - explanation="Testing component scores", - ) - - # Assert - assert 0 <= result.component_scores.vision_alignment <= 100 - assert 0 <= result.component_scores.strategy_alignment <= 100 - assert 0 <= result.component_scores.operations_alignment <= 100 - assert 0 <= result.component_scores.culture_alignment <= 100 - - def test_calculate_alignment_returns_valid_foundation_scores( - self, - calculator: AlignmentCalculator, - complete_business_context: dict, - complete_current_state: dict, - ) -> None: - """Test that foundation scores are valid.""" - # Act - result = calculator.calculate_alignment( - business_context=complete_business_context, - current_state=complete_current_state, - explanation="Testing foundation scores", - ) - - # Assert - assert 0 <= result.foundation_alignment.purpose_alignment <= 100 - assert 0 <= result.foundation_alignment.values_alignment <= 100 - assert 0 <= result.foundation_alignment.mission_alignment <= 100 - - -class TestAlignmentCalculatorComponentScoring: - """Test suite for component score calculations.""" - - @pytest.fixture - def calculator(self) -> AlignmentCalculator: - """Fixture providing calculator instance.""" - return AlignmentCalculator() - - def test_vision_alignment_with_missing_vision(self, calculator: AlignmentCalculator) -> None: - """Test vision scoring with missing vision data.""" - # Arrange - context = {} - state = {"vision_clarity": 80.0} - - # Act - score = calculator._score_vision_alignment(context, state) - - # Assert - assert score == 50.0 # Neutral score for missing data - - def test_vision_alignment_with_complete_data(self, calculator: AlignmentCalculator) -> None: - """Test vision scoring with complete data.""" - # Arrange - context = {"vision": "Our vision statement"} - state = {"vision_clarity": 80.0, "vision_adoption": 70.0} - - # Act - score = calculator._score_vision_alignment(context, state) - - # Assert - assert score == 75.0 # Average of 80 and 70 - - def test_operations_alignment_calculation(self, calculator: AlignmentCalculator) -> None: - """Test operations scoring logic.""" - # Arrange - context = {} - state = {"operational_efficiency": 80.0, "operational_effectiveness": 90.0} - - # Act - score = calculator._score_operations_alignment(context, state) - - # Assert - assert score == 85.0 # Average of 80 and 90 - - def test_culture_alignment_with_missing_values(self, calculator: AlignmentCalculator) -> None: - """Test culture scoring with missing values.""" - # Arrange - context = {} - state = {"culture_values_match": 80.0} - - # Act - score = calculator._score_culture_alignment(context, state) - - # Assert - assert score == 50.0 # Neutral for missing values - - -class TestAlignmentCalculatorFoundationScoring: - """Test suite for foundation score calculations.""" - - @pytest.fixture - def calculator(self) -> AlignmentCalculator: - """Fixture providing calculator instance.""" - return AlignmentCalculator() - - def test_purpose_alignment_with_missing_purpose(self, calculator: AlignmentCalculator) -> None: - """Test purpose scoring with missing purpose.""" - # Arrange - context = {} - - # Act - score = calculator._score_purpose_alignment(context) - - # Assert - assert score == 50.0 - - def test_purpose_alignment_with_clarity(self, calculator: AlignmentCalculator) -> None: - """Test purpose scoring with clarity data.""" - # Arrange - context = {"purpose": "Our purpose", "purpose_clarity": 85.0} - - # Act - score = calculator._score_purpose_alignment(context) - - # Assert - assert score == 85.0 - - def test_values_alignment_with_no_values(self, calculator: AlignmentCalculator) -> None: - """Test values scoring with no values.""" - # Arrange - context = {"values": []} - - # Act - score = calculator._score_values_alignment(context) - - # Assert - assert score == 50.0 # Returns neutral when values list is empty - - def test_values_alignment_with_few_values(self, calculator: AlignmentCalculator) -> None: - """Test values scoring with few values.""" - # Arrange - context = {"values": ["integrity", "innovation"]} - - # Act - score = calculator._score_values_alignment(context) - - # Assert - assert score == 60.0 - - def test_values_alignment_with_many_values(self, calculator: AlignmentCalculator) -> None: - """Test values scoring with good number of values.""" - # Arrange - context = {"values": ["integrity", "innovation", "compassion", "excellence"]} - - # Act - score = calculator._score_values_alignment(context) - - # Assert - assert score == 80.0 - - def test_mission_alignment_with_clarity(self, calculator: AlignmentCalculator) -> None: - """Test mission scoring with clarity.""" - # Arrange - context = {"mission": "Our mission", "mission_clarity": 90.0} - - # Act - score = calculator._score_mission_alignment(context) - - # Assert - assert score == 90.0 - - -class TestAlignmentCalculatorConfidence: - """Test suite for confidence calculations.""" - - @pytest.fixture - def calculator(self) -> AlignmentCalculator: - """Fixture providing calculator instance.""" - return AlignmentCalculator() - - def test_confidence_with_complete_data(self, calculator: AlignmentCalculator) -> None: - """Test confidence with all required fields.""" - # Arrange - context = { - "purpose": "Purpose", - "values": ["val1"], - "mission": "Mission", - "vision": "Vision", - "strategy": "Strategy", - } - state = {"key1": "val1", "key2": "val2"} - - # Act - confidence = calculator._calculate_confidence(context, state) - - # Assert - assert confidence > 80 # Should be high with complete data - - def test_confidence_with_minimal_data(self, calculator: AlignmentCalculator) -> None: - """Test confidence with minimal data.""" - # Arrange - context = {"purpose": "Purpose"} - state = {} - - # Act - confidence = calculator._calculate_confidence(context, state) - - # Assert - assert 50 <= confidence < 60 # Low but above minimum - - def test_confidence_has_minimum_floor(self, calculator: AlignmentCalculator) -> None: - """Test that confidence has minimum threshold.""" - # Arrange - context = {} - state = {} - - # Act - confidence = calculator._calculate_confidence(context, state) - - # Assert - assert confidence >= 50.0 # Minimum confidence level - - -class TestAlignmentCalculatorEdgeCases: - """Test suite for edge cases.""" - - @pytest.fixture - def calculator(self) -> AlignmentCalculator: - """Fixture providing calculator instance.""" - return AlignmentCalculator() - - def test_calculate_alignment_with_empty_context(self, calculator: AlignmentCalculator) -> None: - """Test calculation with empty context.""" - # Act - result = calculator.calculate_alignment( - business_context={}, - current_state={}, - explanation="Minimal data test case", - ) - - # Assert - assert result is not None - assert hasattr(result, "overall_score") - assert result.overall_score >= 0 - assert result.confidence_level >= 50 # Minimum confidence - - def test_calculate_alignment_with_extreme_high_scores( - self, calculator: AlignmentCalculator - ) -> None: - """Test calculation with very high input scores.""" - # Arrange - context = { - "purpose": "Purpose", - "values": ["val1", "val2", "val3"], - "mission": "Mission", - "purpose_clarity": 100.0, - "mission_clarity": 100.0, - } - state = { - "vision_clarity": 100.0, - "vision_adoption": 100.0, - "strategy_clarity": 100.0, - "execution_level": 100.0, - "operational_efficiency": 100.0, - "operational_effectiveness": 100.0, - "culture_values_match": 100.0, - "employee_engagement": 100.0, - } - - # Act - result = calculator.calculate_alignment( - business_context=context, - current_state=state, - explanation="Perfect alignment scenario", - ) - - # Assert - assert result.overall_score <= 100.0 # Should not exceed max - assert result.overall_score > 80.0 # Should be high - - def test_overall_score_weighted_correctly(self, calculator: AlignmentCalculator) -> None: - """Test that overall score uses correct weighting.""" - # Arrange - context = { - "purpose": "P", - "values": ["v1", "v2", "v3"], - "mission": "M", - "vision": "V", - "strategy": "S", - "purpose_clarity": 80.0, - "mission_clarity": 80.0, - } - state = { - "vision_clarity": 80.0, - "vision_adoption": 80.0, - "strategy_clarity": 80.0, - "execution_level": 80.0, - "operational_efficiency": 80.0, - "operational_effectiveness": 80.0, - "culture_values_match": 80.0, - "employee_engagement": 80.0, - } - - # Act - result = calculator.calculate_alignment( - business_context=context, - current_state=state, - explanation="Testing weighted average", - ) - - # Assert - Should be around 80 with uniform scores - assert 75.0 <= result.overall_score <= 85.0 +"""Unit tests for AlignmentCalculator domain service.""" + +import pytest + +from coaching.src.domain.services.alignment_calculator import AlignmentCalculator + +pytestmark = pytest.mark.unit + + +class TestAlignmentCalculatorBasics: + """Test suite for basic alignment calculation.""" + + @pytest.fixture + def calculator(self) -> AlignmentCalculator: + """Fixture providing calculator instance.""" + return AlignmentCalculator() + + @pytest.fixture + def complete_business_context(self) -> dict: + """Fixture with complete business context.""" + return { + "purpose": "Transform healthcare delivery", + "values": ["integrity", "innovation", "compassion"], + "mission": "Provide accessible healthcare", + "vision": "World-class healthcare for all", + "strategy": "Patient-centered digital transformation", + "purpose_clarity": 90.0, + "mission_clarity": 85.0, + } + + @pytest.fixture + def complete_current_state(self) -> dict: + """Fixture with complete current state.""" + return { + "vision_clarity": 80.0, + "vision_adoption": 75.0, + "strategy_clarity": 85.0, + "execution_level": 80.0, + "operational_efficiency": 70.0, + "operational_effectiveness": 75.0, + "culture_values_match": 85.0, + "employee_engagement": 80.0, + } + + def test_calculate_alignment_with_complete_data( + self, + calculator: AlignmentCalculator, + complete_business_context: dict, + complete_current_state: dict, + ) -> None: + """Test alignment calculation with complete data.""" + # Act + result = calculator.calculate_alignment( + business_context=complete_business_context, + current_state=complete_current_state, + explanation="Strong alignment across all dimensions", + ) + + # Assert + assert result is not None + assert hasattr(result, "overall_score") + assert 0 <= result.overall_score <= 100 + assert result.confidence_level > 80 # Should be high with complete data + + def test_calculate_alignment_returns_valid_component_scores( + self, + calculator: AlignmentCalculator, + complete_business_context: dict, + complete_current_state: dict, + ) -> None: + """Test that component scores are valid.""" + # Act + result = calculator.calculate_alignment( + business_context=complete_business_context, + current_state=complete_current_state, + explanation="Testing component scores", + ) + + # Assert + assert 0 <= result.component_scores.vision_alignment <= 100 + assert 0 <= result.component_scores.strategy_alignment <= 100 + assert 0 <= result.component_scores.operations_alignment <= 100 + assert 0 <= result.component_scores.culture_alignment <= 100 + + def test_calculate_alignment_returns_valid_foundation_scores( + self, + calculator: AlignmentCalculator, + complete_business_context: dict, + complete_current_state: dict, + ) -> None: + """Test that foundation scores are valid.""" + # Act + result = calculator.calculate_alignment( + business_context=complete_business_context, + current_state=complete_current_state, + explanation="Testing foundation scores", + ) + + # Assert + assert 0 <= result.foundation_alignment.purpose_alignment <= 100 + assert 0 <= result.foundation_alignment.values_alignment <= 100 + assert 0 <= result.foundation_alignment.mission_alignment <= 100 + + +class TestAlignmentCalculatorComponentScoring: + """Test suite for component score calculations.""" + + @pytest.fixture + def calculator(self) -> AlignmentCalculator: + """Fixture providing calculator instance.""" + return AlignmentCalculator() + + def test_vision_alignment_with_missing_vision(self, calculator: AlignmentCalculator) -> None: + """Test vision scoring with missing vision data.""" + # Arrange + context = {} + state = {"vision_clarity": 80.0} + + # Act + score = calculator._score_vision_alignment(context, state) + + # Assert + assert score == 50.0 # Neutral score for missing data + + def test_vision_alignment_with_complete_data(self, calculator: AlignmentCalculator) -> None: + """Test vision scoring with complete data.""" + # Arrange + context = {"vision": "Our vision statement"} + state = {"vision_clarity": 80.0, "vision_adoption": 70.0} + + # Act + score = calculator._score_vision_alignment(context, state) + + # Assert + assert score == 75.0 # Average of 80 and 70 + + def test_operations_alignment_calculation(self, calculator: AlignmentCalculator) -> None: + """Test operations scoring logic.""" + # Arrange + context = {} + state = {"operational_efficiency": 80.0, "operational_effectiveness": 90.0} + + # Act + score = calculator._score_operations_alignment(context, state) + + # Assert + assert score == 85.0 # Average of 80 and 90 + + def test_culture_alignment_with_missing_values(self, calculator: AlignmentCalculator) -> None: + """Test culture scoring with missing values.""" + # Arrange + context = {} + state = {"culture_values_match": 80.0} + + # Act + score = calculator._score_culture_alignment(context, state) + + # Assert + assert score == 50.0 # Neutral for missing values + + +class TestAlignmentCalculatorFoundationScoring: + """Test suite for foundation score calculations.""" + + @pytest.fixture + def calculator(self) -> AlignmentCalculator: + """Fixture providing calculator instance.""" + return AlignmentCalculator() + + def test_purpose_alignment_with_missing_purpose(self, calculator: AlignmentCalculator) -> None: + """Test purpose scoring with missing purpose.""" + # Arrange + context = {} + + # Act + score = calculator._score_purpose_alignment(context) + + # Assert + assert score == 50.0 + + def test_purpose_alignment_with_clarity(self, calculator: AlignmentCalculator) -> None: + """Test purpose scoring with clarity data.""" + # Arrange + context = {"purpose": "Our purpose", "purpose_clarity": 85.0} + + # Act + score = calculator._score_purpose_alignment(context) + + # Assert + assert score == 85.0 + + def test_values_alignment_with_no_values(self, calculator: AlignmentCalculator) -> None: + """Test values scoring with no values.""" + # Arrange + context = {"values": []} + + # Act + score = calculator._score_values_alignment(context) + + # Assert + assert score == 50.0 # Returns neutral when values list is empty + + def test_values_alignment_with_few_values(self, calculator: AlignmentCalculator) -> None: + """Test values scoring with few values.""" + # Arrange + context = {"values": ["integrity", "innovation"]} + + # Act + score = calculator._score_values_alignment(context) + + # Assert + assert score == 60.0 + + def test_values_alignment_with_many_values(self, calculator: AlignmentCalculator) -> None: + """Test values scoring with good number of values.""" + # Arrange + context = {"values": ["integrity", "innovation", "compassion", "excellence"]} + + # Act + score = calculator._score_values_alignment(context) + + # Assert + assert score == 80.0 + + def test_mission_alignment_with_clarity(self, calculator: AlignmentCalculator) -> None: + """Test mission scoring with clarity.""" + # Arrange + context = {"mission": "Our mission", "mission_clarity": 90.0} + + # Act + score = calculator._score_mission_alignment(context) + + # Assert + assert score == 90.0 + + +class TestAlignmentCalculatorConfidence: + """Test suite for confidence calculations.""" + + @pytest.fixture + def calculator(self) -> AlignmentCalculator: + """Fixture providing calculator instance.""" + return AlignmentCalculator() + + def test_confidence_with_complete_data(self, calculator: AlignmentCalculator) -> None: + """Test confidence with all required fields.""" + # Arrange + context = { + "purpose": "Purpose", + "values": ["val1"], + "mission": "Mission", + "vision": "Vision", + "strategy": "Strategy", + } + state = {"key1": "val1", "key2": "val2"} + + # Act + confidence = calculator._calculate_confidence(context, state) + + # Assert + assert confidence > 80 # Should be high with complete data + + def test_confidence_with_minimal_data(self, calculator: AlignmentCalculator) -> None: + """Test confidence with minimal data.""" + # Arrange + context = {"purpose": "Purpose"} + state = {} + + # Act + confidence = calculator._calculate_confidence(context, state) + + # Assert + assert 50 <= confidence < 60 # Low but above minimum + + def test_confidence_has_minimum_floor(self, calculator: AlignmentCalculator) -> None: + """Test that confidence has minimum threshold.""" + # Arrange + context = {} + state = {} + + # Act + confidence = calculator._calculate_confidence(context, state) + + # Assert + assert confidence >= 50.0 # Minimum confidence level + + +class TestAlignmentCalculatorEdgeCases: + """Test suite for edge cases.""" + + @pytest.fixture + def calculator(self) -> AlignmentCalculator: + """Fixture providing calculator instance.""" + return AlignmentCalculator() + + def test_calculate_alignment_with_empty_context(self, calculator: AlignmentCalculator) -> None: + """Test calculation with empty context.""" + # Act + result = calculator.calculate_alignment( + business_context={}, + current_state={}, + explanation="Minimal data test case", + ) + + # Assert + assert result is not None + assert hasattr(result, "overall_score") + assert result.overall_score >= 0 + assert result.confidence_level >= 50 # Minimum confidence + + def test_calculate_alignment_with_extreme_high_scores( + self, calculator: AlignmentCalculator + ) -> None: + """Test calculation with very high input scores.""" + # Arrange + context = { + "purpose": "Purpose", + "values": ["val1", "val2", "val3"], + "mission": "Mission", + "purpose_clarity": 100.0, + "mission_clarity": 100.0, + } + state = { + "vision_clarity": 100.0, + "vision_adoption": 100.0, + "strategy_clarity": 100.0, + "execution_level": 100.0, + "operational_efficiency": 100.0, + "operational_effectiveness": 100.0, + "culture_values_match": 100.0, + "employee_engagement": 100.0, + } + + # Act + result = calculator.calculate_alignment( + business_context=context, + current_state=state, + explanation="Perfect alignment scenario", + ) + + # Assert + assert result.overall_score <= 100.0 # Should not exceed max + assert result.overall_score > 80.0 # Should be high + + def test_overall_score_weighted_correctly(self, calculator: AlignmentCalculator) -> None: + """Test that overall score uses correct weighting.""" + # Arrange + context = { + "purpose": "P", + "values": ["v1", "v2", "v3"], + "mission": "M", + "vision": "V", + "strategy": "S", + "purpose_clarity": 80.0, + "mission_clarity": 80.0, + } + state = { + "vision_clarity": 80.0, + "vision_adoption": 80.0, + "strategy_clarity": 80.0, + "execution_level": 80.0, + "operational_efficiency": 80.0, + "operational_effectiveness": 80.0, + "culture_values_match": 80.0, + "employee_engagement": 80.0, + } + + # Act + result = calculator.calculate_alignment( + business_context=context, + current_state=state, + explanation="Testing weighted average", + ) + + # Assert - Should be around 80 with uniform scores + assert 75.0 <= result.overall_score <= 85.0 diff --git a/coaching/tests/unit/domain/services/test_completion_validator.py b/coaching/tests/unit/domain/services/test_completion_validator.py index 4b193ecc..03ab714d 100644 --- a/coaching/tests/unit/domain/services/test_completion_validator.py +++ b/coaching/tests/unit/domain/services/test_completion_validator.py @@ -1,298 +1,299 @@ -"""Unit tests for CompletionValidator domain service.""" - -import pytest -from coaching.src.core.constants import CoachingTopic, ConversationPhase, MessageRole -from coaching.src.core.types import ( - create_conversation_id, - create_tenant_id, - create_user_id, -) -from coaching.src.domain.entities.conversation import Conversation -from coaching.src.domain.services.completion_validator import CompletionValidator - -pytestmark = pytest.mark.unit - - -class TestCompletionValidatorBasics: - """Test suite for basic completion validation.""" - - @pytest.fixture - def validator(self) -> CompletionValidator: - """Fixture providing validator instance.""" - return CompletionValidator() - - @pytest.fixture - def conversation(self) -> Conversation: - """Fixture providing test conversation.""" - return Conversation( - conversation_id=create_conversation_id(), - user_id=create_user_id("user_123"), - tenant_id=create_tenant_id("tenant_456"), - topic=CoachingTopic.CORE_VALUES, - ) - - def test_cannot_complete_early_phase_conversation( - self, validator: CompletionValidator, conversation: Conversation - ) -> None: - """Test that conversations in early phases cannot complete.""" - # Act - can_complete = validator.can_complete(conversation) - - # Assert - assert can_complete is False - - def test_cannot_complete_paused_conversation( - self, validator: CompletionValidator, conversation: Conversation - ) -> None: - """Test that paused conversations cannot complete.""" - # Arrange - conversation.mark_paused() - - # Act - can_complete = validator.can_complete(conversation) - - # Assert - assert can_complete is False - - def test_can_complete_with_all_requirements_met( - self, validator: CompletionValidator, conversation: Conversation - ) -> None: - """Test completion with all requirements met.""" - # Arrange - conversation.transition_to_phase(ConversationPhase.VALIDATION) - - # Add messages - for i in range(5): - conversation.add_message(role=MessageRole.USER, content=f"User message {i}") - conversation.add_message(role=MessageRole.ASSISTANT, content=f"Assistant response {i}") - - # Add insights - for i in range(8): - conversation.add_insight(f"Insight {i}") - - # Act - can_complete = validator.can_complete(conversation) - - # Assert - assert can_complete is True - - -class TestCompletionValidatorDetailed: - """Test suite for detailed validation feedback.""" - - @pytest.fixture - def validator(self) -> CompletionValidator: - """Fixture providing validator instance.""" - return CompletionValidator() - - @pytest.fixture - def conversation(self) -> Conversation: - """Fixture providing test conversation.""" - return Conversation( - conversation_id=create_conversation_id(), - user_id=create_user_id("user_123"), - tenant_id=create_tenant_id("tenant_456"), - topic=CoachingTopic.CORE_VALUES, - ) - - def test_validate_completion_returns_reasons_for_failure( - self, validator: CompletionValidator, conversation: Conversation - ) -> None: - """Test that validation provides specific failure reasons.""" - # Act - is_valid, reasons = validator.validate_completion(conversation) - - # Assert - assert is_valid is False - assert len(reasons) > 0 - assert any("phase" in reason.lower() for reason in reasons) - - def test_validate_completion_identifies_missing_messages( - self, validator: CompletionValidator, conversation: Conversation - ) -> None: - """Test that validation identifies insufficient messages.""" - # Arrange - conversation.transition_to_phase(ConversationPhase.VALIDATION) - - # Act - is_valid, reasons = validator.validate_completion(conversation) - - # Assert - assert is_valid is False - assert any("messages" in reason for reason in reasons) - - def test_validate_completion_identifies_missing_insights( - self, validator: CompletionValidator, conversation: Conversation - ) -> None: - """Test that validation identifies insufficient insights.""" - # Arrange - conversation.transition_to_phase(ConversationPhase.VALIDATION) - - # Add enough messages - for i in range(10): - conversation.add_message(role=MessageRole.USER, content=f"Message {i}") - - # Act - is_valid, reasons = validator.validate_completion(conversation) - - # Assert - assert is_valid is False - assert any("insights" in reason.lower() for reason in reasons) - - def test_validate_completion_success_returns_empty_reasons( - self, validator: CompletionValidator, conversation: Conversation - ) -> None: - """Test that successful validation returns empty reasons.""" - # Arrange - conversation.transition_to_phase(ConversationPhase.VALIDATION) - - for i in range(5): - conversation.add_message(role=MessageRole.USER, content=f"User message {i}") - conversation.add_message(role=MessageRole.ASSISTANT, content=f"Assistant response {i}") - - for i in range(8): - conversation.add_insight(f"Insight {i}") - - # Act - is_valid, reasons = validator.validate_completion(conversation) - - # Assert - assert is_valid is True - assert len(reasons) == 0 - - -class TestCompletionValidatorProgress: - """Test suite for completion progress calculations.""" - - @pytest.fixture - def validator(self) -> CompletionValidator: - """Fixture providing validator instance.""" - return CompletionValidator() - - @pytest.fixture - def conversation(self) -> Conversation: - """Fixture providing test conversation.""" - return Conversation( - conversation_id=create_conversation_id(), - user_id=create_user_id("user_123"), - tenant_id=create_tenant_id("tenant_456"), - topic=CoachingTopic.CORE_VALUES, - ) - - def test_get_completion_progress_at_zero( - self, validator: CompletionValidator, conversation: Conversation - ) -> None: - """Test progress calculation with no criteria met.""" - # Act - progress = validator.get_completion_progress(conversation) - - # Assert - assert progress == 0.0 - - def test_get_completion_progress_partial( - self, validator: CompletionValidator, conversation: Conversation - ) -> None: - """Test progress calculation with some criteria met.""" - # Arrange - conversation.transition_to_phase(ConversationPhase.VALIDATION) - - # Act - progress = validator.get_completion_progress(conversation) - - # Assert - assert 0 < progress < 100 - - def test_get_completion_progress_at_full( - self, validator: CompletionValidator, conversation: Conversation - ) -> None: - """Test progress calculation with all criteria met.""" - # Arrange - conversation.transition_to_phase(ConversationPhase.VALIDATION) - - for i in range(5): - conversation.add_message(role=MessageRole.USER, content=f"User message {i}") - conversation.add_message(role=MessageRole.ASSISTANT, content=f"Assistant response {i}") - - for i in range(8): - conversation.add_insight(f"Insight {i}") - - # Act - progress = validator.get_completion_progress(conversation) - - # Assert - assert progress == 100.0 - - -class TestCompletionValidatorRequirements: - """Test suite for specific requirement checks.""" - - @pytest.fixture - def validator(self) -> CompletionValidator: - """Fixture providing validator instance.""" - return CompletionValidator() - - @pytest.fixture - def conversation(self) -> Conversation: - """Fixture providing test conversation.""" - return Conversation( - conversation_id=create_conversation_id(), - user_id=create_user_id("user_123"), - tenant_id=create_tenant_id("tenant_456"), - topic=CoachingTopic.CORE_VALUES, - ) - - def test_validates_minimum_total_messages( - self, validator: CompletionValidator, conversation: Conversation - ) -> None: - """Test validation of minimum total messages.""" - # Arrange - conversation.transition_to_phase(ConversationPhase.VALIDATION) - - for i in range(4): # Less than minimum - conversation.add_message(role=MessageRole.USER, content=f"Message {i}") - - # Act - is_valid, reasons = validator.validate_completion(conversation) - - # Assert - assert is_valid is False - assert any("10 total messages" in reason for reason in reasons) - - def test_validates_minimum_user_responses( - self, validator: CompletionValidator, conversation: Conversation - ) -> None: - """Test validation of minimum user responses.""" - # Arrange - conversation.transition_to_phase(ConversationPhase.VALIDATION) - - # Add mostly assistant messages - for i in range(10): - conversation.add_message(role=MessageRole.ASSISTANT, content=f"Message {i}") - - # Act - is_valid, reasons = validator.validate_completion(conversation) - - # Assert - assert is_valid is False - assert any("user responses" in reason.lower() for reason in reasons) - - def test_validates_minimum_assistant_messages( - self, validator: CompletionValidator, conversation: Conversation - ) -> None: - """Test validation of minimum assistant messages.""" - # Arrange - conversation.transition_to_phase(ConversationPhase.VALIDATION) - - # Add mostly user messages - for i in range(10): - conversation.add_message(role=MessageRole.USER, content=f"Message {i}") - - for i in range(8): - conversation.add_insight(f"Insight {i}") - - # Act - is_valid, reasons = validator.validate_completion(conversation) - - # Assert - assert is_valid is False - assert any("assistant messages" in reason.lower() for reason in reasons) +"""Unit tests for CompletionValidator domain service.""" + +import pytest + +from coaching.src.core.constants import CoachingTopic, ConversationPhase, MessageRole +from coaching.src.core.types import ( + create_conversation_id, + create_tenant_id, + create_user_id, +) +from coaching.src.domain.entities.conversation import Conversation +from coaching.src.domain.services.completion_validator import CompletionValidator + +pytestmark = pytest.mark.unit + + +class TestCompletionValidatorBasics: + """Test suite for basic completion validation.""" + + @pytest.fixture + def validator(self) -> CompletionValidator: + """Fixture providing validator instance.""" + return CompletionValidator() + + @pytest.fixture + def conversation(self) -> Conversation: + """Fixture providing test conversation.""" + return Conversation( + conversation_id=create_conversation_id(), + user_id=create_user_id("user_123"), + tenant_id=create_tenant_id("tenant_456"), + topic=CoachingTopic.CORE_VALUES, + ) + + def test_cannot_complete_early_phase_conversation( + self, validator: CompletionValidator, conversation: Conversation + ) -> None: + """Test that conversations in early phases cannot complete.""" + # Act + can_complete = validator.can_complete(conversation) + + # Assert + assert can_complete is False + + def test_cannot_complete_paused_conversation( + self, validator: CompletionValidator, conversation: Conversation + ) -> None: + """Test that paused conversations cannot complete.""" + # Arrange + conversation.mark_paused() + + # Act + can_complete = validator.can_complete(conversation) + + # Assert + assert can_complete is False + + def test_can_complete_with_all_requirements_met( + self, validator: CompletionValidator, conversation: Conversation + ) -> None: + """Test completion with all requirements met.""" + # Arrange + conversation.transition_to_phase(ConversationPhase.VALIDATION) + + # Add messages + for i in range(5): + conversation.add_message(role=MessageRole.USER, content=f"User message {i}") + conversation.add_message(role=MessageRole.ASSISTANT, content=f"Assistant response {i}") + + # Add insights + for i in range(8): + conversation.add_insight(f"Insight {i}") + + # Act + can_complete = validator.can_complete(conversation) + + # Assert + assert can_complete is True + + +class TestCompletionValidatorDetailed: + """Test suite for detailed validation feedback.""" + + @pytest.fixture + def validator(self) -> CompletionValidator: + """Fixture providing validator instance.""" + return CompletionValidator() + + @pytest.fixture + def conversation(self) -> Conversation: + """Fixture providing test conversation.""" + return Conversation( + conversation_id=create_conversation_id(), + user_id=create_user_id("user_123"), + tenant_id=create_tenant_id("tenant_456"), + topic=CoachingTopic.CORE_VALUES, + ) + + def test_validate_completion_returns_reasons_for_failure( + self, validator: CompletionValidator, conversation: Conversation + ) -> None: + """Test that validation provides specific failure reasons.""" + # Act + is_valid, reasons = validator.validate_completion(conversation) + + # Assert + assert is_valid is False + assert len(reasons) > 0 + assert any("phase" in reason.lower() for reason in reasons) + + def test_validate_completion_identifies_missing_messages( + self, validator: CompletionValidator, conversation: Conversation + ) -> None: + """Test that validation identifies insufficient messages.""" + # Arrange + conversation.transition_to_phase(ConversationPhase.VALIDATION) + + # Act + is_valid, reasons = validator.validate_completion(conversation) + + # Assert + assert is_valid is False + assert any("messages" in reason for reason in reasons) + + def test_validate_completion_identifies_missing_insights( + self, validator: CompletionValidator, conversation: Conversation + ) -> None: + """Test that validation identifies insufficient insights.""" + # Arrange + conversation.transition_to_phase(ConversationPhase.VALIDATION) + + # Add enough messages + for i in range(10): + conversation.add_message(role=MessageRole.USER, content=f"Message {i}") + + # Act + is_valid, reasons = validator.validate_completion(conversation) + + # Assert + assert is_valid is False + assert any("insights" in reason.lower() for reason in reasons) + + def test_validate_completion_success_returns_empty_reasons( + self, validator: CompletionValidator, conversation: Conversation + ) -> None: + """Test that successful validation returns empty reasons.""" + # Arrange + conversation.transition_to_phase(ConversationPhase.VALIDATION) + + for i in range(5): + conversation.add_message(role=MessageRole.USER, content=f"User message {i}") + conversation.add_message(role=MessageRole.ASSISTANT, content=f"Assistant response {i}") + + for i in range(8): + conversation.add_insight(f"Insight {i}") + + # Act + is_valid, reasons = validator.validate_completion(conversation) + + # Assert + assert is_valid is True + assert len(reasons) == 0 + + +class TestCompletionValidatorProgress: + """Test suite for completion progress calculations.""" + + @pytest.fixture + def validator(self) -> CompletionValidator: + """Fixture providing validator instance.""" + return CompletionValidator() + + @pytest.fixture + def conversation(self) -> Conversation: + """Fixture providing test conversation.""" + return Conversation( + conversation_id=create_conversation_id(), + user_id=create_user_id("user_123"), + tenant_id=create_tenant_id("tenant_456"), + topic=CoachingTopic.CORE_VALUES, + ) + + def test_get_completion_progress_at_zero( + self, validator: CompletionValidator, conversation: Conversation + ) -> None: + """Test progress calculation with no criteria met.""" + # Act + progress = validator.get_completion_progress(conversation) + + # Assert + assert progress == 0.0 + + def test_get_completion_progress_partial( + self, validator: CompletionValidator, conversation: Conversation + ) -> None: + """Test progress calculation with some criteria met.""" + # Arrange + conversation.transition_to_phase(ConversationPhase.VALIDATION) + + # Act + progress = validator.get_completion_progress(conversation) + + # Assert + assert 0 < progress < 100 + + def test_get_completion_progress_at_full( + self, validator: CompletionValidator, conversation: Conversation + ) -> None: + """Test progress calculation with all criteria met.""" + # Arrange + conversation.transition_to_phase(ConversationPhase.VALIDATION) + + for i in range(5): + conversation.add_message(role=MessageRole.USER, content=f"User message {i}") + conversation.add_message(role=MessageRole.ASSISTANT, content=f"Assistant response {i}") + + for i in range(8): + conversation.add_insight(f"Insight {i}") + + # Act + progress = validator.get_completion_progress(conversation) + + # Assert + assert progress == 100.0 + + +class TestCompletionValidatorRequirements: + """Test suite for specific requirement checks.""" + + @pytest.fixture + def validator(self) -> CompletionValidator: + """Fixture providing validator instance.""" + return CompletionValidator() + + @pytest.fixture + def conversation(self) -> Conversation: + """Fixture providing test conversation.""" + return Conversation( + conversation_id=create_conversation_id(), + user_id=create_user_id("user_123"), + tenant_id=create_tenant_id("tenant_456"), + topic=CoachingTopic.CORE_VALUES, + ) + + def test_validates_minimum_total_messages( + self, validator: CompletionValidator, conversation: Conversation + ) -> None: + """Test validation of minimum total messages.""" + # Arrange + conversation.transition_to_phase(ConversationPhase.VALIDATION) + + for i in range(4): # Less than minimum + conversation.add_message(role=MessageRole.USER, content=f"Message {i}") + + # Act + is_valid, reasons = validator.validate_completion(conversation) + + # Assert + assert is_valid is False + assert any("10 total messages" in reason for reason in reasons) + + def test_validates_minimum_user_responses( + self, validator: CompletionValidator, conversation: Conversation + ) -> None: + """Test validation of minimum user responses.""" + # Arrange + conversation.transition_to_phase(ConversationPhase.VALIDATION) + + # Add mostly assistant messages + for i in range(10): + conversation.add_message(role=MessageRole.ASSISTANT, content=f"Message {i}") + + # Act + is_valid, reasons = validator.validate_completion(conversation) + + # Assert + assert is_valid is False + assert any("user responses" in reason.lower() for reason in reasons) + + def test_validates_minimum_assistant_messages( + self, validator: CompletionValidator, conversation: Conversation + ) -> None: + """Test validation of minimum assistant messages.""" + # Arrange + conversation.transition_to_phase(ConversationPhase.VALIDATION) + + # Add mostly user messages + for i in range(10): + conversation.add_message(role=MessageRole.USER, content=f"Message {i}") + + for i in range(8): + conversation.add_insight(f"Insight {i}") + + # Act + is_valid, reasons = validator.validate_completion(conversation) + + # Assert + assert is_valid is False + assert any("assistant messages" in reason.lower() for reason in reasons) diff --git a/coaching/tests/unit/domain/services/test_phase_transition_service.py b/coaching/tests/unit/domain/services/test_phase_transition_service.py index 231f6bf6..17ec1421 100644 --- a/coaching/tests/unit/domain/services/test_phase_transition_service.py +++ b/coaching/tests/unit/domain/services/test_phase_transition_service.py @@ -1,289 +1,290 @@ -"""Unit tests for PhaseTransitionService domain service.""" - -import pytest -from coaching.src.core.constants import ( - CoachingTopic, - ConversationPhase, - MessageRole, -) -from coaching.src.core.types import ( - create_conversation_id, - create_tenant_id, - create_user_id, -) -from coaching.src.domain.entities.conversation import Conversation -from coaching.src.domain.services.phase_transition_service import ( - PhaseTransitionService, -) - -pytestmark = pytest.mark.unit - - -class TestPhaseTransitionServiceBasics: - """Test suite for basic phase transition functionality.""" - - @pytest.fixture - def service(self) -> PhaseTransitionService: - """Fixture providing service instance.""" - return PhaseTransitionService() - - @pytest.fixture - def conversation(self) -> Conversation: - """Fixture providing test conversation.""" - return Conversation( - conversation_id=create_conversation_id(), - user_id=create_user_id("user_123"), - tenant_id=create_tenant_id("tenant_456"), - topic=CoachingTopic.CORE_VALUES, - ) - - def test_can_transition_to_exploration_from_introduction( - self, service: PhaseTransitionService, conversation: Conversation - ) -> None: - """Test basic forward transition.""" - # Arrange - Add minimum requirements - conversation.add_message(role=MessageRole.USER, content="Test message") - - # Act - can_transition = service.can_transition_to_phase( - conversation, ConversationPhase.EXPLORATION - ) - - # Assert - assert can_transition is False # Need more responses - - def test_cannot_transition_backward( - self, service: PhaseTransitionService, conversation: Conversation - ) -> None: - """Test that backward transitions are not allowed.""" - # Arrange - conversation.transition_to_phase(ConversationPhase.EXPLORATION) - - # Act - can_transition = service.can_transition_to_phase( - conversation, ConversationPhase.INTRODUCTION - ) - - # Assert - assert can_transition is False - - def test_cannot_transition_paused_conversation( - self, service: PhaseTransitionService, conversation: Conversation - ) -> None: - """Test that paused conversations cannot transition.""" - # Arrange - conversation.mark_paused() - - # Act - can_transition = service.can_transition_to_phase( - conversation, ConversationPhase.EXPLORATION - ) - - # Assert - assert can_transition is False - - -class TestPhaseTransitionRequirements: - """Test suite for phase transition requirements.""" - - @pytest.fixture - def service(self) -> PhaseTransitionService: - """Fixture providing service instance.""" - return PhaseTransitionService() - - @pytest.fixture - def conversation(self) -> Conversation: - """Fixture providing test conversation.""" - return Conversation( - conversation_id=create_conversation_id(), - user_id=create_user_id("user_123"), - tenant_id=create_tenant_id("tenant_456"), - topic=CoachingTopic.CORE_VALUES, - ) - - def test_get_transition_requirements_for_exploration( - self, service: PhaseTransitionService - ) -> None: - """Test getting requirements for exploration phase.""" - # Act - requirements = service.get_transition_requirements(ConversationPhase.EXPLORATION) - - # Assert - assert "min_responses" in requirements - assert "min_insights" in requirements - assert requirements["min_responses"] == 3 - assert requirements["min_insights"] == 2 - - def test_can_transition_with_sufficient_responses_and_insights( - self, service: PhaseTransitionService, conversation: Conversation - ) -> None: - """Test transition with sufficient requirements met.""" - # Arrange - Add required responses - for i in range(3): - conversation.add_message(role=MessageRole.USER, content=f"Message {i}") - - # Add required insights - conversation.add_insight("Insight 1") - conversation.add_insight("Insight 2") - - # Act - can_transition = service.can_transition_to_phase( - conversation, ConversationPhase.EXPLORATION - ) - - # Assert - assert can_transition is True - - def test_cannot_transition_without_sufficient_responses( - self, service: PhaseTransitionService, conversation: Conversation - ) -> None: - """Test that insufficient responses blocks transition.""" - # Arrange - Only 1 response, need 3 - conversation.add_message(role=MessageRole.USER, content="Message") - conversation.add_insight("Insight 1") - conversation.add_insight("Insight 2") - - # Act - can_transition = service.can_transition_to_phase( - conversation, ConversationPhase.EXPLORATION - ) - - # Assert - assert can_transition is False - - def test_cannot_transition_without_sufficient_insights( - self, service: PhaseTransitionService, conversation: Conversation - ) -> None: - """Test that insufficient insights blocks transition.""" - # Arrange - Enough responses but not enough insights - for i in range(3): - conversation.add_message(role=MessageRole.USER, content=f"Message {i}") - - conversation.add_insight("Insight 1") # Need 2 - - # Act - can_transition = service.can_transition_to_phase( - conversation, ConversationPhase.EXPLORATION - ) - - # Assert - assert can_transition is False - - -class TestPhaseTransitionNextPhase: - """Test suite for getting next phase.""" - - @pytest.fixture - def service(self) -> PhaseTransitionService: - """Fixture providing service instance.""" - return PhaseTransitionService() - - @pytest.fixture - def conversation(self) -> Conversation: - """Fixture providing test conversation.""" - return Conversation( - conversation_id=create_conversation_id(), - user_id=create_user_id("user_123"), - tenant_id=create_tenant_id("tenant_456"), - topic=CoachingTopic.CORE_VALUES, - ) - - def test_get_next_phase_when_requirements_met( - self, service: PhaseTransitionService, conversation: Conversation - ) -> None: - """Test getting next phase when requirements are met.""" - # Arrange - for i in range(3): - conversation.add_message(role=MessageRole.USER, content=f"Message {i}") - conversation.add_insight("Insight 1") - conversation.add_insight("Insight 2") - - # Act - next_phase = service.get_next_phase(conversation) - - # Assert - assert next_phase == ConversationPhase.EXPLORATION - - def test_get_next_phase_when_requirements_not_met( - self, service: PhaseTransitionService, conversation: Conversation - ) -> None: - """Test that None returned when requirements not met.""" - # Arrange - Not enough messages/insights - - # Act - next_phase = service.get_next_phase(conversation) - - # Assert - assert next_phase is None - - def test_get_next_phase_returns_none_at_completion( - self, service: PhaseTransitionService, conversation: Conversation - ) -> None: - """Test that None returned when at completion phase.""" - # Arrange - conversation.transition_to_phase(ConversationPhase.COMPLETION) - - # Act - next_phase = service.get_next_phase(conversation) - - # Assert - assert next_phase is None - - -class TestPhaseTransitionReadiness: - """Test suite for phase readiness calculations.""" - - @pytest.fixture - def service(self) -> PhaseTransitionService: - """Fixture providing service instance.""" - return PhaseTransitionService() - - @pytest.fixture - def conversation(self) -> Conversation: - """Fixture providing test conversation.""" - return Conversation( - conversation_id=create_conversation_id(), - user_id=create_user_id("user_123"), - tenant_id=create_tenant_id("tenant_456"), - topic=CoachingTopic.CORE_VALUES, - ) - - def test_calculate_phase_readiness_at_zero_percent( - self, service: PhaseTransitionService, conversation: Conversation - ) -> None: - """Test readiness calculation with no progress.""" - # Act - readiness = service.calculate_phase_readiness(conversation, ConversationPhase.EXPLORATION) - - # Assert - assert readiness == 0.0 - - def test_calculate_phase_readiness_at_fifty_percent( - self, service: PhaseTransitionService, conversation: Conversation - ) -> None: - """Test readiness calculation at 50%.""" - # Arrange - Meet response requirement but not insights - for i in range(3): - conversation.add_message(role=MessageRole.USER, content=f"Message {i}") - - # Act - readiness = service.calculate_phase_readiness(conversation, ConversationPhase.EXPLORATION) - - # Assert - assert readiness == 50.0 - - def test_calculate_phase_readiness_at_full( - self, service: PhaseTransitionService, conversation: Conversation - ) -> None: - """Test readiness calculation at 100%.""" - # Arrange - for i in range(3): - conversation.add_message(role=MessageRole.USER, content=f"Message {i}") - conversation.add_insight("Insight 1") - conversation.add_insight("Insight 2") - - # Act - readiness = service.calculate_phase_readiness(conversation, ConversationPhase.EXPLORATION) - - # Assert - assert readiness == 100.0 +"""Unit tests for PhaseTransitionService domain service.""" + +import pytest + +from coaching.src.core.constants import ( + CoachingTopic, + ConversationPhase, + MessageRole, +) +from coaching.src.core.types import ( + create_conversation_id, + create_tenant_id, + create_user_id, +) +from coaching.src.domain.entities.conversation import Conversation +from coaching.src.domain.services.phase_transition_service import ( + PhaseTransitionService, +) + +pytestmark = pytest.mark.unit + + +class TestPhaseTransitionServiceBasics: + """Test suite for basic phase transition functionality.""" + + @pytest.fixture + def service(self) -> PhaseTransitionService: + """Fixture providing service instance.""" + return PhaseTransitionService() + + @pytest.fixture + def conversation(self) -> Conversation: + """Fixture providing test conversation.""" + return Conversation( + conversation_id=create_conversation_id(), + user_id=create_user_id("user_123"), + tenant_id=create_tenant_id("tenant_456"), + topic=CoachingTopic.CORE_VALUES, + ) + + def test_can_transition_to_exploration_from_introduction( + self, service: PhaseTransitionService, conversation: Conversation + ) -> None: + """Test basic forward transition.""" + # Arrange - Add minimum requirements + conversation.add_message(role=MessageRole.USER, content="Test message") + + # Act + can_transition = service.can_transition_to_phase( + conversation, ConversationPhase.EXPLORATION + ) + + # Assert + assert can_transition is False # Need more responses + + def test_cannot_transition_backward( + self, service: PhaseTransitionService, conversation: Conversation + ) -> None: + """Test that backward transitions are not allowed.""" + # Arrange + conversation.transition_to_phase(ConversationPhase.EXPLORATION) + + # Act + can_transition = service.can_transition_to_phase( + conversation, ConversationPhase.INTRODUCTION + ) + + # Assert + assert can_transition is False + + def test_cannot_transition_paused_conversation( + self, service: PhaseTransitionService, conversation: Conversation + ) -> None: + """Test that paused conversations cannot transition.""" + # Arrange + conversation.mark_paused() + + # Act + can_transition = service.can_transition_to_phase( + conversation, ConversationPhase.EXPLORATION + ) + + # Assert + assert can_transition is False + + +class TestPhaseTransitionRequirements: + """Test suite for phase transition requirements.""" + + @pytest.fixture + def service(self) -> PhaseTransitionService: + """Fixture providing service instance.""" + return PhaseTransitionService() + + @pytest.fixture + def conversation(self) -> Conversation: + """Fixture providing test conversation.""" + return Conversation( + conversation_id=create_conversation_id(), + user_id=create_user_id("user_123"), + tenant_id=create_tenant_id("tenant_456"), + topic=CoachingTopic.CORE_VALUES, + ) + + def test_get_transition_requirements_for_exploration( + self, service: PhaseTransitionService + ) -> None: + """Test getting requirements for exploration phase.""" + # Act + requirements = service.get_transition_requirements(ConversationPhase.EXPLORATION) + + # Assert + assert "min_responses" in requirements + assert "min_insights" in requirements + assert requirements["min_responses"] == 3 + assert requirements["min_insights"] == 2 + + def test_can_transition_with_sufficient_responses_and_insights( + self, service: PhaseTransitionService, conversation: Conversation + ) -> None: + """Test transition with sufficient requirements met.""" + # Arrange - Add required responses + for i in range(3): + conversation.add_message(role=MessageRole.USER, content=f"Message {i}") + + # Add required insights + conversation.add_insight("Insight 1") + conversation.add_insight("Insight 2") + + # Act + can_transition = service.can_transition_to_phase( + conversation, ConversationPhase.EXPLORATION + ) + + # Assert + assert can_transition is True + + def test_cannot_transition_without_sufficient_responses( + self, service: PhaseTransitionService, conversation: Conversation + ) -> None: + """Test that insufficient responses blocks transition.""" + # Arrange - Only 1 response, need 3 + conversation.add_message(role=MessageRole.USER, content="Message") + conversation.add_insight("Insight 1") + conversation.add_insight("Insight 2") + + # Act + can_transition = service.can_transition_to_phase( + conversation, ConversationPhase.EXPLORATION + ) + + # Assert + assert can_transition is False + + def test_cannot_transition_without_sufficient_insights( + self, service: PhaseTransitionService, conversation: Conversation + ) -> None: + """Test that insufficient insights blocks transition.""" + # Arrange - Enough responses but not enough insights + for i in range(3): + conversation.add_message(role=MessageRole.USER, content=f"Message {i}") + + conversation.add_insight("Insight 1") # Need 2 + + # Act + can_transition = service.can_transition_to_phase( + conversation, ConversationPhase.EXPLORATION + ) + + # Assert + assert can_transition is False + + +class TestPhaseTransitionNextPhase: + """Test suite for getting next phase.""" + + @pytest.fixture + def service(self) -> PhaseTransitionService: + """Fixture providing service instance.""" + return PhaseTransitionService() + + @pytest.fixture + def conversation(self) -> Conversation: + """Fixture providing test conversation.""" + return Conversation( + conversation_id=create_conversation_id(), + user_id=create_user_id("user_123"), + tenant_id=create_tenant_id("tenant_456"), + topic=CoachingTopic.CORE_VALUES, + ) + + def test_get_next_phase_when_requirements_met( + self, service: PhaseTransitionService, conversation: Conversation + ) -> None: + """Test getting next phase when requirements are met.""" + # Arrange + for i in range(3): + conversation.add_message(role=MessageRole.USER, content=f"Message {i}") + conversation.add_insight("Insight 1") + conversation.add_insight("Insight 2") + + # Act + next_phase = service.get_next_phase(conversation) + + # Assert + assert next_phase == ConversationPhase.EXPLORATION + + def test_get_next_phase_when_requirements_not_met( + self, service: PhaseTransitionService, conversation: Conversation + ) -> None: + """Test that None returned when requirements not met.""" + # Arrange - Not enough messages/insights + + # Act + next_phase = service.get_next_phase(conversation) + + # Assert + assert next_phase is None + + def test_get_next_phase_returns_none_at_completion( + self, service: PhaseTransitionService, conversation: Conversation + ) -> None: + """Test that None returned when at completion phase.""" + # Arrange + conversation.transition_to_phase(ConversationPhase.COMPLETION) + + # Act + next_phase = service.get_next_phase(conversation) + + # Assert + assert next_phase is None + + +class TestPhaseTransitionReadiness: + """Test suite for phase readiness calculations.""" + + @pytest.fixture + def service(self) -> PhaseTransitionService: + """Fixture providing service instance.""" + return PhaseTransitionService() + + @pytest.fixture + def conversation(self) -> Conversation: + """Fixture providing test conversation.""" + return Conversation( + conversation_id=create_conversation_id(), + user_id=create_user_id("user_123"), + tenant_id=create_tenant_id("tenant_456"), + topic=CoachingTopic.CORE_VALUES, + ) + + def test_calculate_phase_readiness_at_zero_percent( + self, service: PhaseTransitionService, conversation: Conversation + ) -> None: + """Test readiness calculation with no progress.""" + # Act + readiness = service.calculate_phase_readiness(conversation, ConversationPhase.EXPLORATION) + + # Assert + assert readiness == 0.0 + + def test_calculate_phase_readiness_at_fifty_percent( + self, service: PhaseTransitionService, conversation: Conversation + ) -> None: + """Test readiness calculation at 50%.""" + # Arrange - Meet response requirement but not insights + for i in range(3): + conversation.add_message(role=MessageRole.USER, content=f"Message {i}") + + # Act + readiness = service.calculate_phase_readiness(conversation, ConversationPhase.EXPLORATION) + + # Assert + assert readiness == 50.0 + + def test_calculate_phase_readiness_at_full( + self, service: PhaseTransitionService, conversation: Conversation + ) -> None: + """Test readiness calculation at 100%.""" + # Arrange + for i in range(3): + conversation.add_message(role=MessageRole.USER, content=f"Message {i}") + conversation.add_insight("Insight 1") + conversation.add_insight("Insight 2") + + # Act + readiness = service.calculate_phase_readiness(conversation, ConversationPhase.EXPLORATION) + + # Assert + assert readiness == 100.0 diff --git a/coaching/tests/unit/domain/value_objects/test_alignment_score.py b/coaching/tests/unit/domain/value_objects/test_alignment_score.py index b672a9fd..ebacd4a0 100644 --- a/coaching/tests/unit/domain/value_objects/test_alignment_score.py +++ b/coaching/tests/unit/domain/value_objects/test_alignment_score.py @@ -1,441 +1,442 @@ -"""Unit tests for alignment score value objects.""" - -import pytest -from coaching.src.domain.value_objects.alignment_score import ( - AlignmentScore, - ComponentScores, - FoundationAlignment, -) -from pydantic import ValidationError - - -class TestComponentScores: - """Test suite for ComponentScores value object.""" - - def test_create_component_scores_with_valid_values(self) -> None: - """Test creating ComponentScores with valid values.""" - # Arrange & Act - scores = ComponentScores( - vision_alignment=85.0, - strategy_alignment=78.0, - operations_alignment=72.0, - culture_alignment=80.0, - ) - - # Assert - assert scores.vision_alignment == 85.0 - assert scores.strategy_alignment == 78.0 - assert scores.operations_alignment == 72.0 - assert scores.culture_alignment == 80.0 - - def test_component_scores_with_score_below_zero_raises_error(self) -> None: - """Test that score below 0 raises ValidationError.""" - # Arrange & Act & Assert - with pytest.raises(ValidationError): - ComponentScores( - vision_alignment=-1.0, - strategy_alignment=80.0, - operations_alignment=75.0, - culture_alignment=70.0, - ) - - def test_component_scores_with_score_above_100_raises_error( - self, - ) -> None: - """Test that score above 100 raises ValidationError.""" - # Arrange & Act & Assert - with pytest.raises(ValidationError): - ComponentScores( - vision_alignment=101.0, - strategy_alignment=80.0, - operations_alignment=75.0, - culture_alignment=70.0, - ) - - def test_component_scores_is_immutable(self) -> None: - """Test that ComponentScores is immutable.""" - # Arrange - scores = ComponentScores( - vision_alignment=85.0, - strategy_alignment=78.0, - operations_alignment=72.0, - culture_alignment=80.0, - ) - - # Act & Assert - with pytest.raises(ValidationError): - scores.vision_alignment = 90.0 # type: ignore - - def test_get_average_score_returns_correct_average(self) -> None: - """Test get_average_score calculates correctly.""" - # Arrange - scores = ComponentScores( - vision_alignment=80.0, - strategy_alignment=70.0, - operations_alignment=60.0, - culture_alignment=90.0, - ) - - # Act - average = scores.get_average_score() - - # Assert - assert average == 75.0 - - def test_get_lowest_component_identifies_minimum(self) -> None: - """Test get_lowest_component returns the lowest scoring component.""" - # Arrange - scores = ComponentScores( - vision_alignment=80.0, - strategy_alignment=60.0, - operations_alignment=75.0, - culture_alignment=85.0, - ) - - # Act - name, score = scores.get_lowest_component() - - # Assert - assert name == "strategy" - assert score == 60.0 - - def test_get_highest_component_identifies_maximum(self) -> None: - """Test get_highest_component returns the highest scoring component.""" - # Arrange - scores = ComponentScores( - vision_alignment=80.0, - strategy_alignment=70.0, - operations_alignment=95.0, - culture_alignment=85.0, - ) - - # Act - name, score = scores.get_highest_component() - - # Assert - assert name == "operations" - assert score == 95.0 - - -class TestFoundationAlignment: - """Test suite for FoundationAlignment value object.""" - - def test_create_foundation_alignment_with_valid_values(self) -> None: - """Test creating FoundationAlignment with valid values.""" - # Arrange & Act - foundation = FoundationAlignment( - purpose_alignment=90.0, - values_alignment=85.0, - mission_alignment=88.0, - ) - - # Assert - assert foundation.purpose_alignment == 90.0 - assert foundation.values_alignment == 85.0 - assert foundation.mission_alignment == 88.0 - - def test_foundation_alignment_with_score_below_zero_raises_error( - self, - ) -> None: - """Test that score below 0 raises ValidationError.""" - # Arrange & Act & Assert - with pytest.raises(ValidationError): - FoundationAlignment( - purpose_alignment=-1.0, - values_alignment=85.0, - mission_alignment=88.0, - ) - - def test_foundation_alignment_with_score_above_100_raises_error( - self, - ) -> None: - """Test that score above 100 raises ValidationError.""" - # Arrange & Act & Assert - with pytest.raises(ValidationError): - FoundationAlignment( - purpose_alignment=101.0, - values_alignment=85.0, - mission_alignment=88.0, - ) - - def test_foundation_alignment_is_immutable(self) -> None: - """Test that FoundationAlignment is immutable.""" - # Arrange - foundation = FoundationAlignment( - purpose_alignment=90.0, - values_alignment=85.0, - mission_alignment=88.0, - ) - - # Act & Assert - with pytest.raises(ValidationError): - foundation.purpose_alignment = 95.0 # type: ignore - - def test_get_average_score_returns_correct_average(self) -> None: - """Test get_average_score calculates correctly.""" - # Arrange - foundation = FoundationAlignment( - purpose_alignment=90.0, - values_alignment=80.0, - mission_alignment=85.0, - ) - - # Act - average = foundation.get_average_score() - - # Assert - assert average == 85.0 - - def test_is_well_aligned_returns_true_above_threshold(self) -> None: - """Test is_well_aligned returns True when above threshold.""" - # Arrange - foundation = FoundationAlignment( - purpose_alignment=80.0, - values_alignment=75.0, - mission_alignment=85.0, - ) - - # Act & Assert - assert foundation.is_well_aligned(70.0) is True - assert foundation.is_well_aligned(85.0) is False - - def test_is_well_aligned_uses_default_threshold(self) -> None: - """Test is_well_aligned uses default threshold of 70.""" - # Arrange - foundation = FoundationAlignment( - purpose_alignment=75.0, - values_alignment=70.0, - mission_alignment=70.0, - ) - - # Act & Assert - assert foundation.is_well_aligned() is True - - -class TestAlignmentScore: - """Test suite for AlignmentScore value object.""" - - @pytest.fixture - def sample_component_scores(self) -> ComponentScores: - """Fixture providing sample component scores.""" - return ComponentScores( - vision_alignment=85.0, - strategy_alignment=78.0, - operations_alignment=72.0, - culture_alignment=80.0, - ) - - @pytest.fixture - def sample_foundation(self) -> FoundationAlignment: - """Fixture providing sample foundation alignment.""" - return FoundationAlignment( - purpose_alignment=90.0, - values_alignment=88.0, - mission_alignment=85.0, - ) - - def test_create_alignment_score_with_all_fields( - self, - sample_component_scores: ComponentScores, - sample_foundation: FoundationAlignment, - ) -> None: - """Test creating AlignmentScore with all fields.""" - # Arrange & Act - score = AlignmentScore( - overall_score=81.0, - component_scores=sample_component_scores, - foundation_alignment=sample_foundation, - confidence_level=85.0, - explanation="Strong alignment with minor gaps in operations", - ) - - # Assert - assert score.overall_score == 81.0 - assert score.component_scores == sample_component_scores - assert score.foundation_alignment == sample_foundation - assert score.confidence_level == 85.0 - assert score.explanation == "Strong alignment with minor gaps in operations" - - def test_alignment_score_uses_default_confidence( - self, - sample_component_scores: ComponentScores, - sample_foundation: FoundationAlignment, - ) -> None: - """Test that confidence_level defaults to 80.0.""" - # Arrange & Act - score = AlignmentScore( - overall_score=75.0, - component_scores=sample_component_scores, - foundation_alignment=sample_foundation, - explanation="Moderate alignment", - ) - - # Assert - assert score.confidence_level == 80.0 - - def test_alignment_score_with_empty_explanation_raises_error( - self, - sample_component_scores: ComponentScores, - sample_foundation: FoundationAlignment, - ) -> None: - """Test that empty explanation raises ValidationError.""" - # Arrange & Act & Assert - with pytest.raises(ValidationError): - AlignmentScore( - overall_score=75.0, - component_scores=sample_component_scores, - foundation_alignment=sample_foundation, - explanation="", - ) - - def test_alignment_score_with_whitespace_only_explanation_raises_error( - self, - sample_component_scores: ComponentScores, - sample_foundation: FoundationAlignment, - ) -> None: - """Test that whitespace-only explanation raises ValidationError.""" - # Arrange & Act & Assert - with pytest.raises(ValidationError): - AlignmentScore( - overall_score=75.0, - component_scores=sample_component_scores, - foundation_alignment=sample_foundation, - explanation=" ", - ) - - def test_alignment_score_strips_whitespace_from_explanation( - self, - sample_component_scores: ComponentScores, - sample_foundation: FoundationAlignment, - ) -> None: - """Test that explanation whitespace is stripped.""" - # Arrange & Act - score = AlignmentScore( - overall_score=75.0, - component_scores=sample_component_scores, - foundation_alignment=sample_foundation, - explanation=" Good alignment ", - ) - - # Assert - assert score.explanation == "Good alignment" - - def test_alignment_score_is_immutable( - self, - sample_component_scores: ComponentScores, - sample_foundation: FoundationAlignment, - ) -> None: - """Test that AlignmentScore is immutable.""" - # Arrange - score = AlignmentScore( - overall_score=75.0, - component_scores=sample_component_scores, - foundation_alignment=sample_foundation, - explanation="Test explanation for immutability", - ) - - # Act & Assert - with pytest.raises(ValidationError): - score.overall_score = 85.0 # type: ignore - - def test_is_strong_alignment_returns_true_above_threshold( - self, - sample_component_scores: ComponentScores, - sample_foundation: FoundationAlignment, - ) -> None: - """Test is_strong_alignment with score above threshold.""" - # Arrange - score = AlignmentScore( - overall_score=85.0, - component_scores=sample_component_scores, - foundation_alignment=sample_foundation, - explanation="Strong alignment", - ) - - # Act & Assert - assert score.is_strong_alignment(80.0) is True - assert score.is_strong_alignment(90.0) is False - - def test_is_weak_alignment_returns_true_below_threshold( - self, - sample_component_scores: ComponentScores, - sample_foundation: FoundationAlignment, - ) -> None: - """Test is_weak_alignment with score below threshold.""" - # Arrange - score = AlignmentScore( - overall_score=45.0, - component_scores=sample_component_scores, - foundation_alignment=sample_foundation, - explanation="Weak alignment", - ) - - # Act & Assert - assert score.is_weak_alignment(50.0) is True - assert score.is_weak_alignment(40.0) is False - - def test_has_high_confidence_checks_confidence_level( - self, - sample_component_scores: ComponentScores, - sample_foundation: FoundationAlignment, - ) -> None: - """Test has_high_confidence checks confidence level.""" - # Arrange - score = AlignmentScore( - overall_score=75.0, - component_scores=sample_component_scores, - foundation_alignment=sample_foundation, - confidence_level=85.0, - explanation="Test confidence level", - ) - - # Act & Assert - assert score.has_high_confidence(80.0) is True - assert score.has_high_confidence(90.0) is False - - def test_get_gap_areas_identifies_low_components( - self, - sample_foundation: FoundationAlignment, - ) -> None: - """Test get_gap_areas identifies components below threshold.""" - # Arrange - components = ComponentScores( - vision_alignment=85.0, - strategy_alignment=65.0, - operations_alignment=60.0, - culture_alignment=75.0, - ) - score = AlignmentScore( - overall_score=71.0, - component_scores=components, - foundation_alignment=sample_foundation, - explanation="Mixed alignment", - ) - - # Act - gaps = score.get_gap_areas(70.0) - - # Assert - assert len(gaps) == 2 - assert "strategy" in gaps - assert "operations" in gaps - - def test_get_gap_areas_returns_empty_when_all_above_threshold( - self, - sample_component_scores: ComponentScores, - sample_foundation: FoundationAlignment, - ) -> None: - """Test get_gap_areas returns empty when all scores high.""" - # Arrange - score = AlignmentScore( - overall_score=81.0, - component_scores=sample_component_scores, - foundation_alignment=sample_foundation, - explanation="Strong alignment", - ) - - # Act - gaps = score.get_gap_areas(70.0) - - # Assert - assert len(gaps) == 0 +"""Unit tests for alignment score value objects.""" + +import pytest +from pydantic import ValidationError + +from coaching.src.domain.value_objects.alignment_score import ( + AlignmentScore, + ComponentScores, + FoundationAlignment, +) + + +class TestComponentScores: + """Test suite for ComponentScores value object.""" + + def test_create_component_scores_with_valid_values(self) -> None: + """Test creating ComponentScores with valid values.""" + # Arrange & Act + scores = ComponentScores( + vision_alignment=85.0, + strategy_alignment=78.0, + operations_alignment=72.0, + culture_alignment=80.0, + ) + + # Assert + assert scores.vision_alignment == 85.0 + assert scores.strategy_alignment == 78.0 + assert scores.operations_alignment == 72.0 + assert scores.culture_alignment == 80.0 + + def test_component_scores_with_score_below_zero_raises_error(self) -> None: + """Test that score below 0 raises ValidationError.""" + # Arrange & Act & Assert + with pytest.raises(ValidationError): + ComponentScores( + vision_alignment=-1.0, + strategy_alignment=80.0, + operations_alignment=75.0, + culture_alignment=70.0, + ) + + def test_component_scores_with_score_above_100_raises_error( + self, + ) -> None: + """Test that score above 100 raises ValidationError.""" + # Arrange & Act & Assert + with pytest.raises(ValidationError): + ComponentScores( + vision_alignment=101.0, + strategy_alignment=80.0, + operations_alignment=75.0, + culture_alignment=70.0, + ) + + def test_component_scores_is_immutable(self) -> None: + """Test that ComponentScores is immutable.""" + # Arrange + scores = ComponentScores( + vision_alignment=85.0, + strategy_alignment=78.0, + operations_alignment=72.0, + culture_alignment=80.0, + ) + + # Act & Assert + with pytest.raises(ValidationError): + scores.vision_alignment = 90.0 # type: ignore + + def test_get_average_score_returns_correct_average(self) -> None: + """Test get_average_score calculates correctly.""" + # Arrange + scores = ComponentScores( + vision_alignment=80.0, + strategy_alignment=70.0, + operations_alignment=60.0, + culture_alignment=90.0, + ) + + # Act + average = scores.get_average_score() + + # Assert + assert average == 75.0 + + def test_get_lowest_component_identifies_minimum(self) -> None: + """Test get_lowest_component returns the lowest scoring component.""" + # Arrange + scores = ComponentScores( + vision_alignment=80.0, + strategy_alignment=60.0, + operations_alignment=75.0, + culture_alignment=85.0, + ) + + # Act + name, score = scores.get_lowest_component() + + # Assert + assert name == "strategy" + assert score == 60.0 + + def test_get_highest_component_identifies_maximum(self) -> None: + """Test get_highest_component returns the highest scoring component.""" + # Arrange + scores = ComponentScores( + vision_alignment=80.0, + strategy_alignment=70.0, + operations_alignment=95.0, + culture_alignment=85.0, + ) + + # Act + name, score = scores.get_highest_component() + + # Assert + assert name == "operations" + assert score == 95.0 + + +class TestFoundationAlignment: + """Test suite for FoundationAlignment value object.""" + + def test_create_foundation_alignment_with_valid_values(self) -> None: + """Test creating FoundationAlignment with valid values.""" + # Arrange & Act + foundation = FoundationAlignment( + purpose_alignment=90.0, + values_alignment=85.0, + mission_alignment=88.0, + ) + + # Assert + assert foundation.purpose_alignment == 90.0 + assert foundation.values_alignment == 85.0 + assert foundation.mission_alignment == 88.0 + + def test_foundation_alignment_with_score_below_zero_raises_error( + self, + ) -> None: + """Test that score below 0 raises ValidationError.""" + # Arrange & Act & Assert + with pytest.raises(ValidationError): + FoundationAlignment( + purpose_alignment=-1.0, + values_alignment=85.0, + mission_alignment=88.0, + ) + + def test_foundation_alignment_with_score_above_100_raises_error( + self, + ) -> None: + """Test that score above 100 raises ValidationError.""" + # Arrange & Act & Assert + with pytest.raises(ValidationError): + FoundationAlignment( + purpose_alignment=101.0, + values_alignment=85.0, + mission_alignment=88.0, + ) + + def test_foundation_alignment_is_immutable(self) -> None: + """Test that FoundationAlignment is immutable.""" + # Arrange + foundation = FoundationAlignment( + purpose_alignment=90.0, + values_alignment=85.0, + mission_alignment=88.0, + ) + + # Act & Assert + with pytest.raises(ValidationError): + foundation.purpose_alignment = 95.0 # type: ignore + + def test_get_average_score_returns_correct_average(self) -> None: + """Test get_average_score calculates correctly.""" + # Arrange + foundation = FoundationAlignment( + purpose_alignment=90.0, + values_alignment=80.0, + mission_alignment=85.0, + ) + + # Act + average = foundation.get_average_score() + + # Assert + assert average == 85.0 + + def test_is_well_aligned_returns_true_above_threshold(self) -> None: + """Test is_well_aligned returns True when above threshold.""" + # Arrange + foundation = FoundationAlignment( + purpose_alignment=80.0, + values_alignment=75.0, + mission_alignment=85.0, + ) + + # Act & Assert + assert foundation.is_well_aligned(70.0) is True + assert foundation.is_well_aligned(85.0) is False + + def test_is_well_aligned_uses_default_threshold(self) -> None: + """Test is_well_aligned uses default threshold of 70.""" + # Arrange + foundation = FoundationAlignment( + purpose_alignment=75.0, + values_alignment=70.0, + mission_alignment=70.0, + ) + + # Act & Assert + assert foundation.is_well_aligned() is True + + +class TestAlignmentScore: + """Test suite for AlignmentScore value object.""" + + @pytest.fixture + def sample_component_scores(self) -> ComponentScores: + """Fixture providing sample component scores.""" + return ComponentScores( + vision_alignment=85.0, + strategy_alignment=78.0, + operations_alignment=72.0, + culture_alignment=80.0, + ) + + @pytest.fixture + def sample_foundation(self) -> FoundationAlignment: + """Fixture providing sample foundation alignment.""" + return FoundationAlignment( + purpose_alignment=90.0, + values_alignment=88.0, + mission_alignment=85.0, + ) + + def test_create_alignment_score_with_all_fields( + self, + sample_component_scores: ComponentScores, + sample_foundation: FoundationAlignment, + ) -> None: + """Test creating AlignmentScore with all fields.""" + # Arrange & Act + score = AlignmentScore( + overall_score=81.0, + component_scores=sample_component_scores, + foundation_alignment=sample_foundation, + confidence_level=85.0, + explanation="Strong alignment with minor gaps in operations", + ) + + # Assert + assert score.overall_score == 81.0 + assert score.component_scores == sample_component_scores + assert score.foundation_alignment == sample_foundation + assert score.confidence_level == 85.0 + assert score.explanation == "Strong alignment with minor gaps in operations" + + def test_alignment_score_uses_default_confidence( + self, + sample_component_scores: ComponentScores, + sample_foundation: FoundationAlignment, + ) -> None: + """Test that confidence_level defaults to 80.0.""" + # Arrange & Act + score = AlignmentScore( + overall_score=75.0, + component_scores=sample_component_scores, + foundation_alignment=sample_foundation, + explanation="Moderate alignment", + ) + + # Assert + assert score.confidence_level == 80.0 + + def test_alignment_score_with_empty_explanation_raises_error( + self, + sample_component_scores: ComponentScores, + sample_foundation: FoundationAlignment, + ) -> None: + """Test that empty explanation raises ValidationError.""" + # Arrange & Act & Assert + with pytest.raises(ValidationError): + AlignmentScore( + overall_score=75.0, + component_scores=sample_component_scores, + foundation_alignment=sample_foundation, + explanation="", + ) + + def test_alignment_score_with_whitespace_only_explanation_raises_error( + self, + sample_component_scores: ComponentScores, + sample_foundation: FoundationAlignment, + ) -> None: + """Test that whitespace-only explanation raises ValidationError.""" + # Arrange & Act & Assert + with pytest.raises(ValidationError): + AlignmentScore( + overall_score=75.0, + component_scores=sample_component_scores, + foundation_alignment=sample_foundation, + explanation=" ", + ) + + def test_alignment_score_strips_whitespace_from_explanation( + self, + sample_component_scores: ComponentScores, + sample_foundation: FoundationAlignment, + ) -> None: + """Test that explanation whitespace is stripped.""" + # Arrange & Act + score = AlignmentScore( + overall_score=75.0, + component_scores=sample_component_scores, + foundation_alignment=sample_foundation, + explanation=" Good alignment ", + ) + + # Assert + assert score.explanation == "Good alignment" + + def test_alignment_score_is_immutable( + self, + sample_component_scores: ComponentScores, + sample_foundation: FoundationAlignment, + ) -> None: + """Test that AlignmentScore is immutable.""" + # Arrange + score = AlignmentScore( + overall_score=75.0, + component_scores=sample_component_scores, + foundation_alignment=sample_foundation, + explanation="Test explanation for immutability", + ) + + # Act & Assert + with pytest.raises(ValidationError): + score.overall_score = 85.0 # type: ignore + + def test_is_strong_alignment_returns_true_above_threshold( + self, + sample_component_scores: ComponentScores, + sample_foundation: FoundationAlignment, + ) -> None: + """Test is_strong_alignment with score above threshold.""" + # Arrange + score = AlignmentScore( + overall_score=85.0, + component_scores=sample_component_scores, + foundation_alignment=sample_foundation, + explanation="Strong alignment", + ) + + # Act & Assert + assert score.is_strong_alignment(80.0) is True + assert score.is_strong_alignment(90.0) is False + + def test_is_weak_alignment_returns_true_below_threshold( + self, + sample_component_scores: ComponentScores, + sample_foundation: FoundationAlignment, + ) -> None: + """Test is_weak_alignment with score below threshold.""" + # Arrange + score = AlignmentScore( + overall_score=45.0, + component_scores=sample_component_scores, + foundation_alignment=sample_foundation, + explanation="Weak alignment", + ) + + # Act & Assert + assert score.is_weak_alignment(50.0) is True + assert score.is_weak_alignment(40.0) is False + + def test_has_high_confidence_checks_confidence_level( + self, + sample_component_scores: ComponentScores, + sample_foundation: FoundationAlignment, + ) -> None: + """Test has_high_confidence checks confidence level.""" + # Arrange + score = AlignmentScore( + overall_score=75.0, + component_scores=sample_component_scores, + foundation_alignment=sample_foundation, + confidence_level=85.0, + explanation="Test confidence level", + ) + + # Act & Assert + assert score.has_high_confidence(80.0) is True + assert score.has_high_confidence(90.0) is False + + def test_get_gap_areas_identifies_low_components( + self, + sample_foundation: FoundationAlignment, + ) -> None: + """Test get_gap_areas identifies components below threshold.""" + # Arrange + components = ComponentScores( + vision_alignment=85.0, + strategy_alignment=65.0, + operations_alignment=60.0, + culture_alignment=75.0, + ) + score = AlignmentScore( + overall_score=71.0, + component_scores=components, + foundation_alignment=sample_foundation, + explanation="Mixed alignment", + ) + + # Act + gaps = score.get_gap_areas(70.0) + + # Assert + assert len(gaps) == 2 + assert "strategy" in gaps + assert "operations" in gaps + + def test_get_gap_areas_returns_empty_when_all_above_threshold( + self, + sample_component_scores: ComponentScores, + sample_foundation: FoundationAlignment, + ) -> None: + """Test get_gap_areas returns empty when all scores high.""" + # Arrange + score = AlignmentScore( + overall_score=81.0, + component_scores=sample_component_scores, + foundation_alignment=sample_foundation, + explanation="Strong alignment", + ) + + # Act + gaps = score.get_gap_areas(70.0) + + # Assert + assert len(gaps) == 0 diff --git a/coaching/tests/unit/domain/value_objects/test_conversation_context.py b/coaching/tests/unit/domain/value_objects/test_conversation_context.py index 7506a290..0f329a7d 100644 --- a/coaching/tests/unit/domain/value_objects/test_conversation_context.py +++ b/coaching/tests/unit/domain/value_objects/test_conversation_context.py @@ -1,344 +1,345 @@ -"""Unit tests for ConversationContext value object.""" - -import pytest -from coaching.src.core.constants import ConversationPhase -from coaching.src.domain.value_objects.conversation_context import ( - ConversationContext, -) -from pydantic import ValidationError - -pytestmark = pytest.mark.unit - - -class TestConversationContextCreation: - """Test suite for ConversationContext creation.""" - - def test_create_context_with_required_fields_only(self) -> None: - """Test creating context with only required field.""" - # Arrange & Act - context = ConversationContext(current_phase=ConversationPhase.INTRODUCTION) - - # Assert - assert context.current_phase == ConversationPhase.INTRODUCTION - assert context.insights == [] - assert context.response_count == 0 - assert context.progress_percentage == 0.0 - assert context.metadata == {} - - def test_create_context_with_all_fields(self) -> None: - """Test creating context with all fields.""" - # Arrange - insights = ["Values autonomy", "Seeks growth"] - metadata = {"category": "personal"} - - # Act - context = ConversationContext( - current_phase=ConversationPhase.EXPLORATION, - insights=insights, - response_count=5, - progress_percentage=30.0, - metadata=metadata, - ) - - # Assert - assert context.current_phase == ConversationPhase.EXPLORATION - assert context.insights == insights - assert context.response_count == 5 - assert context.progress_percentage == 30.0 - assert context.metadata == metadata - - def test_create_context_strips_whitespace_from_insights(self) -> None: - """Test that whitespace is stripped from insights.""" - # Arrange & Act - context = ConversationContext( - current_phase=ConversationPhase.DEEPENING, - insights=[" insight 1 ", "insight 2 "], - ) - - # Assert - assert context.insights == ["insight 1", "insight 2"] - - -class TestConversationContextValidation: - """Test suite for ConversationContext validation.""" - - def test_create_context_with_negative_response_count_raises_error( - self, - ) -> None: - """Test that negative response count raises error.""" - # Arrange & Act & Assert - with pytest.raises(ValidationError): - ConversationContext( - current_phase=ConversationPhase.INTRODUCTION, - response_count=-1, - ) - - def test_create_context_with_progress_below_zero_raises_error( - self, - ) -> None: - """Test that progress below 0 raises error.""" - # Arrange & Act & Assert - with pytest.raises(ValidationError): - ConversationContext( - current_phase=ConversationPhase.INTRODUCTION, - progress_percentage=-0.1, - ) - - def test_create_context_with_progress_above_100_raises_error( - self, - ) -> None: - """Test that progress above 100 raises error.""" - # Arrange & Act & Assert - with pytest.raises(ValidationError): - ConversationContext( - current_phase=ConversationPhase.COMPLETION, - progress_percentage=100.1, - ) - - def test_create_context_with_empty_insight_raises_error(self) -> None: - """Test that empty insight string raises error.""" - # Arrange & Act & Assert - with pytest.raises(ValidationError): - ConversationContext( - current_phase=ConversationPhase.DEEPENING, - insights=["Valid insight", ""], - ) - - def test_create_context_with_whitespace_only_insight_raises_error( - self, - ) -> None: - """Test that whitespace-only insight raises error.""" - # Arrange & Act & Assert - with pytest.raises(ValidationError): - ConversationContext( - current_phase=ConversationPhase.DEEPENING, - insights=["Valid insight", " "], - ) - - def test_create_context_with_invalid_phase_raises_error(self) -> None: - """Test that invalid phase raises error.""" - # Arrange & Act & Assert - with pytest.raises(ValidationError): - ConversationContext(current_phase="invalid_phase") # type: ignore - - -class TestConversationContextImmutability: - """Test suite for ConversationContext immutability.""" - - def test_context_is_immutable(self) -> None: - """Test that context fields cannot be modified.""" - # Arrange - context = ConversationContext(current_phase=ConversationPhase.EXPLORATION) - - # Act & Assert - with pytest.raises(ValidationError): - context.current_phase = ConversationPhase.DEEPENING # type: ignore - - def test_context_response_count_cannot_be_changed(self) -> None: - """Test that response count cannot be changed.""" - # Arrange - context = ConversationContext(current_phase=ConversationPhase.EXPLORATION, response_count=5) - - # Act & Assert - with pytest.raises(ValidationError): - context.response_count = 10 # type: ignore - - def test_context_progress_cannot_be_changed(self) -> None: - """Test that progress percentage cannot be changed.""" - # Arrange - context = ConversationContext( - current_phase=ConversationPhase.SYNTHESIS, - progress_percentage=50.0, - ) - - # Act & Assert - with pytest.raises(ValidationError): - context.progress_percentage = 75.0 # type: ignore - - -class TestConversationContextMethods: - """Test suite for ConversationContext utility methods.""" - - def test_has_insights_returns_false_when_empty(self) -> None: - """Test has_insights returns False for empty insights.""" - # Arrange - context = ConversationContext(current_phase=ConversationPhase.INTRODUCTION) - - # Act & Assert - assert context.has_insights() is False - - def test_has_insights_returns_true_when_present(self) -> None: - """Test has_insights returns True when insights exist.""" - # Arrange - context = ConversationContext( - current_phase=ConversationPhase.DEEPENING, - insights=["Insight 1"], - ) - - # Act & Assert - assert context.has_insights() is True - - def test_get_insight_count_returns_correct_count(self) -> None: - """Test get_insight_count returns the correct number.""" - # Arrange - insights = ["Insight 1", "Insight 2", "Insight 3"] - context = ConversationContext(current_phase=ConversationPhase.SYNTHESIS, insights=insights) - - # Act - count = context.get_insight_count() - - # Assert - assert count == 3 - - def test_is_in_phase_returns_true_for_current_phase(self) -> None: - """Test is_in_phase returns True for current phase.""" - # Arrange - context = ConversationContext(current_phase=ConversationPhase.EXPLORATION) - - # Act & Assert - assert context.is_in_phase(ConversationPhase.EXPLORATION) is True - assert context.is_in_phase(ConversationPhase.DEEPENING) is False - - def test_has_sufficient_responses_checks_minimum(self) -> None: - """Test has_sufficient_responses compares against minimum.""" - # Arrange - context = ConversationContext(current_phase=ConversationPhase.DEEPENING, response_count=5) - - # Act & Assert - assert context.has_sufficient_responses(3) is True - assert context.has_sufficient_responses(5) is True - assert context.has_sufficient_responses(10) is False - - def test_is_complete_returns_true_at_100_percent(self) -> None: - """Test is_complete returns True when progress is 100%.""" - # Arrange - context = ConversationContext( - current_phase=ConversationPhase.COMPLETION, - progress_percentage=100.0, - ) - - # Act & Assert - assert context.is_complete() is True - - def test_is_complete_returns_false_below_100_percent(self) -> None: - """Test is_complete returns False when progress < 100%.""" - # Arrange - context = ConversationContext( - current_phase=ConversationPhase.VALIDATION, - progress_percentage=99.9, - ) - - # Act & Assert - assert context.is_complete() is False - - -class TestConversationContextPhaseChecks: - """Test suite for phase checking methods.""" - - def test_is_introduction_phase_returns_true_for_introduction( - self, - ) -> None: - """Test is_introduction_phase for introduction phase.""" - # Arrange - context = ConversationContext(current_phase=ConversationPhase.INTRODUCTION) - - # Act & Assert - assert context.is_introduction_phase() is True - assert context.is_exploration_phase() is False - - def test_is_exploration_phase_returns_true_for_exploration( - self, - ) -> None: - """Test is_exploration_phase for exploration phase.""" - # Arrange - context = ConversationContext(current_phase=ConversationPhase.EXPLORATION) - - # Act & Assert - assert context.is_exploration_phase() is True - assert context.is_deepening_phase() is False - - def test_is_deepening_phase_returns_true_for_deepening(self) -> None: - """Test is_deepening_phase for deepening phase.""" - # Arrange - context = ConversationContext(current_phase=ConversationPhase.DEEPENING) - - # Act & Assert - assert context.is_deepening_phase() is True - assert context.is_synthesis_phase() is False - - def test_is_synthesis_phase_returns_true_for_synthesis(self) -> None: - """Test is_synthesis_phase for synthesis phase.""" - # Arrange - context = ConversationContext(current_phase=ConversationPhase.SYNTHESIS) - - # Act & Assert - assert context.is_synthesis_phase() is True - assert context.is_validation_phase() is False - - def test_is_validation_phase_returns_true_for_validation(self) -> None: - """Test is_validation_phase for validation phase.""" - # Arrange - context = ConversationContext(current_phase=ConversationPhase.VALIDATION) - - # Act & Assert - assert context.is_validation_phase() is True - assert context.is_completion_phase() is False - - def test_is_completion_phase_returns_true_for_completion(self) -> None: - """Test is_completion_phase for completion phase.""" - # Arrange - context = ConversationContext(current_phase=ConversationPhase.COMPLETION) - - # Act & Assert - assert context.is_completion_phase() is True - assert context.is_introduction_phase() is False - - -class TestConversationContextEdgeCases: - """Test suite for edge cases and boundary conditions.""" - - def test_context_with_zero_progress(self) -> None: - """Test context with exactly 0% progress.""" - # Arrange & Act - context = ConversationContext( - current_phase=ConversationPhase.INTRODUCTION, - progress_percentage=0.0, - ) - - # Assert - assert context.progress_percentage == 0.0 - assert context.is_complete() is False - - def test_context_with_100_progress(self) -> None: - """Test context with exactly 100% progress.""" - # Arrange & Act - context = ConversationContext( - current_phase=ConversationPhase.COMPLETION, - progress_percentage=100.0, - ) - - # Assert - assert context.progress_percentage == 100.0 - assert context.is_complete() is True - - def test_context_with_zero_responses(self) -> None: - """Test context with zero responses.""" - # Arrange & Act - context = ConversationContext( - current_phase=ConversationPhase.INTRODUCTION, response_count=0 - ) - - # Assert - assert context.response_count == 0 - assert context.has_sufficient_responses(0) is True - assert context.has_sufficient_responses(1) is False - - def test_context_with_empty_insights_list(self) -> None: - """Test context with explicitly empty insights list.""" - # Arrange & Act - context = ConversationContext(current_phase=ConversationPhase.INTRODUCTION, insights=[]) - - # Assert - assert context.insights == [] - assert context.has_insights() is False - assert context.get_insight_count() == 0 +"""Unit tests for ConversationContext value object.""" + +import pytest +from pydantic import ValidationError + +from coaching.src.core.constants import ConversationPhase +from coaching.src.domain.value_objects.conversation_context import ( + ConversationContext, +) + +pytestmark = pytest.mark.unit + + +class TestConversationContextCreation: + """Test suite for ConversationContext creation.""" + + def test_create_context_with_required_fields_only(self) -> None: + """Test creating context with only required field.""" + # Arrange & Act + context = ConversationContext(current_phase=ConversationPhase.INTRODUCTION) + + # Assert + assert context.current_phase == ConversationPhase.INTRODUCTION + assert context.insights == [] + assert context.response_count == 0 + assert context.progress_percentage == 0.0 + assert context.metadata == {} + + def test_create_context_with_all_fields(self) -> None: + """Test creating context with all fields.""" + # Arrange + insights = ["Values autonomy", "Seeks growth"] + metadata = {"category": "personal"} + + # Act + context = ConversationContext( + current_phase=ConversationPhase.EXPLORATION, + insights=insights, + response_count=5, + progress_percentage=30.0, + metadata=metadata, + ) + + # Assert + assert context.current_phase == ConversationPhase.EXPLORATION + assert context.insights == insights + assert context.response_count == 5 + assert context.progress_percentage == 30.0 + assert context.metadata == metadata + + def test_create_context_strips_whitespace_from_insights(self) -> None: + """Test that whitespace is stripped from insights.""" + # Arrange & Act + context = ConversationContext( + current_phase=ConversationPhase.DEEPENING, + insights=[" insight 1 ", "insight 2 "], + ) + + # Assert + assert context.insights == ["insight 1", "insight 2"] + + +class TestConversationContextValidation: + """Test suite for ConversationContext validation.""" + + def test_create_context_with_negative_response_count_raises_error( + self, + ) -> None: + """Test that negative response count raises error.""" + # Arrange & Act & Assert + with pytest.raises(ValidationError): + ConversationContext( + current_phase=ConversationPhase.INTRODUCTION, + response_count=-1, + ) + + def test_create_context_with_progress_below_zero_raises_error( + self, + ) -> None: + """Test that progress below 0 raises error.""" + # Arrange & Act & Assert + with pytest.raises(ValidationError): + ConversationContext( + current_phase=ConversationPhase.INTRODUCTION, + progress_percentage=-0.1, + ) + + def test_create_context_with_progress_above_100_raises_error( + self, + ) -> None: + """Test that progress above 100 raises error.""" + # Arrange & Act & Assert + with pytest.raises(ValidationError): + ConversationContext( + current_phase=ConversationPhase.COMPLETION, + progress_percentage=100.1, + ) + + def test_create_context_with_empty_insight_raises_error(self) -> None: + """Test that empty insight string raises error.""" + # Arrange & Act & Assert + with pytest.raises(ValidationError): + ConversationContext( + current_phase=ConversationPhase.DEEPENING, + insights=["Valid insight", ""], + ) + + def test_create_context_with_whitespace_only_insight_raises_error( + self, + ) -> None: + """Test that whitespace-only insight raises error.""" + # Arrange & Act & Assert + with pytest.raises(ValidationError): + ConversationContext( + current_phase=ConversationPhase.DEEPENING, + insights=["Valid insight", " "], + ) + + def test_create_context_with_invalid_phase_raises_error(self) -> None: + """Test that invalid phase raises error.""" + # Arrange & Act & Assert + with pytest.raises(ValidationError): + ConversationContext(current_phase="invalid_phase") # type: ignore + + +class TestConversationContextImmutability: + """Test suite for ConversationContext immutability.""" + + def test_context_is_immutable(self) -> None: + """Test that context fields cannot be modified.""" + # Arrange + context = ConversationContext(current_phase=ConversationPhase.EXPLORATION) + + # Act & Assert + with pytest.raises(ValidationError): + context.current_phase = ConversationPhase.DEEPENING # type: ignore + + def test_context_response_count_cannot_be_changed(self) -> None: + """Test that response count cannot be changed.""" + # Arrange + context = ConversationContext(current_phase=ConversationPhase.EXPLORATION, response_count=5) + + # Act & Assert + with pytest.raises(ValidationError): + context.response_count = 10 # type: ignore + + def test_context_progress_cannot_be_changed(self) -> None: + """Test that progress percentage cannot be changed.""" + # Arrange + context = ConversationContext( + current_phase=ConversationPhase.SYNTHESIS, + progress_percentage=50.0, + ) + + # Act & Assert + with pytest.raises(ValidationError): + context.progress_percentage = 75.0 # type: ignore + + +class TestConversationContextMethods: + """Test suite for ConversationContext utility methods.""" + + def test_has_insights_returns_false_when_empty(self) -> None: + """Test has_insights returns False for empty insights.""" + # Arrange + context = ConversationContext(current_phase=ConversationPhase.INTRODUCTION) + + # Act & Assert + assert context.has_insights() is False + + def test_has_insights_returns_true_when_present(self) -> None: + """Test has_insights returns True when insights exist.""" + # Arrange + context = ConversationContext( + current_phase=ConversationPhase.DEEPENING, + insights=["Insight 1"], + ) + + # Act & Assert + assert context.has_insights() is True + + def test_get_insight_count_returns_correct_count(self) -> None: + """Test get_insight_count returns the correct number.""" + # Arrange + insights = ["Insight 1", "Insight 2", "Insight 3"] + context = ConversationContext(current_phase=ConversationPhase.SYNTHESIS, insights=insights) + + # Act + count = context.get_insight_count() + + # Assert + assert count == 3 + + def test_is_in_phase_returns_true_for_current_phase(self) -> None: + """Test is_in_phase returns True for current phase.""" + # Arrange + context = ConversationContext(current_phase=ConversationPhase.EXPLORATION) + + # Act & Assert + assert context.is_in_phase(ConversationPhase.EXPLORATION) is True + assert context.is_in_phase(ConversationPhase.DEEPENING) is False + + def test_has_sufficient_responses_checks_minimum(self) -> None: + """Test has_sufficient_responses compares against minimum.""" + # Arrange + context = ConversationContext(current_phase=ConversationPhase.DEEPENING, response_count=5) + + # Act & Assert + assert context.has_sufficient_responses(3) is True + assert context.has_sufficient_responses(5) is True + assert context.has_sufficient_responses(10) is False + + def test_is_complete_returns_true_at_100_percent(self) -> None: + """Test is_complete returns True when progress is 100%.""" + # Arrange + context = ConversationContext( + current_phase=ConversationPhase.COMPLETION, + progress_percentage=100.0, + ) + + # Act & Assert + assert context.is_complete() is True + + def test_is_complete_returns_false_below_100_percent(self) -> None: + """Test is_complete returns False when progress < 100%.""" + # Arrange + context = ConversationContext( + current_phase=ConversationPhase.VALIDATION, + progress_percentage=99.9, + ) + + # Act & Assert + assert context.is_complete() is False + + +class TestConversationContextPhaseChecks: + """Test suite for phase checking methods.""" + + def test_is_introduction_phase_returns_true_for_introduction( + self, + ) -> None: + """Test is_introduction_phase for introduction phase.""" + # Arrange + context = ConversationContext(current_phase=ConversationPhase.INTRODUCTION) + + # Act & Assert + assert context.is_introduction_phase() is True + assert context.is_exploration_phase() is False + + def test_is_exploration_phase_returns_true_for_exploration( + self, + ) -> None: + """Test is_exploration_phase for exploration phase.""" + # Arrange + context = ConversationContext(current_phase=ConversationPhase.EXPLORATION) + + # Act & Assert + assert context.is_exploration_phase() is True + assert context.is_deepening_phase() is False + + def test_is_deepening_phase_returns_true_for_deepening(self) -> None: + """Test is_deepening_phase for deepening phase.""" + # Arrange + context = ConversationContext(current_phase=ConversationPhase.DEEPENING) + + # Act & Assert + assert context.is_deepening_phase() is True + assert context.is_synthesis_phase() is False + + def test_is_synthesis_phase_returns_true_for_synthesis(self) -> None: + """Test is_synthesis_phase for synthesis phase.""" + # Arrange + context = ConversationContext(current_phase=ConversationPhase.SYNTHESIS) + + # Act & Assert + assert context.is_synthesis_phase() is True + assert context.is_validation_phase() is False + + def test_is_validation_phase_returns_true_for_validation(self) -> None: + """Test is_validation_phase for validation phase.""" + # Arrange + context = ConversationContext(current_phase=ConversationPhase.VALIDATION) + + # Act & Assert + assert context.is_validation_phase() is True + assert context.is_completion_phase() is False + + def test_is_completion_phase_returns_true_for_completion(self) -> None: + """Test is_completion_phase for completion phase.""" + # Arrange + context = ConversationContext(current_phase=ConversationPhase.COMPLETION) + + # Act & Assert + assert context.is_completion_phase() is True + assert context.is_introduction_phase() is False + + +class TestConversationContextEdgeCases: + """Test suite for edge cases and boundary conditions.""" + + def test_context_with_zero_progress(self) -> None: + """Test context with exactly 0% progress.""" + # Arrange & Act + context = ConversationContext( + current_phase=ConversationPhase.INTRODUCTION, + progress_percentage=0.0, + ) + + # Assert + assert context.progress_percentage == 0.0 + assert context.is_complete() is False + + def test_context_with_100_progress(self) -> None: + """Test context with exactly 100% progress.""" + # Arrange & Act + context = ConversationContext( + current_phase=ConversationPhase.COMPLETION, + progress_percentage=100.0, + ) + + # Assert + assert context.progress_percentage == 100.0 + assert context.is_complete() is True + + def test_context_with_zero_responses(self) -> None: + """Test context with zero responses.""" + # Arrange & Act + context = ConversationContext( + current_phase=ConversationPhase.INTRODUCTION, response_count=0 + ) + + # Assert + assert context.response_count == 0 + assert context.has_sufficient_responses(0) is True + assert context.has_sufficient_responses(1) is False + + def test_context_with_empty_insights_list(self) -> None: + """Test context with explicitly empty insights list.""" + # Arrange & Act + context = ConversationContext(current_phase=ConversationPhase.INTRODUCTION, insights=[]) + + # Assert + assert context.insights == [] + assert context.has_insights() is False + assert context.get_insight_count() == 0 diff --git a/coaching/tests/unit/domain/value_objects/test_message.py b/coaching/tests/unit/domain/value_objects/test_message.py index 8981070d..58b41a1f 100644 --- a/coaching/tests/unit/domain/value_objects/test_message.py +++ b/coaching/tests/unit/domain/value_objects/test_message.py @@ -1,256 +1,257 @@ -"""Unit tests for Message value object.""" - -from datetime import UTC, datetime, timedelta - -import pytest -from coaching.src.core.constants import MessageRole -from coaching.src.domain.value_objects.message import Message -from pydantic import ValidationError - - -class TestMessageCreation: - """Test suite for Message creation and validation.""" - - def test_create_message_with_required_fields(self) -> None: - """Test creating a message with only required fields.""" - # Arrange & Act - message = Message(role=MessageRole.USER, content="Hello, coach!") - - # Assert - assert message.role == MessageRole.USER - assert message.content == "Hello, coach!" - assert message.message_id is not None - assert message.timestamp is not None - assert message.metadata == {} - - def test_create_message_with_all_fields(self) -> None: - """Test creating a message with all fields specified.""" - # Arrange - timestamp = datetime.now(UTC) - metadata = {"source": "web", "user_agent": "Mozilla"} - - # Act - message = Message( - role=MessageRole.ASSISTANT, - content="Let's explore your values.", - timestamp=timestamp, - metadata=metadata, - ) - - # Assert - assert message.role == MessageRole.ASSISTANT - assert message.content == "Let's explore your values." - assert message.timestamp == timestamp - assert message.metadata == metadata - - def test_create_message_auto_generates_id(self) -> None: - """Test that message ID is auto-generated if not provided.""" - # Arrange & Act - message1 = Message(role=MessageRole.USER, content="First message") - message2 = Message(role=MessageRole.USER, content="Second message") - - # Assert - assert message1.message_id is not None - assert message2.message_id is not None - assert message1.message_id != message2.message_id - - def test_create_message_auto_generates_timestamp(self) -> None: - """Test that timestamp is auto-generated if not provided.""" - # Arrange - before = datetime.now(UTC) - - # Act - message = Message(role=MessageRole.USER, content="Test message") - - # Assert - after = datetime.now(UTC) - assert before <= message.timestamp <= after - - def test_create_message_strips_whitespace_from_content(self) -> None: - """Test that leading/trailing whitespace is stripped from content.""" - # Arrange & Act - message = Message(role=MessageRole.USER, content=" Content with spaces ") - - # Assert - assert message.content == "Content with spaces" - - -class TestMessageValidation: - """Test suite for Message validation rules.""" - - def test_create_message_with_empty_content_raises_error(self) -> None: - """Test that empty content raises ValidationError.""" - # Arrange & Act & Assert - with pytest.raises(ValidationError): - Message(role=MessageRole.USER, content="") - - def test_create_message_with_whitespace_only_content_raises_error( - self, - ) -> None: - """Test that whitespace-only content raises ValidationError.""" - # Arrange & Act & Assert - with pytest.raises(ValidationError): - Message(role=MessageRole.USER, content=" ") - - def test_create_message_with_future_timestamp_raises_error(self) -> None: - """Test that future timestamp raises ValidationError.""" - # Arrange - future = datetime.now(UTC) + timedelta(hours=1) - - # Act & Assert - with pytest.raises(ValidationError): - Message(role=MessageRole.USER, content="Test", timestamp=future) - - def test_create_message_with_content_too_long_raises_error(self) -> None: - """Test that content exceeding max length raises ValidationError.""" - # Arrange - long_content = "x" * 10001 - - # Act & Assert - with pytest.raises(ValidationError): - Message(role=MessageRole.USER, content=long_content) - - def test_create_message_with_invalid_role_raises_error(self) -> None: - """Test that invalid role raises ValidationError.""" - # Arrange & Act & Assert - with pytest.raises(ValidationError): - Message(role="invalid_role", content="Test") # type: ignore - - -class TestMessageImmutability: - """Test suite for Message immutability.""" - - def test_message_is_immutable(self) -> None: - """Test that message fields cannot be modified after creation.""" - # Arrange - message = Message(role=MessageRole.USER, content="Original content") - - # Act & Assert - with pytest.raises(ValidationError): - message.content = "Modified content" # type: ignore - - def test_message_role_cannot_be_changed(self) -> None: - """Test that message role cannot be changed.""" - # Arrange - message = Message(role=MessageRole.USER, content="Test") - - # Act & Assert - with pytest.raises(ValidationError): - message.role = MessageRole.ASSISTANT # type: ignore - - def test_message_metadata_is_immutable(self) -> None: - """Test that message metadata dict is protected by frozen model.""" - # Arrange - metadata = {"key": "value"} - message = Message(role=MessageRole.USER, content="Test", metadata=metadata) - - # Act & Assert - Cannot reassign metadata - with pytest.raises(ValidationError): - message.metadata = {"new": "dict"} # type: ignore - - -class TestMessageRoleChecks: - """Test suite for message role checking methods.""" - - def test_is_from_user_returns_true_for_user_message(self) -> None: - """Test is_from_user returns True for user messages.""" - # Arrange - message = Message(role=MessageRole.USER, content="Test") - - # Act & Assert - assert message.is_from_user() is True - assert message.is_from_assistant() is False - assert message.is_system_message() is False - - def test_is_from_assistant_returns_true_for_assistant_message( - self, - ) -> None: - """Test is_from_assistant returns True for assistant messages.""" - # Arrange - message = Message(role=MessageRole.ASSISTANT, content="Test") - - # Act & Assert - assert message.is_from_user() is False - assert message.is_from_assistant() is True - assert message.is_system_message() is False - - def test_is_system_message_returns_true_for_system_message(self) -> None: - """Test is_system_message returns True for system messages.""" - # Arrange - message = Message(role=MessageRole.SYSTEM, content="Test") - - # Act & Assert - assert message.is_from_user() is False - assert message.is_from_assistant() is False - assert message.is_system_message() is True - - -class TestMessageMethods: - """Test suite for Message utility methods.""" - - def test_get_content_length_returns_correct_length(self) -> None: - """Test get_content_length returns the correct character count.""" - # Arrange - content = "This is a test message" - message = Message(role=MessageRole.USER, content=content) - - # Act - length = message.get_content_length() - - # Assert - assert length == len(content) - - def test_has_metadata_returns_false_for_empty_metadata(self) -> None: - """Test has_metadata returns False when metadata is empty.""" - # Arrange - message = Message(role=MessageRole.USER, content="Test") - - # Act & Assert - assert message.has_metadata() is False - - def test_has_metadata_returns_true_when_metadata_exists(self) -> None: - """Test has_metadata returns True when metadata is provided.""" - # Arrange - message = Message( - role=MessageRole.USER, - content="Test", - metadata={"source": "web"}, - ) - - # Act & Assert - assert message.has_metadata() is True - - -class TestMessageEquality: - """Test suite for Message equality.""" - - def test_messages_with_same_values_are_equal(self) -> None: - """Test that messages with identical values are equal.""" - # Arrange - timestamp = datetime.now(UTC) - message1 = Message( - role=MessageRole.USER, - content="Test", - timestamp=timestamp, - ) - message2 = Message( - role=MessageRole.USER, - content="Test", - timestamp=timestamp, - ) - - # Act & Assert - # Note: They won't be equal because message_id is auto-generated - assert message1.message_id != message2.message_id - assert message1 != message2 - - def test_messages_can_be_compared(self) -> None: - """Test that messages support equality comparison.""" - # Arrange - message1 = Message(role=MessageRole.USER, content="Test 1") - message2 = Message(role=MessageRole.USER, content="Test 2") - message3 = message1 - - # Act & Assert - assert message1 == message3 # Same instance - assert message1 != message2 # Different instances +"""Unit tests for Message value object.""" + +from datetime import UTC, datetime, timedelta + +import pytest +from pydantic import ValidationError + +from coaching.src.core.constants import MessageRole +from coaching.src.domain.value_objects.message import Message + + +class TestMessageCreation: + """Test suite for Message creation and validation.""" + + def test_create_message_with_required_fields(self) -> None: + """Test creating a message with only required fields.""" + # Arrange & Act + message = Message(role=MessageRole.USER, content="Hello, coach!") + + # Assert + assert message.role == MessageRole.USER + assert message.content == "Hello, coach!" + assert message.message_id is not None + assert message.timestamp is not None + assert message.metadata == {} + + def test_create_message_with_all_fields(self) -> None: + """Test creating a message with all fields specified.""" + # Arrange + timestamp = datetime.now(UTC) + metadata = {"source": "web", "user_agent": "Mozilla"} + + # Act + message = Message( + role=MessageRole.ASSISTANT, + content="Let's explore your values.", + timestamp=timestamp, + metadata=metadata, + ) + + # Assert + assert message.role == MessageRole.ASSISTANT + assert message.content == "Let's explore your values." + assert message.timestamp == timestamp + assert message.metadata == metadata + + def test_create_message_auto_generates_id(self) -> None: + """Test that message ID is auto-generated if not provided.""" + # Arrange & Act + message1 = Message(role=MessageRole.USER, content="First message") + message2 = Message(role=MessageRole.USER, content="Second message") + + # Assert + assert message1.message_id is not None + assert message2.message_id is not None + assert message1.message_id != message2.message_id + + def test_create_message_auto_generates_timestamp(self) -> None: + """Test that timestamp is auto-generated if not provided.""" + # Arrange + before = datetime.now(UTC) + + # Act + message = Message(role=MessageRole.USER, content="Test message") + + # Assert + after = datetime.now(UTC) + assert before <= message.timestamp <= after + + def test_create_message_strips_whitespace_from_content(self) -> None: + """Test that leading/trailing whitespace is stripped from content.""" + # Arrange & Act + message = Message(role=MessageRole.USER, content=" Content with spaces ") + + # Assert + assert message.content == "Content with spaces" + + +class TestMessageValidation: + """Test suite for Message validation rules.""" + + def test_create_message_with_empty_content_raises_error(self) -> None: + """Test that empty content raises ValidationError.""" + # Arrange & Act & Assert + with pytest.raises(ValidationError): + Message(role=MessageRole.USER, content="") + + def test_create_message_with_whitespace_only_content_raises_error( + self, + ) -> None: + """Test that whitespace-only content raises ValidationError.""" + # Arrange & Act & Assert + with pytest.raises(ValidationError): + Message(role=MessageRole.USER, content=" ") + + def test_create_message_with_future_timestamp_raises_error(self) -> None: + """Test that future timestamp raises ValidationError.""" + # Arrange + future = datetime.now(UTC) + timedelta(hours=1) + + # Act & Assert + with pytest.raises(ValidationError): + Message(role=MessageRole.USER, content="Test", timestamp=future) + + def test_create_message_with_content_too_long_raises_error(self) -> None: + """Test that content exceeding max length raises ValidationError.""" + # Arrange + long_content = "x" * 10001 + + # Act & Assert + with pytest.raises(ValidationError): + Message(role=MessageRole.USER, content=long_content) + + def test_create_message_with_invalid_role_raises_error(self) -> None: + """Test that invalid role raises ValidationError.""" + # Arrange & Act & Assert + with pytest.raises(ValidationError): + Message(role="invalid_role", content="Test") # type: ignore + + +class TestMessageImmutability: + """Test suite for Message immutability.""" + + def test_message_is_immutable(self) -> None: + """Test that message fields cannot be modified after creation.""" + # Arrange + message = Message(role=MessageRole.USER, content="Original content") + + # Act & Assert + with pytest.raises(ValidationError): + message.content = "Modified content" # type: ignore + + def test_message_role_cannot_be_changed(self) -> None: + """Test that message role cannot be changed.""" + # Arrange + message = Message(role=MessageRole.USER, content="Test") + + # Act & Assert + with pytest.raises(ValidationError): + message.role = MessageRole.ASSISTANT # type: ignore + + def test_message_metadata_is_immutable(self) -> None: + """Test that message metadata dict is protected by frozen model.""" + # Arrange + metadata = {"key": "value"} + message = Message(role=MessageRole.USER, content="Test", metadata=metadata) + + # Act & Assert - Cannot reassign metadata + with pytest.raises(ValidationError): + message.metadata = {"new": "dict"} # type: ignore + + +class TestMessageRoleChecks: + """Test suite for message role checking methods.""" + + def test_is_from_user_returns_true_for_user_message(self) -> None: + """Test is_from_user returns True for user messages.""" + # Arrange + message = Message(role=MessageRole.USER, content="Test") + + # Act & Assert + assert message.is_from_user() is True + assert message.is_from_assistant() is False + assert message.is_system_message() is False + + def test_is_from_assistant_returns_true_for_assistant_message( + self, + ) -> None: + """Test is_from_assistant returns True for assistant messages.""" + # Arrange + message = Message(role=MessageRole.ASSISTANT, content="Test") + + # Act & Assert + assert message.is_from_user() is False + assert message.is_from_assistant() is True + assert message.is_system_message() is False + + def test_is_system_message_returns_true_for_system_message(self) -> None: + """Test is_system_message returns True for system messages.""" + # Arrange + message = Message(role=MessageRole.SYSTEM, content="Test") + + # Act & Assert + assert message.is_from_user() is False + assert message.is_from_assistant() is False + assert message.is_system_message() is True + + +class TestMessageMethods: + """Test suite for Message utility methods.""" + + def test_get_content_length_returns_correct_length(self) -> None: + """Test get_content_length returns the correct character count.""" + # Arrange + content = "This is a test message" + message = Message(role=MessageRole.USER, content=content) + + # Act + length = message.get_content_length() + + # Assert + assert length == len(content) + + def test_has_metadata_returns_false_for_empty_metadata(self) -> None: + """Test has_metadata returns False when metadata is empty.""" + # Arrange + message = Message(role=MessageRole.USER, content="Test") + + # Act & Assert + assert message.has_metadata() is False + + def test_has_metadata_returns_true_when_metadata_exists(self) -> None: + """Test has_metadata returns True when metadata is provided.""" + # Arrange + message = Message( + role=MessageRole.USER, + content="Test", + metadata={"source": "web"}, + ) + + # Act & Assert + assert message.has_metadata() is True + + +class TestMessageEquality: + """Test suite for Message equality.""" + + def test_messages_with_same_values_are_equal(self) -> None: + """Test that messages with identical values are equal.""" + # Arrange + timestamp = datetime.now(UTC) + message1 = Message( + role=MessageRole.USER, + content="Test", + timestamp=timestamp, + ) + message2 = Message( + role=MessageRole.USER, + content="Test", + timestamp=timestamp, + ) + + # Act & Assert + # Note: They won't be equal because message_id is auto-generated + assert message1.message_id != message2.message_id + assert message1 != message2 + + def test_messages_can_be_compared(self) -> None: + """Test that messages support equality comparison.""" + # Arrange + message1 = Message(role=MessageRole.USER, content="Test 1") + message2 = Message(role=MessageRole.USER, content="Test 2") + message3 = message1 + + # Act & Assert + assert message1 == message3 # Same instance + assert message1 != message2 # Different instances diff --git a/coaching/tests/unit/infrastructure/cache/test_in_memory_cache.py b/coaching/tests/unit/infrastructure/cache/test_in_memory_cache.py index 98e4464d..e7ab27fe 100644 --- a/coaching/tests/unit/infrastructure/cache/test_in_memory_cache.py +++ b/coaching/tests/unit/infrastructure/cache/test_in_memory_cache.py @@ -1,67 +1,68 @@ -import asyncio - -import pytest -from coaching.src.infrastructure.cache.in_memory_cache import InMemoryCache - -pytestmark = pytest.mark.unit - - -class TestInMemoryCache: - """Test suite for InMemoryCache.""" - - @pytest.fixture - def cache(self) -> InMemoryCache: - return InMemoryCache(default_ttl=60) - - @pytest.mark.asyncio - async def test_set_and_get(self, cache: InMemoryCache) -> None: - """Test setting and getting a value.""" - await cache.set("key", "value") - result = await cache.get("key") - assert result == "value" - - @pytest.mark.asyncio - async def test_get_nonexistent_key(self, cache: InMemoryCache) -> None: - """Test getting a key that doesn't exist.""" - result = await cache.get("nonexistent") - assert result is None - - @pytest.mark.asyncio - async def test_ttl_expiration(self, cache: InMemoryCache) -> None: - """Test that values expire after TTL.""" - # Set with very short TTL - await cache.set("key", "value", ttl=1) - - # Should exist immediately - assert await cache.get("key") == "value" - - # Wait for expiration - await asyncio.sleep(1.1) - - # Should be gone - assert await cache.get("key") is None - - @pytest.mark.asyncio - async def test_delete(self, cache: InMemoryCache) -> None: - """Test deleting a value.""" - await cache.set("key", "value") - await cache.delete("key") - assert await cache.get("key") is None - - @pytest.mark.asyncio - async def test_clear_all(self, cache: InMemoryCache) -> None: - """Test clearing the cache.""" - await cache.set("key1", "value1") - await cache.set("key2", "value2") - - cache.clear_all() - - assert await cache.get("key1") is None - assert await cache.get("key2") is None - - @pytest.mark.asyncio - async def test_exists(self, cache: InMemoryCache) -> None: - """Test checking if a key exists.""" - await cache.set("key", "value") - assert await cache.exists("key") is True - assert await cache.exists("nonexistent") is False +import asyncio + +import pytest + +from coaching.src.infrastructure.cache.in_memory_cache import InMemoryCache + +pytestmark = pytest.mark.unit + + +class TestInMemoryCache: + """Test suite for InMemoryCache.""" + + @pytest.fixture + def cache(self) -> InMemoryCache: + return InMemoryCache(default_ttl=60) + + @pytest.mark.asyncio + async def test_set_and_get(self, cache: InMemoryCache) -> None: + """Test setting and getting a value.""" + await cache.set("key", "value") + result = await cache.get("key") + assert result == "value" + + @pytest.mark.asyncio + async def test_get_nonexistent_key(self, cache: InMemoryCache) -> None: + """Test getting a key that doesn't exist.""" + result = await cache.get("nonexistent") + assert result is None + + @pytest.mark.asyncio + async def test_ttl_expiration(self, cache: InMemoryCache) -> None: + """Test that values expire after TTL.""" + # Set with very short TTL + await cache.set("key", "value", ttl=1) + + # Should exist immediately + assert await cache.get("key") == "value" + + # Wait for expiration + await asyncio.sleep(1.1) + + # Should be gone + assert await cache.get("key") is None + + @pytest.mark.asyncio + async def test_delete(self, cache: InMemoryCache) -> None: + """Test deleting a value.""" + await cache.set("key", "value") + await cache.delete("key") + assert await cache.get("key") is None + + @pytest.mark.asyncio + async def test_clear_all(self, cache: InMemoryCache) -> None: + """Test clearing the cache.""" + await cache.set("key1", "value1") + await cache.set("key2", "value2") + + cache.clear_all() + + assert await cache.get("key1") is None + assert await cache.get("key2") is None + + @pytest.mark.asyncio + async def test_exists(self, cache: InMemoryCache) -> None: + """Test checking if a key exists.""" + await cache.set("key", "value") + assert await cache.exists("key") is True + assert await cache.exists("nonexistent") is False diff --git a/coaching/tests/unit/infrastructure/llm/test_google_vertex_provider.py b/coaching/tests/unit/infrastructure/llm/test_google_vertex_provider.py index 4ccf02fe..e73f4d54 100644 --- a/coaching/tests/unit/infrastructure/llm/test_google_vertex_provider.py +++ b/coaching/tests/unit/infrastructure/llm/test_google_vertex_provider.py @@ -8,6 +8,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest + from coaching.src.domain.ports.llm_provider_port import LLMMessage from coaching.src.infrastructure.llm.google_vertex_provider import GoogleVertexLLMProvider diff --git a/coaching/tests/unit/infrastructure/llm/test_provider_factory.py b/coaching/tests/unit/infrastructure/llm/test_provider_factory.py index bf19b9fc..78918cf9 100644 --- a/coaching/tests/unit/infrastructure/llm/test_provider_factory.py +++ b/coaching/tests/unit/infrastructure/llm/test_provider_factory.py @@ -1,325 +1,326 @@ -"""Unit tests for LLM Provider Factory. - -Tests for the LLMProviderFactory which enables dynamic multi-provider -model selection based on MODEL_REGISTRY configuration. -""" - -from unittest.mock import MagicMock, patch - -import pytest -from coaching.src.core.config_multitenant import Settings -from coaching.src.core.llm_models import MODEL_REGISTRY, LLMProvider -from coaching.src.infrastructure.llm.exceptions import ( - ModelNotAvailableError, - ModelNotFoundError, - ProviderNotConfiguredError, -) -from coaching.src.infrastructure.llm.provider_factory import ( - LLMProviderFactory, - get_provider_factory, -) - - -@pytest.fixture -def mock_settings() -> MagicMock: - """Create mock settings for testing.""" - settings = MagicMock(spec=Settings) - settings.bedrock_region = "us-east-1" - settings.openai_api_key = None - settings.anthropic_api_key = None - settings.google_project_id = None - settings.google_vertex_location = "us-central1" - return settings - - -@pytest.fixture -def mock_bedrock_client() -> MagicMock: - """Create mock Bedrock client.""" - return MagicMock() - - -@pytest.fixture -def factory(mock_settings: MagicMock, mock_bedrock_client: MagicMock) -> LLMProviderFactory: - """Create factory with mock dependencies.""" - return LLMProviderFactory( - settings=mock_settings, - bedrock_client=mock_bedrock_client, - ) - - -class TestLLMProviderFactoryInit: - """Test factory initialization.""" - - def test_init_creates_empty_provider_cache( - self, mock_settings: MagicMock, mock_bedrock_client: MagicMock - ) -> None: - """Test that factory initializes with empty provider cache.""" - factory = LLMProviderFactory( - settings=mock_settings, - bedrock_client=mock_bedrock_client, - ) - assert factory._providers == {} - assert factory._settings == mock_settings - - def test_init_accepts_bedrock_client( - self, mock_settings: MagicMock, mock_bedrock_client: MagicMock - ) -> None: - """Test that factory accepts injected bedrock client.""" - factory = LLMProviderFactory( - settings=mock_settings, - bedrock_client=mock_bedrock_client, - ) - assert factory._bedrock_client == mock_bedrock_client - - -class TestGetProviderForModel: - """Test get_provider_for_model method.""" - - def test_get_provider_for_bedrock_model_returns_provider_and_model_name( - self, factory: LLMProviderFactory - ) -> None: - """Test that Bedrock models return correct provider and model name.""" - # Claude models are Bedrock-based - provider, model_name = factory.get_provider_for_model("CLAUDE_3_5_SONNET_V2") - - assert provider is not None - assert model_name == "anthropic.claude-3-5-sonnet-20241022-v2:0" - assert provider.provider_name == "bedrock" - - def test_get_provider_for_model_caches_provider(self, factory: LLMProviderFactory) -> None: - """Test that providers are cached and reused.""" - # First call creates provider - provider1, _ = factory.get_provider_for_model("CLAUDE_3_5_SONNET_V2") - - # Second call should return same provider - provider2, _ = factory.get_provider_for_model("CLAUDE_3_SONNET") - - assert provider1 is provider2 - assert len(factory._providers) == 1 - - def test_get_provider_for_unknown_model_raises_error(self, factory: LLMProviderFactory) -> None: - """Test that unknown model codes raise ModelNotFoundError.""" - with pytest.raises(ModelNotFoundError) as exc_info: - factory.get_provider_for_model("UNKNOWN_MODEL") - - assert exc_info.value.model_code == "UNKNOWN_MODEL" - assert "CLAUDE_3_5_SONNET" in exc_info.value.available_models - - @patch("coaching.src.infrastructure.llm.provider_factory.get_model") - def test_get_provider_for_inactive_model_raises_error( - self, mock_get_model: MagicMock, factory: LLMProviderFactory - ) -> None: - """Test that inactive models raise ModelNotAvailableError.""" - from coaching.src.core.llm_models import LLMProvider, SupportedModel - - # Create a mock inactive model - mock_get_model.return_value = SupportedModel( - code="TEST_INACTIVE_MODEL", - provider=LLMProvider.OPENAI, - model_name="test-inactive-model", - version="1.0", - provider_class="OpenAILLMProvider", - capabilities=["chat"], - max_tokens=1000, - cost_per_1k_tokens=0.001, - is_active=False, # Explicitly inactive - ) - - with pytest.raises(ModelNotAvailableError) as exc_info: - factory.get_provider_for_model("TEST_INACTIVE_MODEL") - - assert exc_info.value.model_code == "TEST_INACTIVE_MODEL" - assert "inactive" in exc_info.value.reason.lower() - - @patch("coaching.src.infrastructure.llm.provider_factory.get_openai_api_key") - def test_get_provider_for_openai_model_with_api_key( - self, - mock_get_api_key: MagicMock, - factory: LLMProviderFactory, - ) -> None: - """Test that OpenAI models work when API key is configured.""" - mock_get_api_key.return_value = "sk-test-key" - - provider, model_name = factory.get_provider_for_model("GPT_5_MINI") - - assert provider is not None - assert model_name == "gpt-5-mini" - assert provider.provider_name == "openai" - - @patch("coaching.src.infrastructure.llm.provider_factory.get_openai_api_key") - def test_get_provider_for_openai_model_without_api_key_raises_error( - self, - mock_get_api_key: MagicMock, - factory: LLMProviderFactory, - ) -> None: - """Test that OpenAI models raise error without API key.""" - mock_get_api_key.return_value = None - - with pytest.raises(ProviderNotConfiguredError) as exc_info: - factory.get_provider_for_model("GPT_5_MINI") - - assert exc_info.value.provider == "openai" - assert "OPENAI_API_KEY" in exc_info.value.missing_config - - -class TestGetModelInfo: - """Test get_model_info method.""" - - def test_get_model_info_returns_model_config(self, factory: LLMProviderFactory) -> None: - """Test that model info is returned correctly.""" - model_info = factory.get_model_info("CLAUDE_3_5_SONNET_V2") - - assert model_info.code == "CLAUDE_3_5_SONNET_V2" - assert model_info.provider == LLMProvider.BEDROCK - assert model_info.model_name == "anthropic.claude-3-5-sonnet-20241022-v2:0" - - def test_get_model_info_unknown_model_raises_error(self, factory: LLMProviderFactory) -> None: - """Test that unknown model codes raise ModelNotFoundError.""" - with pytest.raises(ModelNotFoundError): - factory.get_model_info("UNKNOWN_MODEL") - - -class TestIsProviderConfigured: - """Test is_provider_configured method.""" - - def test_bedrock_always_configured(self, factory: LLMProviderFactory) -> None: - """Test that Bedrock is always considered configured (uses IAM).""" - assert factory.is_provider_configured(LLMProvider.BEDROCK) is True - - @patch("coaching.src.infrastructure.llm.provider_factory.get_openai_api_key") - def test_openai_configured_with_api_key( - self, - mock_get_api_key: MagicMock, - factory: LLMProviderFactory, - ) -> None: - """Test OpenAI is configured when API key is available.""" - mock_get_api_key.return_value = "sk-test-key" - assert factory.is_provider_configured(LLMProvider.OPENAI) is True - - @patch("coaching.src.infrastructure.llm.provider_factory.get_openai_api_key") - def test_openai_not_configured_without_api_key( - self, - mock_get_api_key: MagicMock, - factory: LLMProviderFactory, - ) -> None: - """Test OpenAI is not configured without API key.""" - mock_get_api_key.return_value = None - assert factory.is_provider_configured(LLMProvider.OPENAI) is False - - def test_anthropic_not_configured_without_api_key( - self, factory: LLMProviderFactory, mock_settings: MagicMock - ) -> None: - """Test Anthropic is not configured without API key.""" - mock_settings.anthropic_api_key = None - assert factory.is_provider_configured(LLMProvider.ANTHROPIC) is False - - -class TestClearCache: - """Test cache clearing functionality.""" - - def test_clear_cache_removes_all_providers(self, factory: LLMProviderFactory) -> None: - """Test that clear_cache removes all cached providers.""" - # First get a provider to populate cache - factory.get_provider_for_model("CLAUDE_3_5_SONNET_V2") - assert len(factory._providers) > 0 - - # Clear cache - factory.clear_cache() - - assert factory._providers == {} - - -class TestProviderCreation: - """Test individual provider creation methods.""" - - def test_create_bedrock_provider_uses_injected_client( - self, factory: LLMProviderFactory, mock_bedrock_client: MagicMock - ) -> None: - """Test that Bedrock provider uses injected client.""" - provider, _ = factory.get_provider_for_model("CLAUDE_3_5_SONNET_V2") - - # Verify the provider was created with the injected client - assert provider.bedrock_client == mock_bedrock_client - - @patch("coaching.src.infrastructure.llm.provider_factory.get_openai_api_key") - def test_create_openai_provider_with_api_key( - self, - mock_get_api_key: MagicMock, - factory: LLMProviderFactory, - ) -> None: - """Test OpenAI provider creation with API key.""" - mock_get_api_key.return_value = "sk-test-key" - - provider, _ = factory.get_provider_for_model("GPT_5_MINI") - - assert provider.provider_name == "openai" - assert provider.api_key == "sk-test-key" - - @patch("coaching.src.infrastructure.llm.provider_factory.get_google_vertex_credentials") - @patch.dict("os.environ", {"GOOGLE_APPLICATION_CREDENTIALS": ""}, clear=False) - def test_create_google_vertex_provider_without_credentials_raises_error( - self, - mock_get_creds: MagicMock, - factory: LLMProviderFactory, - mock_settings: MagicMock, - ) -> None: - """Test Google Vertex provider raises error without credentials.""" - mock_get_creds.return_value = None - mock_settings.google_project_id = None - - with pytest.raises(ProviderNotConfiguredError) as exc_info: - factory.get_provider_for_model("GEMINI_2_5_PRO") - - assert exc_info.value.provider == "google_vertex" - - -class TestModelRegistryIntegration: - """Test integration with MODEL_REGISTRY.""" - - def test_all_active_models_have_valid_provider(self, factory: LLMProviderFactory) -> None: - """Test that all active models in registry can resolve to a provider type.""" - for code, model in MODEL_REGISTRY.items(): - if model.is_active: - # Should not raise error for provider type validation - # (may raise ProviderNotConfiguredError for non-Bedrock without credentials) - try: - provider_type = model.provider - assert provider_type in LLMProvider - except Exception: - pytest.fail(f"Model {code} has invalid provider type") - - def test_bedrock_models_resolve_correctly(self, factory: LLMProviderFactory) -> None: - """Test that all Bedrock models resolve to Bedrock provider.""" - bedrock_models = [ - code - for code, model in MODEL_REGISTRY.items() - if model.provider == LLMProvider.BEDROCK and model.is_active - ] - - for model_code in bedrock_models: - provider, model_name = factory.get_provider_for_model(model_code) - assert provider.provider_name == "bedrock" - # Model names can have regional prefixes (us., eu., apac.) - assert any( - prefix in model_name - for prefix in ("anthropic.", "meta.", "amazon.", "us.", "eu.", "apac.") - ) - - -class TestModuleLevelSingleton: - """Test module-level get_provider_factory function.""" - - def test_get_provider_factory_returns_singleton(self) -> None: - """Test that get_provider_factory returns singleton instance.""" - # Reset module-level singleton - import coaching.src.infrastructure.llm.provider_factory as factory_module - - factory_module._factory_instance = None - - factory1 = get_provider_factory() - factory2 = get_provider_factory() - - assert factory1 is factory2 - - # Clean up - factory_module._factory_instance = None +"""Unit tests for LLM Provider Factory. + +Tests for the LLMProviderFactory which enables dynamic multi-provider +model selection based on MODEL_REGISTRY configuration. +""" + +from unittest.mock import MagicMock, patch + +import pytest + +from coaching.src.core.config_multitenant import Settings +from coaching.src.core.llm_models import MODEL_REGISTRY, LLMProvider +from coaching.src.infrastructure.llm.exceptions import ( + ModelNotAvailableError, + ModelNotFoundError, + ProviderNotConfiguredError, +) +from coaching.src.infrastructure.llm.provider_factory import ( + LLMProviderFactory, + get_provider_factory, +) + + +@pytest.fixture +def mock_settings() -> MagicMock: + """Create mock settings for testing.""" + settings = MagicMock(spec=Settings) + settings.bedrock_region = "us-east-1" + settings.openai_api_key = None + settings.anthropic_api_key = None + settings.google_project_id = None + settings.google_vertex_location = "us-central1" + return settings + + +@pytest.fixture +def mock_bedrock_client() -> MagicMock: + """Create mock Bedrock client.""" + return MagicMock() + + +@pytest.fixture +def factory(mock_settings: MagicMock, mock_bedrock_client: MagicMock) -> LLMProviderFactory: + """Create factory with mock dependencies.""" + return LLMProviderFactory( + settings=mock_settings, + bedrock_client=mock_bedrock_client, + ) + + +class TestLLMProviderFactoryInit: + """Test factory initialization.""" + + def test_init_creates_empty_provider_cache( + self, mock_settings: MagicMock, mock_bedrock_client: MagicMock + ) -> None: + """Test that factory initializes with empty provider cache.""" + factory = LLMProviderFactory( + settings=mock_settings, + bedrock_client=mock_bedrock_client, + ) + assert factory._providers == {} + assert factory._settings == mock_settings + + def test_init_accepts_bedrock_client( + self, mock_settings: MagicMock, mock_bedrock_client: MagicMock + ) -> None: + """Test that factory accepts injected bedrock client.""" + factory = LLMProviderFactory( + settings=mock_settings, + bedrock_client=mock_bedrock_client, + ) + assert factory._bedrock_client == mock_bedrock_client + + +class TestGetProviderForModel: + """Test get_provider_for_model method.""" + + def test_get_provider_for_bedrock_model_returns_provider_and_model_name( + self, factory: LLMProviderFactory + ) -> None: + """Test that Bedrock models return correct provider and model name.""" + # Claude models are Bedrock-based + provider, model_name = factory.get_provider_for_model("CLAUDE_3_5_SONNET_V2") + + assert provider is not None + assert model_name == "anthropic.claude-3-5-sonnet-20241022-v2:0" + assert provider.provider_name == "bedrock" + + def test_get_provider_for_model_caches_provider(self, factory: LLMProviderFactory) -> None: + """Test that providers are cached and reused.""" + # First call creates provider + provider1, _ = factory.get_provider_for_model("CLAUDE_3_5_SONNET_V2") + + # Second call should return same provider + provider2, _ = factory.get_provider_for_model("CLAUDE_3_SONNET") + + assert provider1 is provider2 + assert len(factory._providers) == 1 + + def test_get_provider_for_unknown_model_raises_error(self, factory: LLMProviderFactory) -> None: + """Test that unknown model codes raise ModelNotFoundError.""" + with pytest.raises(ModelNotFoundError) as exc_info: + factory.get_provider_for_model("UNKNOWN_MODEL") + + assert exc_info.value.model_code == "UNKNOWN_MODEL" + assert "CLAUDE_3_5_SONNET" in exc_info.value.available_models + + @patch("coaching.src.infrastructure.llm.provider_factory.get_model") + def test_get_provider_for_inactive_model_raises_error( + self, mock_get_model: MagicMock, factory: LLMProviderFactory + ) -> None: + """Test that inactive models raise ModelNotAvailableError.""" + from coaching.src.core.llm_models import LLMProvider, SupportedModel + + # Create a mock inactive model + mock_get_model.return_value = SupportedModel( + code="TEST_INACTIVE_MODEL", + provider=LLMProvider.OPENAI, + model_name="test-inactive-model", + version="1.0", + provider_class="OpenAILLMProvider", + capabilities=["chat"], + max_tokens=1000, + cost_per_1k_tokens=0.001, + is_active=False, # Explicitly inactive + ) + + with pytest.raises(ModelNotAvailableError) as exc_info: + factory.get_provider_for_model("TEST_INACTIVE_MODEL") + + assert exc_info.value.model_code == "TEST_INACTIVE_MODEL" + assert "inactive" in exc_info.value.reason.lower() + + @patch("coaching.src.infrastructure.llm.provider_factory.get_openai_api_key") + def test_get_provider_for_openai_model_with_api_key( + self, + mock_get_api_key: MagicMock, + factory: LLMProviderFactory, + ) -> None: + """Test that OpenAI models work when API key is configured.""" + mock_get_api_key.return_value = "sk-test-key" + + provider, model_name = factory.get_provider_for_model("GPT_5_MINI") + + assert provider is not None + assert model_name == "gpt-5-mini" + assert provider.provider_name == "openai" + + @patch("coaching.src.infrastructure.llm.provider_factory.get_openai_api_key") + def test_get_provider_for_openai_model_without_api_key_raises_error( + self, + mock_get_api_key: MagicMock, + factory: LLMProviderFactory, + ) -> None: + """Test that OpenAI models raise error without API key.""" + mock_get_api_key.return_value = None + + with pytest.raises(ProviderNotConfiguredError) as exc_info: + factory.get_provider_for_model("GPT_5_MINI") + + assert exc_info.value.provider == "openai" + assert "OPENAI_API_KEY" in exc_info.value.missing_config + + +class TestGetModelInfo: + """Test get_model_info method.""" + + def test_get_model_info_returns_model_config(self, factory: LLMProviderFactory) -> None: + """Test that model info is returned correctly.""" + model_info = factory.get_model_info("CLAUDE_3_5_SONNET_V2") + + assert model_info.code == "CLAUDE_3_5_SONNET_V2" + assert model_info.provider == LLMProvider.BEDROCK + assert model_info.model_name == "anthropic.claude-3-5-sonnet-20241022-v2:0" + + def test_get_model_info_unknown_model_raises_error(self, factory: LLMProviderFactory) -> None: + """Test that unknown model codes raise ModelNotFoundError.""" + with pytest.raises(ModelNotFoundError): + factory.get_model_info("UNKNOWN_MODEL") + + +class TestIsProviderConfigured: + """Test is_provider_configured method.""" + + def test_bedrock_always_configured(self, factory: LLMProviderFactory) -> None: + """Test that Bedrock is always considered configured (uses IAM).""" + assert factory.is_provider_configured(LLMProvider.BEDROCK) is True + + @patch("coaching.src.infrastructure.llm.provider_factory.get_openai_api_key") + def test_openai_configured_with_api_key( + self, + mock_get_api_key: MagicMock, + factory: LLMProviderFactory, + ) -> None: + """Test OpenAI is configured when API key is available.""" + mock_get_api_key.return_value = "sk-test-key" + assert factory.is_provider_configured(LLMProvider.OPENAI) is True + + @patch("coaching.src.infrastructure.llm.provider_factory.get_openai_api_key") + def test_openai_not_configured_without_api_key( + self, + mock_get_api_key: MagicMock, + factory: LLMProviderFactory, + ) -> None: + """Test OpenAI is not configured without API key.""" + mock_get_api_key.return_value = None + assert factory.is_provider_configured(LLMProvider.OPENAI) is False + + def test_anthropic_not_configured_without_api_key( + self, factory: LLMProviderFactory, mock_settings: MagicMock + ) -> None: + """Test Anthropic is not configured without API key.""" + mock_settings.anthropic_api_key = None + assert factory.is_provider_configured(LLMProvider.ANTHROPIC) is False + + +class TestClearCache: + """Test cache clearing functionality.""" + + def test_clear_cache_removes_all_providers(self, factory: LLMProviderFactory) -> None: + """Test that clear_cache removes all cached providers.""" + # First get a provider to populate cache + factory.get_provider_for_model("CLAUDE_3_5_SONNET_V2") + assert len(factory._providers) > 0 + + # Clear cache + factory.clear_cache() + + assert factory._providers == {} + + +class TestProviderCreation: + """Test individual provider creation methods.""" + + def test_create_bedrock_provider_uses_injected_client( + self, factory: LLMProviderFactory, mock_bedrock_client: MagicMock + ) -> None: + """Test that Bedrock provider uses injected client.""" + provider, _ = factory.get_provider_for_model("CLAUDE_3_5_SONNET_V2") + + # Verify the provider was created with the injected client + assert provider.bedrock_client == mock_bedrock_client + + @patch("coaching.src.infrastructure.llm.provider_factory.get_openai_api_key") + def test_create_openai_provider_with_api_key( + self, + mock_get_api_key: MagicMock, + factory: LLMProviderFactory, + ) -> None: + """Test OpenAI provider creation with API key.""" + mock_get_api_key.return_value = "sk-test-key" + + provider, _ = factory.get_provider_for_model("GPT_5_MINI") + + assert provider.provider_name == "openai" + assert provider.api_key == "sk-test-key" + + @patch("coaching.src.infrastructure.llm.provider_factory.get_google_vertex_credentials") + @patch.dict("os.environ", {"GOOGLE_APPLICATION_CREDENTIALS": ""}, clear=False) + def test_create_google_vertex_provider_without_credentials_raises_error( + self, + mock_get_creds: MagicMock, + factory: LLMProviderFactory, + mock_settings: MagicMock, + ) -> None: + """Test Google Vertex provider raises error without credentials.""" + mock_get_creds.return_value = None + mock_settings.google_project_id = None + + with pytest.raises(ProviderNotConfiguredError) as exc_info: + factory.get_provider_for_model("GEMINI_2_5_PRO") + + assert exc_info.value.provider == "google_vertex" + + +class TestModelRegistryIntegration: + """Test integration with MODEL_REGISTRY.""" + + def test_all_active_models_have_valid_provider(self, factory: LLMProviderFactory) -> None: + """Test that all active models in registry can resolve to a provider type.""" + for code, model in MODEL_REGISTRY.items(): + if model.is_active: + # Should not raise error for provider type validation + # (may raise ProviderNotConfiguredError for non-Bedrock without credentials) + try: + provider_type = model.provider + assert provider_type in LLMProvider + except Exception: + pytest.fail(f"Model {code} has invalid provider type") + + def test_bedrock_models_resolve_correctly(self, factory: LLMProviderFactory) -> None: + """Test that all Bedrock models resolve to Bedrock provider.""" + bedrock_models = [ + code + for code, model in MODEL_REGISTRY.items() + if model.provider == LLMProvider.BEDROCK and model.is_active + ] + + for model_code in bedrock_models: + provider, model_name = factory.get_provider_for_model(model_code) + assert provider.provider_name == "bedrock" + # Model names can have regional prefixes (us., eu., apac.) + assert any( + prefix in model_name + for prefix in ("anthropic.", "meta.", "amazon.", "us.", "eu.", "apac.") + ) + + +class TestModuleLevelSingleton: + """Test module-level get_provider_factory function.""" + + def test_get_provider_factory_returns_singleton(self) -> None: + """Test that get_provider_factory returns singleton instance.""" + # Reset module-level singleton + import coaching.src.infrastructure.llm.provider_factory as factory_module + + factory_module._factory_instance = None + + factory1 = get_provider_factory() + factory2 = get_provider_factory() + + assert factory1 is factory2 + + # Clean up + factory_module._factory_instance = None diff --git a/coaching/tests/unit/infrastructure/repositories/llm_config/test_template_metadata_repository.py b/coaching/tests/unit/infrastructure/repositories/llm_config/test_template_metadata_repository.py index 5d06919e..c642c6ab 100644 --- a/coaching/tests/unit/infrastructure/repositories/llm_config/test_template_metadata_repository.py +++ b/coaching/tests/unit/infrastructure/repositories/llm_config/test_template_metadata_repository.py @@ -1,361 +1,362 @@ -from datetime import UTC, datetime, timedelta -from unittest.mock import Mock, patch - -import pytest -from coaching.src.domain.entities.llm_config.template_metadata import TemplateMetadata -from coaching.src.infrastructure.repositories.llm_config.template_metadata_repository import ( - TemplateMetadataRepository, -) - - -class TestTemplateMetadataRepository: - @pytest.fixture - def mock_dynamodb_resource(self): - mock = Mock() - - # Create a real class for the exception - class ConditionalCheckFailedError(Exception): - pass - - mock.meta.client.exceptions.ConditionalCheckFailedException = ConditionalCheckFailedError - return mock - - @pytest.fixture - def mock_table(self): - return Mock() - - @pytest.fixture - def repository(self, mock_dynamodb_resource, mock_table): - mock_dynamodb_resource.Table.return_value = mock_table - return TemplateMetadataRepository(mock_dynamodb_resource, "test-table") - - @pytest.fixture - def sample_metadata(self): - return TemplateMetadata( - template_id="tmpl_123", - template_code="test_template", - interaction_code="COACHING_SESSION", - name="Test Template", - description="Test template description", - s3_bucket="test-bucket", - s3_key="templates/test_template/v1.0.json", - version="1.0", - is_active=True, - created_at=datetime.now(UTC), - updated_at=datetime.now(UTC), - created_by="user_123", - ) - - @pytest.mark.asyncio - async def test_create_success(self, repository, mock_table, sample_metadata): - # Arrange - mock_table.put_item.return_value = {} - - # Mock validation of interaction code - with patch( - "coaching.src.infrastructure.repositories.llm_config.template_metadata_repository.TemplateMetadataRepository._validate_interaction_code" - ): - # Act - result = await repository.create(sample_metadata) - - # Assert - assert result == sample_metadata - mock_table.put_item.assert_called_once() - call_args = mock_table.put_item.call_args[1] - assert call_args["Item"]["template_id"] == sample_metadata.template_id - assert call_args["Item"]["template_code"] == sample_metadata.template_code - - @pytest.mark.asyncio - async def test_get_by_id_found(self, repository, mock_table, sample_metadata): - # Arrange - item = { - "template_id": sample_metadata.template_id, - "template_code": sample_metadata.template_code, - "interaction_code": sample_metadata.interaction_code, - "name": sample_metadata.name, - "description": sample_metadata.description, - "s3_bucket": sample_metadata.s3_bucket, - "s3_key": sample_metadata.s3_key, - "version": sample_metadata.version, - "is_active": sample_metadata.is_active, - "created_at": sample_metadata.created_at.isoformat(), - "updated_at": sample_metadata.updated_at.isoformat(), - "created_by": sample_metadata.created_by, - } - mock_table.get_item.return_value = {"Item": item} - - # Act - result = await repository.get_by_id(sample_metadata.template_id) - - # Assert - assert result is not None - assert result.template_id == sample_metadata.template_id - mock_table.get_item.assert_called_once_with( - Key={"template_id": sample_metadata.template_id} - ) - - @pytest.mark.asyncio - async def test_get_by_id_not_found(self, repository, mock_table): - # Arrange - mock_table.get_item.return_value = {} - - # Act - result = await repository.get_by_id("non_existent") - - # Assert - assert result is None - - @pytest.mark.asyncio - async def test_get_by_code_found(self, repository, mock_table, sample_metadata): - # Arrange - item = { - "template_id": sample_metadata.template_id, - "template_code": sample_metadata.template_code, - "interaction_code": sample_metadata.interaction_code, - "name": sample_metadata.name, - "description": sample_metadata.description, - "s3_bucket": sample_metadata.s3_bucket, - "s3_key": sample_metadata.s3_key, - "version": sample_metadata.version, - "is_active": sample_metadata.is_active, - "created_at": sample_metadata.created_at.isoformat(), - "updated_at": sample_metadata.updated_at.isoformat(), - "created_by": sample_metadata.created_by, - } - mock_table.query.return_value = {"Items": [item]} - - # Act - result = await repository.get_by_code(sample_metadata.template_code) - - # Assert - assert result is not None - assert result.template_code == sample_metadata.template_code - mock_table.query.assert_called_once() - call_args = mock_table.query.call_args[1] - assert call_args["IndexName"] == "code-index" - - @pytest.mark.asyncio - async def test_get_by_interaction(self, repository, mock_table, sample_metadata): - # Arrange - item = { - "template_id": sample_metadata.template_id, - "template_code": sample_metadata.template_code, - "interaction_code": sample_metadata.interaction_code, - "name": sample_metadata.name, - "description": sample_metadata.description, - "s3_bucket": sample_metadata.s3_bucket, - "s3_key": sample_metadata.s3_key, - "version": sample_metadata.version, - "is_active": sample_metadata.is_active, - "created_at": sample_metadata.created_at.isoformat(), - "updated_at": sample_metadata.updated_at.isoformat(), - "created_by": sample_metadata.created_by, - } - mock_table.query.return_value = {"Items": [item]} - - # Act - results = await repository.get_by_interaction(sample_metadata.interaction_code) - - # Assert - assert len(results) == 1 - assert results[0].interaction_code == sample_metadata.interaction_code - mock_table.query.assert_called_once() - call_args = mock_table.query.call_args[1] - assert call_args["IndexName"] == "interaction-index" - - @pytest.mark.asyncio - async def test_get_active_for_interaction(self, repository, mock_table, sample_metadata): - # Arrange - # Mock get_by_interaction to return a list with one active template - with patch.object(repository, "get_by_interaction", return_value=[sample_metadata]): - # Act - result = await repository.get_active_for_interaction(sample_metadata.interaction_code) - - # Assert - assert result is not None - assert result.is_active is True - repository.get_by_interaction.assert_called_once_with(sample_metadata.interaction_code) - - @pytest.mark.asyncio - async def test_list_versions(self, repository, mock_table, sample_metadata): - # Arrange - v1 = sample_metadata.model_copy() - v1.version = "1.0" - v1.created_at = datetime.now(UTC) - timedelta(days=1) - - v2 = sample_metadata.model_copy() - v2.version = "2.0" - v2.created_at = datetime.now(UTC) - - item1 = { - "template_id": v1.template_id, - "template_code": v1.template_code, - "interaction_code": v1.interaction_code, - "name": v1.name, - "description": v1.description, - "s3_bucket": v1.s3_bucket, - "s3_key": v1.s3_key, - "version": v1.version, - "is_active": v1.is_active, - "created_at": v1.created_at.isoformat(), - "updated_at": v1.updated_at.isoformat(), - "created_by": v1.created_by, - } - item2 = { - "template_id": v2.template_id, - "template_code": v2.template_code, - "interaction_code": v2.interaction_code, - "name": v2.name, - "description": v2.description, - "s3_bucket": v2.s3_bucket, - "s3_key": v2.s3_key, - "version": v2.version, - "is_active": v2.is_active, - "created_at": v2.created_at.isoformat(), - "updated_at": v2.updated_at.isoformat(), - "created_by": v2.created_by, - } - - mock_table.query.return_value = {"Items": [item1, item2]} - - # Act - results = await repository.list_versions(sample_metadata.template_code) - - # Assert - assert len(results) == 2 - # Should be sorted by created_at desc (v2 then v1) - assert results[0].version == "2.0" - assert results[1].version == "1.0" - - @pytest.mark.asyncio - async def test_update_success(self, repository, mock_table, sample_metadata): - # Arrange - mock_table.put_item.return_value = {} - - with patch( - "coaching.src.infrastructure.repositories.llm_config.template_metadata_repository.TemplateMetadataRepository._validate_interaction_code" - ): - # Act - result = await repository.update(sample_metadata.template_id, sample_metadata) - - # Assert - assert result == sample_metadata - mock_table.put_item.assert_called_once() - call_args = mock_table.put_item.call_args[1] - assert call_args["Item"]["template_id"] == sample_metadata.template_id - assert "ConditionExpression" in call_args - - @pytest.mark.asyncio - async def test_update_not_found(self, repository, mock_table, sample_metadata): - # Arrange - # Mock ConditionalCheckFailedException - exception = repository.dynamodb.meta.client.exceptions.ConditionalCheckFailedException( - { - "Error": { - "Code": "ConditionalCheckFailedException", - "Message": "The conditional request failed", - } - }, - "PutItem", - ) - mock_table.put_item.side_effect = exception - - with ( - patch( - "coaching.src.infrastructure.repositories.llm_config.template_metadata_repository.TemplateMetadataRepository._validate_interaction_code" - ), - pytest.raises(ValueError, match=f"Template not found: {sample_metadata.template_id}"), - ): - # Act & Assert - await repository.update(sample_metadata.template_id, sample_metadata) - - @pytest.mark.asyncio - async def test_deactivate_success(self, repository, mock_table, sample_metadata): - # Arrange - mock_table.update_item.return_value = {} - - # Act - result = await repository.deactivate(sample_metadata.template_id) - - # Assert - assert result is True - mock_table.update_item.assert_called_once() - call_args = mock_table.update_item.call_args[1] - assert call_args["Key"]["template_id"] == sample_metadata.template_id - assert "SET is_active = :inactive" in call_args["UpdateExpression"] - - @pytest.mark.asyncio - async def test_deactivate_not_found(self, repository, mock_table, sample_metadata): - # Arrange - exception = repository.dynamodb.meta.client.exceptions.ConditionalCheckFailedException( - { - "Error": { - "Code": "ConditionalCheckFailedException", - "Message": "The conditional request failed", - } - }, - "UpdateItem", - ) - mock_table.update_item.side_effect = exception - - # Act & Assert - with pytest.raises(ValueError, match=f"Template not found: {sample_metadata.template_id}"): - await repository.deactivate(sample_metadata.template_id) - - @pytest.mark.asyncio - async def test_activate_success(self, repository, mock_table, sample_metadata): - # Arrange - mock_table.update_item.return_value = {} - - # Act - result = await repository.activate(sample_metadata.template_id) - - # Assert - assert result is True - mock_table.update_item.assert_called_once() - call_args = mock_table.update_item.call_args[1] - assert call_args["Key"]["template_id"] == sample_metadata.template_id - assert "SET is_active = :active" in call_args["UpdateExpression"] - - @pytest.mark.asyncio - async def test_activate_not_found(self, repository, mock_table, sample_metadata): - # Arrange - exception = repository.dynamodb.meta.client.exceptions.ConditionalCheckFailedException( - { - "Error": { - "Code": "ConditionalCheckFailedException", - "Message": "The conditional request failed", - } - }, - "UpdateItem", - ) - mock_table.update_item.side_effect = exception - - # Act & Assert - with pytest.raises(ValueError, match=f"Template not found: {sample_metadata.template_id}"): - await repository.activate(sample_metadata.template_id) - - @pytest.mark.asyncio - async def test_create_duplicate_id(self, repository, mock_table, sample_metadata): - # Arrange - exception = repository.dynamodb.meta.client.exceptions.ConditionalCheckFailedException( - { - "Error": { - "Code": "ConditionalCheckFailedException", - "Message": "The conditional request failed", - } - }, - "PutItem", - ) - mock_table.put_item.side_effect = exception - - with ( - patch( - "coaching.src.infrastructure.repositories.llm_config.template_metadata_repository.TemplateMetadataRepository._validate_interaction_code" - ), - pytest.raises( - ValueError, match=f"Template ID already exists: {sample_metadata.template_id}" - ), - ): - # Act & Assert - await repository.create(sample_metadata) +from datetime import UTC, datetime, timedelta +from unittest.mock import Mock, patch + +import pytest + +from coaching.src.domain.entities.llm_config.template_metadata import TemplateMetadata +from coaching.src.infrastructure.repositories.llm_config.template_metadata_repository import ( + TemplateMetadataRepository, +) + + +class TestTemplateMetadataRepository: + @pytest.fixture + def mock_dynamodb_resource(self): + mock = Mock() + + # Create a real class for the exception + class ConditionalCheckFailedError(Exception): + pass + + mock.meta.client.exceptions.ConditionalCheckFailedException = ConditionalCheckFailedError + return mock + + @pytest.fixture + def mock_table(self): + return Mock() + + @pytest.fixture + def repository(self, mock_dynamodb_resource, mock_table): + mock_dynamodb_resource.Table.return_value = mock_table + return TemplateMetadataRepository(mock_dynamodb_resource, "test-table") + + @pytest.fixture + def sample_metadata(self): + return TemplateMetadata( + template_id="tmpl_123", + template_code="test_template", + interaction_code="COACHING_SESSION", + name="Test Template", + description="Test template description", + s3_bucket="test-bucket", + s3_key="templates/test_template/v1.0.json", + version="1.0", + is_active=True, + created_at=datetime.now(UTC), + updated_at=datetime.now(UTC), + created_by="user_123", + ) + + @pytest.mark.asyncio + async def test_create_success(self, repository, mock_table, sample_metadata): + # Arrange + mock_table.put_item.return_value = {} + + # Mock validation of interaction code + with patch( + "coaching.src.infrastructure.repositories.llm_config.template_metadata_repository.TemplateMetadataRepository._validate_interaction_code" + ): + # Act + result = await repository.create(sample_metadata) + + # Assert + assert result == sample_metadata + mock_table.put_item.assert_called_once() + call_args = mock_table.put_item.call_args[1] + assert call_args["Item"]["template_id"] == sample_metadata.template_id + assert call_args["Item"]["template_code"] == sample_metadata.template_code + + @pytest.mark.asyncio + async def test_get_by_id_found(self, repository, mock_table, sample_metadata): + # Arrange + item = { + "template_id": sample_metadata.template_id, + "template_code": sample_metadata.template_code, + "interaction_code": sample_metadata.interaction_code, + "name": sample_metadata.name, + "description": sample_metadata.description, + "s3_bucket": sample_metadata.s3_bucket, + "s3_key": sample_metadata.s3_key, + "version": sample_metadata.version, + "is_active": sample_metadata.is_active, + "created_at": sample_metadata.created_at.isoformat(), + "updated_at": sample_metadata.updated_at.isoformat(), + "created_by": sample_metadata.created_by, + } + mock_table.get_item.return_value = {"Item": item} + + # Act + result = await repository.get_by_id(sample_metadata.template_id) + + # Assert + assert result is not None + assert result.template_id == sample_metadata.template_id + mock_table.get_item.assert_called_once_with( + Key={"template_id": sample_metadata.template_id} + ) + + @pytest.mark.asyncio + async def test_get_by_id_not_found(self, repository, mock_table): + # Arrange + mock_table.get_item.return_value = {} + + # Act + result = await repository.get_by_id("non_existent") + + # Assert + assert result is None + + @pytest.mark.asyncio + async def test_get_by_code_found(self, repository, mock_table, sample_metadata): + # Arrange + item = { + "template_id": sample_metadata.template_id, + "template_code": sample_metadata.template_code, + "interaction_code": sample_metadata.interaction_code, + "name": sample_metadata.name, + "description": sample_metadata.description, + "s3_bucket": sample_metadata.s3_bucket, + "s3_key": sample_metadata.s3_key, + "version": sample_metadata.version, + "is_active": sample_metadata.is_active, + "created_at": sample_metadata.created_at.isoformat(), + "updated_at": sample_metadata.updated_at.isoformat(), + "created_by": sample_metadata.created_by, + } + mock_table.query.return_value = {"Items": [item]} + + # Act + result = await repository.get_by_code(sample_metadata.template_code) + + # Assert + assert result is not None + assert result.template_code == sample_metadata.template_code + mock_table.query.assert_called_once() + call_args = mock_table.query.call_args[1] + assert call_args["IndexName"] == "code-index" + + @pytest.mark.asyncio + async def test_get_by_interaction(self, repository, mock_table, sample_metadata): + # Arrange + item = { + "template_id": sample_metadata.template_id, + "template_code": sample_metadata.template_code, + "interaction_code": sample_metadata.interaction_code, + "name": sample_metadata.name, + "description": sample_metadata.description, + "s3_bucket": sample_metadata.s3_bucket, + "s3_key": sample_metadata.s3_key, + "version": sample_metadata.version, + "is_active": sample_metadata.is_active, + "created_at": sample_metadata.created_at.isoformat(), + "updated_at": sample_metadata.updated_at.isoformat(), + "created_by": sample_metadata.created_by, + } + mock_table.query.return_value = {"Items": [item]} + + # Act + results = await repository.get_by_interaction(sample_metadata.interaction_code) + + # Assert + assert len(results) == 1 + assert results[0].interaction_code == sample_metadata.interaction_code + mock_table.query.assert_called_once() + call_args = mock_table.query.call_args[1] + assert call_args["IndexName"] == "interaction-index" + + @pytest.mark.asyncio + async def test_get_active_for_interaction(self, repository, mock_table, sample_metadata): + # Arrange + # Mock get_by_interaction to return a list with one active template + with patch.object(repository, "get_by_interaction", return_value=[sample_metadata]): + # Act + result = await repository.get_active_for_interaction(sample_metadata.interaction_code) + + # Assert + assert result is not None + assert result.is_active is True + repository.get_by_interaction.assert_called_once_with(sample_metadata.interaction_code) + + @pytest.mark.asyncio + async def test_list_versions(self, repository, mock_table, sample_metadata): + # Arrange + v1 = sample_metadata.model_copy() + v1.version = "1.0" + v1.created_at = datetime.now(UTC) - timedelta(days=1) + + v2 = sample_metadata.model_copy() + v2.version = "2.0" + v2.created_at = datetime.now(UTC) + + item1 = { + "template_id": v1.template_id, + "template_code": v1.template_code, + "interaction_code": v1.interaction_code, + "name": v1.name, + "description": v1.description, + "s3_bucket": v1.s3_bucket, + "s3_key": v1.s3_key, + "version": v1.version, + "is_active": v1.is_active, + "created_at": v1.created_at.isoformat(), + "updated_at": v1.updated_at.isoformat(), + "created_by": v1.created_by, + } + item2 = { + "template_id": v2.template_id, + "template_code": v2.template_code, + "interaction_code": v2.interaction_code, + "name": v2.name, + "description": v2.description, + "s3_bucket": v2.s3_bucket, + "s3_key": v2.s3_key, + "version": v2.version, + "is_active": v2.is_active, + "created_at": v2.created_at.isoformat(), + "updated_at": v2.updated_at.isoformat(), + "created_by": v2.created_by, + } + + mock_table.query.return_value = {"Items": [item1, item2]} + + # Act + results = await repository.list_versions(sample_metadata.template_code) + + # Assert + assert len(results) == 2 + # Should be sorted by created_at desc (v2 then v1) + assert results[0].version == "2.0" + assert results[1].version == "1.0" + + @pytest.mark.asyncio + async def test_update_success(self, repository, mock_table, sample_metadata): + # Arrange + mock_table.put_item.return_value = {} + + with patch( + "coaching.src.infrastructure.repositories.llm_config.template_metadata_repository.TemplateMetadataRepository._validate_interaction_code" + ): + # Act + result = await repository.update(sample_metadata.template_id, sample_metadata) + + # Assert + assert result == sample_metadata + mock_table.put_item.assert_called_once() + call_args = mock_table.put_item.call_args[1] + assert call_args["Item"]["template_id"] == sample_metadata.template_id + assert "ConditionExpression" in call_args + + @pytest.mark.asyncio + async def test_update_not_found(self, repository, mock_table, sample_metadata): + # Arrange + # Mock ConditionalCheckFailedException + exception = repository.dynamodb.meta.client.exceptions.ConditionalCheckFailedException( + { + "Error": { + "Code": "ConditionalCheckFailedException", + "Message": "The conditional request failed", + } + }, + "PutItem", + ) + mock_table.put_item.side_effect = exception + + with ( + patch( + "coaching.src.infrastructure.repositories.llm_config.template_metadata_repository.TemplateMetadataRepository._validate_interaction_code" + ), + pytest.raises(ValueError, match=f"Template not found: {sample_metadata.template_id}"), + ): + # Act & Assert + await repository.update(sample_metadata.template_id, sample_metadata) + + @pytest.mark.asyncio + async def test_deactivate_success(self, repository, mock_table, sample_metadata): + # Arrange + mock_table.update_item.return_value = {} + + # Act + result = await repository.deactivate(sample_metadata.template_id) + + # Assert + assert result is True + mock_table.update_item.assert_called_once() + call_args = mock_table.update_item.call_args[1] + assert call_args["Key"]["template_id"] == sample_metadata.template_id + assert "SET is_active = :inactive" in call_args["UpdateExpression"] + + @pytest.mark.asyncio + async def test_deactivate_not_found(self, repository, mock_table, sample_metadata): + # Arrange + exception = repository.dynamodb.meta.client.exceptions.ConditionalCheckFailedException( + { + "Error": { + "Code": "ConditionalCheckFailedException", + "Message": "The conditional request failed", + } + }, + "UpdateItem", + ) + mock_table.update_item.side_effect = exception + + # Act & Assert + with pytest.raises(ValueError, match=f"Template not found: {sample_metadata.template_id}"): + await repository.deactivate(sample_metadata.template_id) + + @pytest.mark.asyncio + async def test_activate_success(self, repository, mock_table, sample_metadata): + # Arrange + mock_table.update_item.return_value = {} + + # Act + result = await repository.activate(sample_metadata.template_id) + + # Assert + assert result is True + mock_table.update_item.assert_called_once() + call_args = mock_table.update_item.call_args[1] + assert call_args["Key"]["template_id"] == sample_metadata.template_id + assert "SET is_active = :active" in call_args["UpdateExpression"] + + @pytest.mark.asyncio + async def test_activate_not_found(self, repository, mock_table, sample_metadata): + # Arrange + exception = repository.dynamodb.meta.client.exceptions.ConditionalCheckFailedException( + { + "Error": { + "Code": "ConditionalCheckFailedException", + "Message": "The conditional request failed", + } + }, + "UpdateItem", + ) + mock_table.update_item.side_effect = exception + + # Act & Assert + with pytest.raises(ValueError, match=f"Template not found: {sample_metadata.template_id}"): + await repository.activate(sample_metadata.template_id) + + @pytest.mark.asyncio + async def test_create_duplicate_id(self, repository, mock_table, sample_metadata): + # Arrange + exception = repository.dynamodb.meta.client.exceptions.ConditionalCheckFailedException( + { + "Error": { + "Code": "ConditionalCheckFailedException", + "Message": "The conditional request failed", + } + }, + "PutItem", + ) + mock_table.put_item.side_effect = exception + + with ( + patch( + "coaching.src.infrastructure.repositories.llm_config.template_metadata_repository.TemplateMetadataRepository._validate_interaction_code" + ), + pytest.raises( + ValueError, match=f"Template ID already exists: {sample_metadata.template_id}" + ), + ): + # Act & Assert + await repository.create(sample_metadata) diff --git a/coaching/tests/unit/infrastructure/repositories/test_dynamodb_conversation_repository.py b/coaching/tests/unit/infrastructure/repositories/test_dynamodb_conversation_repository.py index d6355911..dd9c4610 100644 --- a/coaching/tests/unit/infrastructure/repositories/test_dynamodb_conversation_repository.py +++ b/coaching/tests/unit/infrastructure/repositories/test_dynamodb_conversation_repository.py @@ -1,244 +1,245 @@ -from datetime import UTC, datetime -from unittest.mock import MagicMock - -import pytest -from coaching.src.core.constants import ( - CoachingTopic, - ConversationStatus, - MessageRole, -) -from coaching.src.core.types import ( - ConversationId, - create_tenant_id, - create_user_id, -) -from coaching.src.domain.entities.conversation import Conversation -from coaching.src.domain.value_objects.conversation_context import ConversationContext -from coaching.src.domain.value_objects.message import Message -from coaching.src.infrastructure.repositories.dynamodb_conversation_repository import ( - DynamoDBConversationRepository, -) - - -@pytest.fixture -def mock_dynamodb_resource() -> MagicMock: - resource = MagicMock() - table = MagicMock() - resource.Table.return_value = table - return resource - - -@pytest.fixture -def mock_table(mock_dynamodb_resource: MagicMock) -> MagicMock: - return mock_dynamodb_resource.Table.return_value - - -@pytest.fixture -def repository(mock_dynamodb_resource: MagicMock) -> DynamoDBConversationRepository: - return DynamoDBConversationRepository(mock_dynamodb_resource, "test-table") - - -@pytest.fixture -def sample_conversation() -> Conversation: - return Conversation( - conversation_id=ConversationId("conv-123"), - user_id=create_user_id("user-123"), - tenant_id=create_tenant_id("tenant-123"), - topic=CoachingTopic.GOALS, - status=ConversationStatus.ACTIVE, - messages=[ - Message(role=MessageRole.USER, content="Hello", timestamp=datetime.now(UTC)), - Message(role=MessageRole.ASSISTANT, content="Hi", timestamp=datetime.now(UTC)), - ], - context=ConversationContext(), - created_at=datetime.now(UTC), - updated_at=datetime.now(UTC), - metadata={"key": "value"}, - ) - - -@pytest.mark.asyncio -async def test_save_success( - repository: DynamoDBConversationRepository, - mock_table: MagicMock, - sample_conversation: Conversation, -) -> None: - # Act - await repository.save(sample_conversation) - - # Assert - mock_table.put_item.assert_called_once() - call_args = mock_table.put_item.call_args - item = call_args.kwargs["Item"] - - assert item["conversation_id"] == sample_conversation.conversation_id - assert item["user_id"] == sample_conversation.user_id - assert item["tenant_id"] == sample_conversation.tenant_id - assert item["topic"] == sample_conversation.topic - assert item["status"] == sample_conversation.status.value - assert len(item["messages"]) == 2 - assert "ttl" in item - - -@pytest.mark.asyncio -async def test_save_error( - repository: DynamoDBConversationRepository, - mock_table: MagicMock, - sample_conversation: Conversation, -) -> None: - # Arrange - mock_table.put_item.side_effect = Exception("DynamoDB Error") - - # Act & Assert - with pytest.raises(Exception, match="DynamoDB Error"): - await repository.save(sample_conversation) - - -@pytest.mark.asyncio -async def test_get_by_id_success( - repository: DynamoDBConversationRepository, - mock_table: MagicMock, - sample_conversation: Conversation, -) -> None: - # Arrange - # Mock the item returned by DynamoDB - # We need to manually construct the item dict as it would be stored - item = repository._to_dynamodb_item(sample_conversation) - mock_table.get_item.return_value = {"Item": item} - - # Act - result = await repository.get_by_id(ConversationId("conv-123"), create_tenant_id("tenant-123")) - - # Assert - assert result is not None - assert result.conversation_id == sample_conversation.conversation_id - assert result.user_id == sample_conversation.user_id - assert len(result.messages) == 2 - - -@pytest.mark.asyncio -async def test_get_by_id_not_found( - repository: DynamoDBConversationRepository, mock_table: MagicMock -) -> None: - # Arrange - mock_table.get_item.return_value = {} - - # Act - result = await repository.get_by_id(ConversationId("conv-123")) - - # Assert - assert result is None - - -@pytest.mark.asyncio -async def test_get_by_id_tenant_mismatch( - repository: DynamoDBConversationRepository, - mock_table: MagicMock, - sample_conversation: Conversation, -) -> None: - # Arrange - item = repository._to_dynamodb_item(sample_conversation) - mock_table.get_item.return_value = {"Item": item} - - # Act - result = await repository.get_by_id( - ConversationId("conv-123"), create_tenant_id("other-tenant") - ) - - # Assert - assert result is None - - -@pytest.mark.asyncio -async def test_get_by_user_success(repository, mock_table, sample_conversation): - # Arrange - item = repository._to_dynamodb_item(sample_conversation) - mock_table.query.return_value = {"Items": [item]} - - # Act - results = await repository.get_by_user("user-123", "tenant-123") - - # Assert - assert len(results) == 1 - assert results[0].conversation_id == sample_conversation.conversation_id - - # Verify query params - mock_table.query.assert_called_once() - call_kwargs = mock_table.query.call_args.kwargs - assert call_kwargs["IndexName"] == "user_id-index" - assert call_kwargs["Limit"] == 10 - - -@pytest.mark.asyncio -async def test_get_by_user_active_only(repository, mock_table): - # Arrange - mock_table.query.return_value = {"Items": []} - - # Act - await repository.get_by_user("user-123", active_only=True) - - # Assert - call_kwargs = mock_table.query.call_args.kwargs - assert "FilterExpression" in call_kwargs - # Note: Checking exact FilterExpression structure with boto3 conditions is tricky in mocks - # We assume if FilterExpression is present, logic was triggered - - -@pytest.mark.asyncio -async def test_delete_success(repository, mock_table, sample_conversation): - # Arrange - # First get_by_id is called - item = repository._to_dynamodb_item(sample_conversation) - mock_table.get_item.return_value = {"Item": item} - - # Act - result = await repository.delete("conv-123", "tenant-123") - - # Assert - assert result is True - mock_table.update_item.assert_called_once() - call_kwargs = mock_table.update_item.call_args.kwargs - assert call_kwargs["Key"] == {"conversation_id": "conv-123"} - assert ":status" in call_kwargs["ExpressionAttributeValues"] - assert call_kwargs["ExpressionAttributeValues"][":status"] == ConversationStatus.ABANDONED.value - - -@pytest.mark.asyncio -async def test_delete_not_found(repository, mock_table): - # Arrange - mock_table.get_item.return_value = {} - - # Act - result = await repository.delete("conv-123") - - # Assert - assert result is False - mock_table.update_item.assert_not_called() - - -@pytest.mark.asyncio -async def test_exists(repository, mock_table, sample_conversation): - # Arrange - item = repository._to_dynamodb_item(sample_conversation) - mock_table.get_item.return_value = {"Item": item} - - # Act - exists = await repository.exists("conv-123") - - # Assert - assert exists is True - - -@pytest.mark.asyncio -async def test_get_active_count(repository, mock_table, sample_conversation): - # Arrange - item = repository._to_dynamodb_item(sample_conversation) - mock_table.query.return_value = {"Items": [item, item]} # Return 2 items - - # Act - count = await repository.get_active_count("user-123") - - # Assert - assert count == 2 - call_kwargs = mock_table.query.call_args.kwargs - assert call_kwargs["Limit"] == 100 +from datetime import UTC, datetime +from unittest.mock import MagicMock + +import pytest + +from coaching.src.core.constants import ( + CoachingTopic, + ConversationStatus, + MessageRole, +) +from coaching.src.core.types import ( + ConversationId, + create_tenant_id, + create_user_id, +) +from coaching.src.domain.entities.conversation import Conversation +from coaching.src.domain.value_objects.conversation_context import ConversationContext +from coaching.src.domain.value_objects.message import Message +from coaching.src.infrastructure.repositories.dynamodb_conversation_repository import ( + DynamoDBConversationRepository, +) + + +@pytest.fixture +def mock_dynamodb_resource() -> MagicMock: + resource = MagicMock() + table = MagicMock() + resource.Table.return_value = table + return resource + + +@pytest.fixture +def mock_table(mock_dynamodb_resource: MagicMock) -> MagicMock: + return mock_dynamodb_resource.Table.return_value + + +@pytest.fixture +def repository(mock_dynamodb_resource: MagicMock) -> DynamoDBConversationRepository: + return DynamoDBConversationRepository(mock_dynamodb_resource, "test-table") + + +@pytest.fixture +def sample_conversation() -> Conversation: + return Conversation( + conversation_id=ConversationId("conv-123"), + user_id=create_user_id("user-123"), + tenant_id=create_tenant_id("tenant-123"), + topic=CoachingTopic.GOALS, + status=ConversationStatus.ACTIVE, + messages=[ + Message(role=MessageRole.USER, content="Hello", timestamp=datetime.now(UTC)), + Message(role=MessageRole.ASSISTANT, content="Hi", timestamp=datetime.now(UTC)), + ], + context=ConversationContext(), + created_at=datetime.now(UTC), + updated_at=datetime.now(UTC), + metadata={"key": "value"}, + ) + + +@pytest.mark.asyncio +async def test_save_success( + repository: DynamoDBConversationRepository, + mock_table: MagicMock, + sample_conversation: Conversation, +) -> None: + # Act + await repository.save(sample_conversation) + + # Assert + mock_table.put_item.assert_called_once() + call_args = mock_table.put_item.call_args + item = call_args.kwargs["Item"] + + assert item["conversation_id"] == sample_conversation.conversation_id + assert item["user_id"] == sample_conversation.user_id + assert item["tenant_id"] == sample_conversation.tenant_id + assert item["topic"] == sample_conversation.topic + assert item["status"] == sample_conversation.status.value + assert len(item["messages"]) == 2 + assert "ttl" in item + + +@pytest.mark.asyncio +async def test_save_error( + repository: DynamoDBConversationRepository, + mock_table: MagicMock, + sample_conversation: Conversation, +) -> None: + # Arrange + mock_table.put_item.side_effect = Exception("DynamoDB Error") + + # Act & Assert + with pytest.raises(Exception, match="DynamoDB Error"): + await repository.save(sample_conversation) + + +@pytest.mark.asyncio +async def test_get_by_id_success( + repository: DynamoDBConversationRepository, + mock_table: MagicMock, + sample_conversation: Conversation, +) -> None: + # Arrange + # Mock the item returned by DynamoDB + # We need to manually construct the item dict as it would be stored + item = repository._to_dynamodb_item(sample_conversation) + mock_table.get_item.return_value = {"Item": item} + + # Act + result = await repository.get_by_id(ConversationId("conv-123"), create_tenant_id("tenant-123")) + + # Assert + assert result is not None + assert result.conversation_id == sample_conversation.conversation_id + assert result.user_id == sample_conversation.user_id + assert len(result.messages) == 2 + + +@pytest.mark.asyncio +async def test_get_by_id_not_found( + repository: DynamoDBConversationRepository, mock_table: MagicMock +) -> None: + # Arrange + mock_table.get_item.return_value = {} + + # Act + result = await repository.get_by_id(ConversationId("conv-123")) + + # Assert + assert result is None + + +@pytest.mark.asyncio +async def test_get_by_id_tenant_mismatch( + repository: DynamoDBConversationRepository, + mock_table: MagicMock, + sample_conversation: Conversation, +) -> None: + # Arrange + item = repository._to_dynamodb_item(sample_conversation) + mock_table.get_item.return_value = {"Item": item} + + # Act + result = await repository.get_by_id( + ConversationId("conv-123"), create_tenant_id("other-tenant") + ) + + # Assert + assert result is None + + +@pytest.mark.asyncio +async def test_get_by_user_success(repository, mock_table, sample_conversation): + # Arrange + item = repository._to_dynamodb_item(sample_conversation) + mock_table.query.return_value = {"Items": [item]} + + # Act + results = await repository.get_by_user("user-123", "tenant-123") + + # Assert + assert len(results) == 1 + assert results[0].conversation_id == sample_conversation.conversation_id + + # Verify query params + mock_table.query.assert_called_once() + call_kwargs = mock_table.query.call_args.kwargs + assert call_kwargs["IndexName"] == "user_id-index" + assert call_kwargs["Limit"] == 10 + + +@pytest.mark.asyncio +async def test_get_by_user_active_only(repository, mock_table): + # Arrange + mock_table.query.return_value = {"Items": []} + + # Act + await repository.get_by_user("user-123", active_only=True) + + # Assert + call_kwargs = mock_table.query.call_args.kwargs + assert "FilterExpression" in call_kwargs + # Note: Checking exact FilterExpression structure with boto3 conditions is tricky in mocks + # We assume if FilterExpression is present, logic was triggered + + +@pytest.mark.asyncio +async def test_delete_success(repository, mock_table, sample_conversation): + # Arrange + # First get_by_id is called + item = repository._to_dynamodb_item(sample_conversation) + mock_table.get_item.return_value = {"Item": item} + + # Act + result = await repository.delete("conv-123", "tenant-123") + + # Assert + assert result is True + mock_table.update_item.assert_called_once() + call_kwargs = mock_table.update_item.call_args.kwargs + assert call_kwargs["Key"] == {"conversation_id": "conv-123"} + assert ":status" in call_kwargs["ExpressionAttributeValues"] + assert call_kwargs["ExpressionAttributeValues"][":status"] == ConversationStatus.ABANDONED.value + + +@pytest.mark.asyncio +async def test_delete_not_found(repository, mock_table): + # Arrange + mock_table.get_item.return_value = {} + + # Act + result = await repository.delete("conv-123") + + # Assert + assert result is False + mock_table.update_item.assert_not_called() + + +@pytest.mark.asyncio +async def test_exists(repository, mock_table, sample_conversation): + # Arrange + item = repository._to_dynamodb_item(sample_conversation) + mock_table.get_item.return_value = {"Item": item} + + # Act + exists = await repository.exists("conv-123") + + # Assert + assert exists is True + + +@pytest.mark.asyncio +async def test_get_active_count(repository, mock_table, sample_conversation): + # Arrange + item = repository._to_dynamodb_item(sample_conversation) + mock_table.query.return_value = {"Items": [item, item]} # Return 2 items + + # Act + count = await repository.get_active_count("user-123") + + # Assert + assert count == 2 + call_kwargs = mock_table.query.call_args.kwargs + assert call_kwargs["Limit"] == 100 diff --git a/coaching/tests/unit/integration/sql_template/test_service.py b/coaching/tests/unit/integration/sql_template/test_service.py index 5dda9ee2..809a7f2f 100644 --- a/coaching/tests/unit/integration/sql_template/test_service.py +++ b/coaching/tests/unit/integration/sql_template/test_service.py @@ -6,6 +6,8 @@ from uuid import UUID import pytest +from pydantic import ValidationError as PydanticValidationError + from coaching.src.integration.sql_template.enums import ErrorCode, ErrorStage from coaching.src.integration.sql_template.errors import SqlTemplateGenerationError from coaching.src.integration.sql_template.idempotency import InMemoryGenerationIdempotencyStore @@ -17,7 +19,6 @@ ) from coaching.src.integration.sql_template.sql_generator import SqlTemplateGenerator from coaching.src.integration.sql_template.sql_validator import SqlTemplateValidator -from pydantic import ValidationError as PydanticValidationError class StubDiscoveryClient: diff --git a/coaching/tests/unit/integration/sql_template/test_sql_validator.py b/coaching/tests/unit/integration/sql_template/test_sql_validator.py index 4100f846..07d96a82 100644 --- a/coaching/tests/unit/integration/sql_template/test_sql_validator.py +++ b/coaching/tests/unit/integration/sql_template/test_sql_validator.py @@ -3,6 +3,7 @@ from __future__ import annotations import pytest + from coaching.src.integration.sql_template.enums import ValidationFailureCode, ValidationMethod from coaching.src.integration.sql_template.errors import ValidationError from coaching.src.integration.sql_template.models import RequestedEvent, SqlGenerationResult diff --git a/coaching/tests/unit/llm/providers/test_bedrock.py b/coaching/tests/unit/llm/providers/test_bedrock.py index cad1e46e..0e9baa6e 100644 --- a/coaching/tests/unit/llm/providers/test_bedrock.py +++ b/coaching/tests/unit/llm/providers/test_bedrock.py @@ -1,136 +1,137 @@ -from collections.abc import AsyncIterator, Iterator -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest -from coaching.src.llm.providers.base import ProviderConfig, ProviderType -from coaching.src.llm.providers.bedrock import BedrockProvider - - -@pytest.fixture -def provider_config() -> ProviderConfig: - return ProviderConfig( - provider_type=ProviderType.BEDROCK, - model_name="anthropic.claude-3-sonnet-20240229-v1:0", - region_name="us-east-1", - api_key="test-key", - ) - - -@pytest.fixture -def mock_boto3() -> Iterator[MagicMock]: - with patch("boto3.Session") as mock: - yield mock - - -@pytest.fixture -def mock_chat_bedrock() -> Iterator[MagicMock]: - """Mock ChatBedrockConverse for testing.""" - with patch("coaching.src.llm.providers.bedrock.ChatBedrockConverse") as mock: - yield mock - - -@pytest.mark.asyncio -async def test_initialize( - provider_config: ProviderConfig, mock_boto3: MagicMock, mock_chat_bedrock: MagicMock -) -> None: - provider = BedrockProvider(provider_config) - await provider.initialize() - assert provider._client is not None - mock_chat_bedrock.assert_called() - - -@pytest.mark.asyncio -async def test_invoke( - provider_config: ProviderConfig, mock_boto3: MagicMock, mock_chat_bedrock: MagicMock -) -> None: - provider = BedrockProvider(provider_config) - await provider.initialize() - - mock_llm = mock_chat_bedrock.return_value - mock_llm.ainvoke = AsyncMock(return_value=MagicMock(content="Test response")) - - response = await provider.invoke([]) - - assert response == "Test response" - mock_llm.ainvoke.assert_called() - - -@pytest.mark.asyncio -async def test_stream(provider_config: ProviderConfig, mock_chat_bedrock: MagicMock) -> None: - provider = BedrockProvider(provider_config) - await provider.initialize() - - mock_llm = mock_chat_bedrock.return_value - - # Mock astream to return an async iterator - async def async_generator() -> AsyncIterator[MagicMock]: - yield MagicMock(content="Chunk 1") - yield MagicMock(content="Chunk 2") - - mock_llm.astream.return_value = async_generator() - - chunks = [] - async for chunk in provider.stream([]): - chunks.append(chunk) - - assert chunks == ["Chunk 1", "Chunk 2"] - - -@pytest.mark.asyncio -async def test_validate_model_supported(provider_config: ProviderConfig) -> None: - provider = BedrockProvider(provider_config) - - with patch("boto3.Session") as mock_session: - mock_client = MagicMock() - mock_session.return_value.client.return_value = mock_client - - mock_client.list_foundation_models.return_value = { - "modelSummaries": [{"modelId": "anthropic.claude-3-sonnet-20240229-v1:0"}] - } - - is_valid = await provider.validate_model("anthropic.claude-3-sonnet-20240229-v1:0") - assert is_valid is True - - -@pytest.mark.asyncio -async def test_validate_model_unsupported(provider_config: ProviderConfig) -> None: - provider = BedrockProvider(provider_config) - is_valid = await provider.validate_model("unsupported-model") - assert is_valid is False - - -@pytest.mark.asyncio -async def test_get_model_info(provider_config: ProviderConfig) -> None: - provider = BedrockProvider(provider_config) - - with patch("boto3.Session") as mock_session: - mock_client = MagicMock() - mock_session.return_value.client.return_value = mock_client - - mock_client.get_foundation_model.return_value = { - "modelDetails": {"modelName": "Claude 3 Sonnet", "providerName": "Anthropic"} - } - - info = await provider.get_model_info() - assert info["model_name"] == "Claude 3 Sonnet" - assert info["provider_name"] == "Anthropic" - - -@pytest.mark.asyncio -async def test_cleanup(provider_config: ProviderConfig) -> None: - provider = BedrockProvider(provider_config) - await provider.initialize() - assert provider._client is not None - - await provider.cleanup() - assert provider._client is None - - -def test_count_tokens(provider_config: ProviderConfig) -> None: - provider = BedrockProvider(provider_config) - # Mock tokenizer - provider.tokenizer = MagicMock() - provider.tokenizer.encode.return_value = [1, 2, 3] - - count = provider.count_tokens("test text") - assert count == 3 +from collections.abc import AsyncIterator, Iterator +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from coaching.src.llm.providers.base import ProviderConfig, ProviderType +from coaching.src.llm.providers.bedrock import BedrockProvider + + +@pytest.fixture +def provider_config() -> ProviderConfig: + return ProviderConfig( + provider_type=ProviderType.BEDROCK, + model_name="anthropic.claude-3-sonnet-20240229-v1:0", + region_name="us-east-1", + api_key="test-key", + ) + + +@pytest.fixture +def mock_boto3() -> Iterator[MagicMock]: + with patch("boto3.Session") as mock: + yield mock + + +@pytest.fixture +def mock_chat_bedrock() -> Iterator[MagicMock]: + """Mock ChatBedrockConverse for testing.""" + with patch("coaching.src.llm.providers.bedrock.ChatBedrockConverse") as mock: + yield mock + + +@pytest.mark.asyncio +async def test_initialize( + provider_config: ProviderConfig, mock_boto3: MagicMock, mock_chat_bedrock: MagicMock +) -> None: + provider = BedrockProvider(provider_config) + await provider.initialize() + assert provider._client is not None + mock_chat_bedrock.assert_called() + + +@pytest.mark.asyncio +async def test_invoke( + provider_config: ProviderConfig, mock_boto3: MagicMock, mock_chat_bedrock: MagicMock +) -> None: + provider = BedrockProvider(provider_config) + await provider.initialize() + + mock_llm = mock_chat_bedrock.return_value + mock_llm.ainvoke = AsyncMock(return_value=MagicMock(content="Test response")) + + response = await provider.invoke([]) + + assert response == "Test response" + mock_llm.ainvoke.assert_called() + + +@pytest.mark.asyncio +async def test_stream(provider_config: ProviderConfig, mock_chat_bedrock: MagicMock) -> None: + provider = BedrockProvider(provider_config) + await provider.initialize() + + mock_llm = mock_chat_bedrock.return_value + + # Mock astream to return an async iterator + async def async_generator() -> AsyncIterator[MagicMock]: + yield MagicMock(content="Chunk 1") + yield MagicMock(content="Chunk 2") + + mock_llm.astream.return_value = async_generator() + + chunks = [] + async for chunk in provider.stream([]): + chunks.append(chunk) + + assert chunks == ["Chunk 1", "Chunk 2"] + + +@pytest.mark.asyncio +async def test_validate_model_supported(provider_config: ProviderConfig) -> None: + provider = BedrockProvider(provider_config) + + with patch("boto3.Session") as mock_session: + mock_client = MagicMock() + mock_session.return_value.client.return_value = mock_client + + mock_client.list_foundation_models.return_value = { + "modelSummaries": [{"modelId": "anthropic.claude-3-sonnet-20240229-v1:0"}] + } + + is_valid = await provider.validate_model("anthropic.claude-3-sonnet-20240229-v1:0") + assert is_valid is True + + +@pytest.mark.asyncio +async def test_validate_model_unsupported(provider_config: ProviderConfig) -> None: + provider = BedrockProvider(provider_config) + is_valid = await provider.validate_model("unsupported-model") + assert is_valid is False + + +@pytest.mark.asyncio +async def test_get_model_info(provider_config: ProviderConfig) -> None: + provider = BedrockProvider(provider_config) + + with patch("boto3.Session") as mock_session: + mock_client = MagicMock() + mock_session.return_value.client.return_value = mock_client + + mock_client.get_foundation_model.return_value = { + "modelDetails": {"modelName": "Claude 3 Sonnet", "providerName": "Anthropic"} + } + + info = await provider.get_model_info() + assert info["model_name"] == "Claude 3 Sonnet" + assert info["provider_name"] == "Anthropic" + + +@pytest.mark.asyncio +async def test_cleanup(provider_config: ProviderConfig) -> None: + provider = BedrockProvider(provider_config) + await provider.initialize() + assert provider._client is not None + + await provider.cleanup() + assert provider._client is None + + +def test_count_tokens(provider_config: ProviderConfig) -> None: + provider = BedrockProvider(provider_config) + # Mock tokenizer + provider.tokenizer = MagicMock() + provider.tokenizer.encode.return_value = [1, 2, 3] + + count = provider.count_tokens("test text") + assert count == 3 diff --git a/coaching/tests/unit/models/test_coaching_results.py b/coaching/tests/unit/models/test_coaching_results.py index 0c35107b..c05f397d 100644 --- a/coaching/tests/unit/models/test_coaching_results.py +++ b/coaching/tests/unit/models/test_coaching_results.py @@ -1,219 +1,220 @@ -"""Unit tests for coaching result models. - -Tests for the Pydantic models used to capture final coaching session results. -""" - -import pytest -from coaching.src.models.coaching_results import ( - COACHING_RESULT_MODELS, - CoreValue, - CoreValuesResult, - PurposeResult, - VisionResult, - get_coaching_result_model, - get_result_json_schema, -) -from pydantic import ValidationError - - -class TestCoreValue: - """Tests for CoreValue model.""" - - def test_create_valid_core_value(self) -> None: - """Test creating a valid core value.""" - value = CoreValue( - name="Integrity", - description="We act with honesty and ethics in all situations and interactions", - importance="Guides our decision making and builds trust with stakeholders", - ) - - assert value.name == "Integrity" - assert "honesty" in value.description.lower() - assert len(value.importance) > 0 - - def test_name_validation_min_length(self) -> None: - """Test that name must have minimum length.""" - with pytest.raises(ValidationError): - CoreValue( - name="", # Too short - description="A valid description of the value with enough characters", - importance="Why this matters is very important for the org", - ) - - def test_description_validation_min_length(self) -> None: - """Test that description must have minimum length.""" - with pytest.raises(ValidationError): - CoreValue( - name="Test Value", - description="Short", # Too short (< 10) - importance="Why this matters is very important for the org", - ) - - -class TestCoreValuesResult: - """Tests for CoreValuesResult model.""" - - def test_create_valid_result(self) -> None: - """Test creating a valid CoreValuesResult.""" - result = CoreValuesResult( - values=[ - CoreValue( - name="Innovation", - description="Constantly seeking new and better ways to serve our customers", - importance="Drives our competitive advantage and market leadership", - ), - CoreValue( - name="Teamwork", - description="Working together to achieve more than any individual could alone", - importance="Enables us to tackle complex challenges collaboratively", - ), - ], - summary="These values guide our daily decisions and culture. " - "They represent who we are as an organization.", - ) - - assert len(result.values) == 2 - assert result.values[0].name == "Innovation" - assert len(result.summary) > 0 - - def test_requires_at_least_one_value(self) -> None: - """Test that at least one core value is required.""" - with pytest.raises(ValidationError): - CoreValuesResult( - values=[], # Empty list - summary="No values to summarize but this needs to be at least fifty characters long.", - ) - - def test_max_seven_values(self) -> None: - """Test that maximum 7 core values are allowed.""" - values = [ - CoreValue( - name=f"Value Number {i}", - description=f"Description for value number {i} with enough characters", - importance=f"Importance of value number {i} with enough characters", - ) - for i in range(8) # 8 values - ] - - with pytest.raises(ValidationError): - CoreValuesResult( - values=values, - summary="Too many values but summary still needs to be at least fifty characters long.", - ) - - -class TestPurposeResult: - """Tests for PurposeResult model.""" - - def test_create_valid_result(self) -> None: - """Test creating a valid PurposeResult.""" - result = PurposeResult( - purpose_statement="To empower businesses to reach their full potential", - why_it_matters="Small and medium-sized businesses are the backbone of the economy " - "and deserve access to world-class guidance and support.", - how_it_guides="Every decision we make is evaluated against whether it helps businesses " - "grow and succeed in their markets.", - ) - - assert "empower" in result.purpose_statement.lower() - assert len(result.why_it_matters) > 0 - assert len(result.how_it_guides) > 0 - - def test_purpose_statement_min_length(self) -> None: - """Test that purpose statement has minimum length.""" - with pytest.raises(ValidationError): - PurposeResult( - purpose_statement="Short", # Too short (< 20) - why_it_matters="This is a valid why_it_matters field with enough characters.", - how_it_guides="This is a valid how_it_guides field with enough characters.", - ) - - -class TestVisionResult: - """Tests for VisionResult model.""" - - def test_create_valid_result(self) -> None: - """Test creating a valid VisionResult.""" - result = VisionResult( - vision_statement="To be the leading provider of AI coaching solutions", - time_horizon="5 years", - key_aspirations=[ - "Market leader in AI coaching", - "10,000 active customers", - "Global presence", - ], - ) - - assert "leading" in result.vision_statement.lower() - assert result.time_horizon == "5 years" - assert len(result.key_aspirations) == 3 - - def test_requires_at_least_one_aspiration(self) -> None: - """Test that at least one key aspiration is required.""" - with pytest.raises(ValidationError): - VisionResult( - vision_statement="A valid vision statement with enough length", - time_horizon="3 years", - key_aspirations=[], # Empty - ) - - -class TestCoachingResultModelsRegistry: - """Tests for the result model registry.""" - - def test_registry_contains_all_models(self) -> None: - """Test that registry contains all expected models.""" - assert "CoreValuesResult" in COACHING_RESULT_MODELS - assert "PurposeResult" in COACHING_RESULT_MODELS - assert "VisionResult" in COACHING_RESULT_MODELS - - def test_registry_values_are_classes(self) -> None: - """Test that registry values are Pydantic model classes.""" - for model in COACHING_RESULT_MODELS.values(): - assert hasattr(model, "model_validate") - assert hasattr(model, "model_json_schema") - - -class TestGetCoachingResultModel: - """Tests for get_coaching_result_model function.""" - - def test_get_existing_model(self) -> None: - """Test getting an existing model.""" - model = get_coaching_result_model("CoreValuesResult") - - assert model is not None - assert model == CoreValuesResult - - def test_get_nonexistent_model_returns_none(self) -> None: - """Test that getting a nonexistent model returns None.""" - model = get_coaching_result_model("NonexistentModel") - - assert model is None - - -class TestGetResultJsonSchema: - """Tests for get_result_json_schema function.""" - - def test_get_schema_for_existing_model(self) -> None: - """Test getting JSON schema for existing model.""" - schema = get_result_json_schema("CoreValuesResult") - - assert schema is not None - assert "properties" in schema - assert "values" in schema["properties"] - assert "summary" in schema["properties"] - - def test_get_schema_for_nonexistent_model(self) -> None: - """Test that getting schema for nonexistent model returns None.""" - schema = get_result_json_schema("NonexistentModel") - - assert schema is None - - def test_schema_includes_descriptions(self) -> None: - """Test that schema includes field descriptions.""" - schema = get_result_json_schema("PurposeResult") - - assert schema is not None - # Check that descriptions are included in the schema - props = schema.get("properties", {}) - assert "purpose_statement" in props +"""Unit tests for coaching result models. + +Tests for the Pydantic models used to capture final coaching session results. +""" + +import pytest +from pydantic import ValidationError + +from coaching.src.models.coaching_results import ( + COACHING_RESULT_MODELS, + CoreValue, + CoreValuesResult, + PurposeResult, + VisionResult, + get_coaching_result_model, + get_result_json_schema, +) + + +class TestCoreValue: + """Tests for CoreValue model.""" + + def test_create_valid_core_value(self) -> None: + """Test creating a valid core value.""" + value = CoreValue( + name="Integrity", + description="We act with honesty and ethics in all situations and interactions", + importance="Guides our decision making and builds trust with stakeholders", + ) + + assert value.name == "Integrity" + assert "honesty" in value.description.lower() + assert len(value.importance) > 0 + + def test_name_validation_min_length(self) -> None: + """Test that name must have minimum length.""" + with pytest.raises(ValidationError): + CoreValue( + name="", # Too short + description="A valid description of the value with enough characters", + importance="Why this matters is very important for the org", + ) + + def test_description_validation_min_length(self) -> None: + """Test that description must have minimum length.""" + with pytest.raises(ValidationError): + CoreValue( + name="Test Value", + description="Short", # Too short (< 10) + importance="Why this matters is very important for the org", + ) + + +class TestCoreValuesResult: + """Tests for CoreValuesResult model.""" + + def test_create_valid_result(self) -> None: + """Test creating a valid CoreValuesResult.""" + result = CoreValuesResult( + values=[ + CoreValue( + name="Innovation", + description="Constantly seeking new and better ways to serve our customers", + importance="Drives our competitive advantage and market leadership", + ), + CoreValue( + name="Teamwork", + description="Working together to achieve more than any individual could alone", + importance="Enables us to tackle complex challenges collaboratively", + ), + ], + summary="These values guide our daily decisions and culture. " + "They represent who we are as an organization.", + ) + + assert len(result.values) == 2 + assert result.values[0].name == "Innovation" + assert len(result.summary) > 0 + + def test_requires_at_least_one_value(self) -> None: + """Test that at least one core value is required.""" + with pytest.raises(ValidationError): + CoreValuesResult( + values=[], # Empty list + summary="No values to summarize but this needs to be at least fifty characters long.", + ) + + def test_max_seven_values(self) -> None: + """Test that maximum 7 core values are allowed.""" + values = [ + CoreValue( + name=f"Value Number {i}", + description=f"Description for value number {i} with enough characters", + importance=f"Importance of value number {i} with enough characters", + ) + for i in range(8) # 8 values + ] + + with pytest.raises(ValidationError): + CoreValuesResult( + values=values, + summary="Too many values but summary still needs to be at least fifty characters long.", + ) + + +class TestPurposeResult: + """Tests for PurposeResult model.""" + + def test_create_valid_result(self) -> None: + """Test creating a valid PurposeResult.""" + result = PurposeResult( + purpose_statement="To empower businesses to reach their full potential", + why_it_matters="Small and medium-sized businesses are the backbone of the economy " + "and deserve access to world-class guidance and support.", + how_it_guides="Every decision we make is evaluated against whether it helps businesses " + "grow and succeed in their markets.", + ) + + assert "empower" in result.purpose_statement.lower() + assert len(result.why_it_matters) > 0 + assert len(result.how_it_guides) > 0 + + def test_purpose_statement_min_length(self) -> None: + """Test that purpose statement has minimum length.""" + with pytest.raises(ValidationError): + PurposeResult( + purpose_statement="Short", # Too short (< 20) + why_it_matters="This is a valid why_it_matters field with enough characters.", + how_it_guides="This is a valid how_it_guides field with enough characters.", + ) + + +class TestVisionResult: + """Tests for VisionResult model.""" + + def test_create_valid_result(self) -> None: + """Test creating a valid VisionResult.""" + result = VisionResult( + vision_statement="To be the leading provider of AI coaching solutions", + time_horizon="5 years", + key_aspirations=[ + "Market leader in AI coaching", + "10,000 active customers", + "Global presence", + ], + ) + + assert "leading" in result.vision_statement.lower() + assert result.time_horizon == "5 years" + assert len(result.key_aspirations) == 3 + + def test_requires_at_least_one_aspiration(self) -> None: + """Test that at least one key aspiration is required.""" + with pytest.raises(ValidationError): + VisionResult( + vision_statement="A valid vision statement with enough length", + time_horizon="3 years", + key_aspirations=[], # Empty + ) + + +class TestCoachingResultModelsRegistry: + """Tests for the result model registry.""" + + def test_registry_contains_all_models(self) -> None: + """Test that registry contains all expected models.""" + assert "CoreValuesResult" in COACHING_RESULT_MODELS + assert "PurposeResult" in COACHING_RESULT_MODELS + assert "VisionResult" in COACHING_RESULT_MODELS + + def test_registry_values_are_classes(self) -> None: + """Test that registry values are Pydantic model classes.""" + for model in COACHING_RESULT_MODELS.values(): + assert hasattr(model, "model_validate") + assert hasattr(model, "model_json_schema") + + +class TestGetCoachingResultModel: + """Tests for get_coaching_result_model function.""" + + def test_get_existing_model(self) -> None: + """Test getting an existing model.""" + model = get_coaching_result_model("CoreValuesResult") + + assert model is not None + assert model == CoreValuesResult + + def test_get_nonexistent_model_returns_none(self) -> None: + """Test that getting a nonexistent model returns None.""" + model = get_coaching_result_model("NonexistentModel") + + assert model is None + + +class TestGetResultJsonSchema: + """Tests for get_result_json_schema function.""" + + def test_get_schema_for_existing_model(self) -> None: + """Test getting JSON schema for existing model.""" + schema = get_result_json_schema("CoreValuesResult") + + assert schema is not None + assert "properties" in schema + assert "values" in schema["properties"] + assert "summary" in schema["properties"] + + def test_get_schema_for_nonexistent_model(self) -> None: + """Test that getting schema for nonexistent model returns None.""" + schema = get_result_json_schema("NonexistentModel") + + assert schema is None + + def test_schema_includes_descriptions(self) -> None: + """Test that schema includes field descriptions.""" + schema = get_result_json_schema("PurposeResult") + + assert schema is not None + # Check that descriptions are included in the schema + props = schema.get("properties", {}) + assert "purpose_statement" in props diff --git a/coaching/tests/unit/models/test_email_insight_response.py b/coaching/tests/unit/models/test_email_insight_response.py index a82b0d79..7040f0fb 100644 --- a/coaching/tests/unit/models/test_email_insight_response.py +++ b/coaching/tests/unit/models/test_email_insight_response.py @@ -3,9 +3,10 @@ from datetime import UTC, datetime import pytest -from coaching.src.models.responses import EmailInsightResponse from pydantic import ValidationError +from coaching.src.models.responses import EmailInsightResponse + @pytest.mark.unit class TestEmailInsightResponse: diff --git a/coaching/tests/unit/models/test_llm_coaching_response.py b/coaching/tests/unit/models/test_llm_coaching_response.py index e89ab32b..d5f98112 100644 --- a/coaching/tests/unit/models/test_llm_coaching_response.py +++ b/coaching/tests/unit/models/test_llm_coaching_response.py @@ -1,265 +1,266 @@ -"""Unit tests for LLM coaching response model and parsing.""" - -import pytest -from coaching.src.models.llm_coaching_response import ( - AUTO_COMPLETION_CONFIDENCE_THRESHOLD, - LLMCoachingResponse, - parse_llm_coaching_response, - should_auto_complete, -) - - -class TestLLMCoachingResponse: - """Tests for LLMCoachingResponse model.""" - - def test_basic_response_creation(self) -> None: - """Test creating a basic response with required fields.""" - response = LLMCoachingResponse(message="Hello, how can I help?") - - assert response.message == "Hello, how can I help?" - assert response.is_final is False - assert response.result is None - assert response.confidence == 0.0 - - def test_completion_response_creation(self) -> None: - """Test creating a completion response with all fields.""" - result = { - "values": [{"name": "Integrity", "description": "...", "importance": "..."}], - "summary": "Your core values are...", - } - response = LLMCoachingResponse( - message="Thank you for this session!", - is_final=True, - result=result, - confidence=0.92, - ) - - assert response.message == "Thank you for this session!" - assert response.is_final is True - assert response.result == result - assert response.confidence == 0.92 - - def test_confidence_validation_min(self) -> None: - """Test that confidence below 0 is rejected.""" - with pytest.raises(ValueError): - LLMCoachingResponse(message="Test", confidence=-0.1) - - def test_confidence_validation_max(self) -> None: - """Test that confidence above 1 is rejected.""" - with pytest.raises(ValueError): - LLMCoachingResponse(message="Test", confidence=1.1) - - def test_message_required(self) -> None: - """Test that message is required.""" - with pytest.raises(ValueError): - LLMCoachingResponse(message="") # type: ignore - - def test_message_min_length(self) -> None: - """Test that empty message is rejected.""" - with pytest.raises(ValueError): - LLMCoachingResponse(message="") - - -class TestParseLLMCoachingResponse: - """Tests for parse_llm_coaching_response function.""" - - def test_parse_valid_json_normal_response(self) -> None: - """Test parsing valid JSON for normal conversation.""" - raw = '{"message": "Tell me more about that.", "is_final": false, "result": null, "confidence": 0.0}' - - parsed = parse_llm_coaching_response(raw) - - assert parsed.message == "Tell me more about that." - assert parsed.is_final is False - assert parsed.result is None - assert parsed.confidence == 0.0 - - def test_parse_valid_json_completion_response(self) -> None: - """Test parsing valid JSON for completion.""" - raw = """ - { - "message": "Thank you for this wonderful session!", - "is_final": true, - "result": { - "values": [ - {"name": "Integrity", "description": "Being honest", "importance": "Guides decisions"} - ], - "summary": "Your core value is integrity." - }, - "confidence": 0.92 - } - """ - - parsed = parse_llm_coaching_response(raw) - - assert parsed.message == "Thank you for this wonderful session!" - assert parsed.is_final is True - assert parsed.result is not None - assert len(parsed.result["values"]) == 1 - assert parsed.confidence == 0.92 - - def test_parse_json_in_markdown_block(self) -> None: - """Test parsing JSON wrapped in markdown code block.""" - raw = """ -```json -{ - "message": "Here are my thoughts...", - "is_final": false, - "result": null, - "confidence": 0.0 -} -``` -""" - - parsed = parse_llm_coaching_response(raw) - - assert parsed.message == "Here are my thoughts..." - assert parsed.is_final is False - - def test_parse_json_in_generic_markdown_block(self) -> None: - """Test parsing JSON in generic code block (no language specified).""" - raw = """ -``` -{"message": "Testing", "is_final": false, "result": null, "confidence": 0.0} -``` -""" - - parsed = parse_llm_coaching_response(raw) - - assert parsed.message == "Testing" - assert parsed.is_final is False - - def test_fallback_for_plain_text_response(self) -> None: - """Test graceful handling of non-JSON responses.""" - raw = "This is a plain text response without any JSON structure." - - parsed = parse_llm_coaching_response(raw) - - assert parsed.message == raw - assert parsed.is_final is False - assert parsed.result is None - assert parsed.confidence == 0.0 - - def test_fallback_for_invalid_json(self) -> None: - """Test fallback for malformed JSON.""" - raw = '{"message": "incomplete json' - - parsed = parse_llm_coaching_response(raw) - - assert parsed.message == raw - assert parsed.is_final is False - - def test_fallback_for_json_array(self) -> None: - """Test fallback when JSON is an array instead of object.""" - raw = '["item1", "item2"]' - - parsed = parse_llm_coaching_response(raw) - - assert parsed.message == raw - assert parsed.is_final is False - - def test_parse_with_extra_whitespace(self) -> None: - """Test parsing JSON with leading/trailing whitespace.""" - raw = """ - - {"message": "Trimmed correctly", "is_final": false, "result": null, "confidence": 0.0} - - """ - - parsed = parse_llm_coaching_response(raw) - - assert parsed.message == "Trimmed correctly" - - -class TestShouldAutoComplete: - """Tests for should_auto_complete function.""" - - def test_auto_complete_when_all_conditions_met(self) -> None: - """Test auto-completion triggers when all conditions are met.""" - response = LLMCoachingResponse( - message="Session complete!", - is_final=True, - result={"values": []}, - confidence=0.85, - ) - - assert should_auto_complete(response) is True - - def test_no_auto_complete_when_not_final(self) -> None: - """Test no auto-completion when is_final is False.""" - response = LLMCoachingResponse( - message="Continuing...", - is_final=False, - result={"values": []}, - confidence=0.9, - ) - - assert should_auto_complete(response) is False - - def test_no_auto_complete_when_confidence_too_low(self) -> None: - """Test no auto-completion when confidence below threshold.""" - response = LLMCoachingResponse( - message="Maybe done?", - is_final=True, - result={"values": []}, - confidence=0.5, # Below 0.7 threshold - ) - - assert should_auto_complete(response) is False - - def test_no_auto_complete_when_result_is_none(self) -> None: - """Test no auto-completion when result is None.""" - response = LLMCoachingResponse( - message="Done but no result", - is_final=True, - result=None, - confidence=0.9, - ) - - assert should_auto_complete(response) is False - - def test_auto_complete_at_exact_threshold(self) -> None: - """Test auto-completion at exactly the threshold.""" - response = LLMCoachingResponse( - message="At threshold", - is_final=True, - result={"data": "value"}, - confidence=AUTO_COMPLETION_CONFIDENCE_THRESHOLD, - ) - - assert should_auto_complete(response) is True - - def test_no_auto_complete_just_below_threshold(self) -> None: - """Test no auto-completion just below threshold.""" - response = LLMCoachingResponse( - message="Just below", - is_final=True, - result={"data": "value"}, - confidence=AUTO_COMPLETION_CONFIDENCE_THRESHOLD - 0.01, - ) - - assert should_auto_complete(response) is False - - def test_auto_complete_with_empty_dict_result(self) -> None: - """Test auto-completion with empty dict as result (valid result).""" - response = LLMCoachingResponse( - message="Empty result", - is_final=True, - result={}, - confidence=0.8, - ) - - # Empty dict is truthy for 'is not None' check - assert should_auto_complete(response) is True - - -class TestAutoCompletionConfidenceThreshold: - """Tests for the confidence threshold constant.""" - - def test_threshold_value(self) -> None: - """Test the threshold is set to expected value.""" - assert AUTO_COMPLETION_CONFIDENCE_THRESHOLD == 0.7 - - def test_threshold_is_reasonable(self) -> None: - """Test threshold is in reasonable range.""" - assert 0.5 <= AUTO_COMPLETION_CONFIDENCE_THRESHOLD <= 0.9 +"""Unit tests for LLM coaching response model and parsing.""" + +import pytest + +from coaching.src.models.llm_coaching_response import ( + AUTO_COMPLETION_CONFIDENCE_THRESHOLD, + LLMCoachingResponse, + parse_llm_coaching_response, + should_auto_complete, +) + + +class TestLLMCoachingResponse: + """Tests for LLMCoachingResponse model.""" + + def test_basic_response_creation(self) -> None: + """Test creating a basic response with required fields.""" + response = LLMCoachingResponse(message="Hello, how can I help?") + + assert response.message == "Hello, how can I help?" + assert response.is_final is False + assert response.result is None + assert response.confidence == 0.0 + + def test_completion_response_creation(self) -> None: + """Test creating a completion response with all fields.""" + result = { + "values": [{"name": "Integrity", "description": "...", "importance": "..."}], + "summary": "Your core values are...", + } + response = LLMCoachingResponse( + message="Thank you for this session!", + is_final=True, + result=result, + confidence=0.92, + ) + + assert response.message == "Thank you for this session!" + assert response.is_final is True + assert response.result == result + assert response.confidence == 0.92 + + def test_confidence_validation_min(self) -> None: + """Test that confidence below 0 is rejected.""" + with pytest.raises(ValueError): + LLMCoachingResponse(message="Test", confidence=-0.1) + + def test_confidence_validation_max(self) -> None: + """Test that confidence above 1 is rejected.""" + with pytest.raises(ValueError): + LLMCoachingResponse(message="Test", confidence=1.1) + + def test_message_required(self) -> None: + """Test that message is required.""" + with pytest.raises(ValueError): + LLMCoachingResponse(message="") # type: ignore + + def test_message_min_length(self) -> None: + """Test that empty message is rejected.""" + with pytest.raises(ValueError): + LLMCoachingResponse(message="") + + +class TestParseLLMCoachingResponse: + """Tests for parse_llm_coaching_response function.""" + + def test_parse_valid_json_normal_response(self) -> None: + """Test parsing valid JSON for normal conversation.""" + raw = '{"message": "Tell me more about that.", "is_final": false, "result": null, "confidence": 0.0}' + + parsed = parse_llm_coaching_response(raw) + + assert parsed.message == "Tell me more about that." + assert parsed.is_final is False + assert parsed.result is None + assert parsed.confidence == 0.0 + + def test_parse_valid_json_completion_response(self) -> None: + """Test parsing valid JSON for completion.""" + raw = """ + { + "message": "Thank you for this wonderful session!", + "is_final": true, + "result": { + "values": [ + {"name": "Integrity", "description": "Being honest", "importance": "Guides decisions"} + ], + "summary": "Your core value is integrity." + }, + "confidence": 0.92 + } + """ + + parsed = parse_llm_coaching_response(raw) + + assert parsed.message == "Thank you for this wonderful session!" + assert parsed.is_final is True + assert parsed.result is not None + assert len(parsed.result["values"]) == 1 + assert parsed.confidence == 0.92 + + def test_parse_json_in_markdown_block(self) -> None: + """Test parsing JSON wrapped in markdown code block.""" + raw = """ +```json +{ + "message": "Here are my thoughts...", + "is_final": false, + "result": null, + "confidence": 0.0 +} +``` +""" + + parsed = parse_llm_coaching_response(raw) + + assert parsed.message == "Here are my thoughts..." + assert parsed.is_final is False + + def test_parse_json_in_generic_markdown_block(self) -> None: + """Test parsing JSON in generic code block (no language specified).""" + raw = """ +``` +{"message": "Testing", "is_final": false, "result": null, "confidence": 0.0} +``` +""" + + parsed = parse_llm_coaching_response(raw) + + assert parsed.message == "Testing" + assert parsed.is_final is False + + def test_fallback_for_plain_text_response(self) -> None: + """Test graceful handling of non-JSON responses.""" + raw = "This is a plain text response without any JSON structure." + + parsed = parse_llm_coaching_response(raw) + + assert parsed.message == raw + assert parsed.is_final is False + assert parsed.result is None + assert parsed.confidence == 0.0 + + def test_fallback_for_invalid_json(self) -> None: + """Test fallback for malformed JSON.""" + raw = '{"message": "incomplete json' + + parsed = parse_llm_coaching_response(raw) + + assert parsed.message == raw + assert parsed.is_final is False + + def test_fallback_for_json_array(self) -> None: + """Test fallback when JSON is an array instead of object.""" + raw = '["item1", "item2"]' + + parsed = parse_llm_coaching_response(raw) + + assert parsed.message == raw + assert parsed.is_final is False + + def test_parse_with_extra_whitespace(self) -> None: + """Test parsing JSON with leading/trailing whitespace.""" + raw = """ + + {"message": "Trimmed correctly", "is_final": false, "result": null, "confidence": 0.0} + + """ + + parsed = parse_llm_coaching_response(raw) + + assert parsed.message == "Trimmed correctly" + + +class TestShouldAutoComplete: + """Tests for should_auto_complete function.""" + + def test_auto_complete_when_all_conditions_met(self) -> None: + """Test auto-completion triggers when all conditions are met.""" + response = LLMCoachingResponse( + message="Session complete!", + is_final=True, + result={"values": []}, + confidence=0.85, + ) + + assert should_auto_complete(response) is True + + def test_no_auto_complete_when_not_final(self) -> None: + """Test no auto-completion when is_final is False.""" + response = LLMCoachingResponse( + message="Continuing...", + is_final=False, + result={"values": []}, + confidence=0.9, + ) + + assert should_auto_complete(response) is False + + def test_no_auto_complete_when_confidence_too_low(self) -> None: + """Test no auto-completion when confidence below threshold.""" + response = LLMCoachingResponse( + message="Maybe done?", + is_final=True, + result={"values": []}, + confidence=0.5, # Below 0.7 threshold + ) + + assert should_auto_complete(response) is False + + def test_no_auto_complete_when_result_is_none(self) -> None: + """Test no auto-completion when result is None.""" + response = LLMCoachingResponse( + message="Done but no result", + is_final=True, + result=None, + confidence=0.9, + ) + + assert should_auto_complete(response) is False + + def test_auto_complete_at_exact_threshold(self) -> None: + """Test auto-completion at exactly the threshold.""" + response = LLMCoachingResponse( + message="At threshold", + is_final=True, + result={"data": "value"}, + confidence=AUTO_COMPLETION_CONFIDENCE_THRESHOLD, + ) + + assert should_auto_complete(response) is True + + def test_no_auto_complete_just_below_threshold(self) -> None: + """Test no auto-completion just below threshold.""" + response = LLMCoachingResponse( + message="Just below", + is_final=True, + result={"data": "value"}, + confidence=AUTO_COMPLETION_CONFIDENCE_THRESHOLD - 0.01, + ) + + assert should_auto_complete(response) is False + + def test_auto_complete_with_empty_dict_result(self) -> None: + """Test auto-completion with empty dict as result (valid result).""" + response = LLMCoachingResponse( + message="Empty result", + is_final=True, + result={}, + confidence=0.8, + ) + + # Empty dict is truthy for 'is not None' check + assert should_auto_complete(response) is True + + +class TestAutoCompletionConfidenceThreshold: + """Tests for the confidence threshold constant.""" + + def test_threshold_value(self) -> None: + """Test the threshold is set to expected value.""" + assert AUTO_COMPLETION_CONFIDENCE_THRESHOLD == 0.7 + + def test_threshold_is_reasonable(self) -> None: + """Test threshold is in reasonable range.""" + assert 0.5 <= AUTO_COMPLETION_CONFIDENCE_THRESHOLD <= 0.9 diff --git a/coaching/tests/unit/repositories/test_topic_repository.py b/coaching/tests/unit/repositories/test_topic_repository.py index 5e12b8cf..4f3a5758 100644 --- a/coaching/tests/unit/repositories/test_topic_repository.py +++ b/coaching/tests/unit/repositories/test_topic_repository.py @@ -4,6 +4,7 @@ from unittest.mock import MagicMock import pytest + from coaching.src.domain.entities.llm_topic import ( LLMTopic, PromptInfo, diff --git a/coaching/tests/unit/services/test_async_execution_service.py b/coaching/tests/unit/services/test_async_execution_service.py index 1bd97a24..8c08ad5b 100644 --- a/coaching/tests/unit/services/test_async_execution_service.py +++ b/coaching/tests/unit/services/test_async_execution_service.py @@ -4,6 +4,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest + from coaching.src.api.models.ai_job_kickoff import ApiAiJobRequestedDetail from coaching.src.domain.entities.ai_job import AIJob, AIJobErrorCode, AIJobStatus from coaching.src.services.async_execution_service import ( diff --git a/coaching/tests/unit/services/test_audit_log_service.py b/coaching/tests/unit/services/test_audit_log_service.py index bfc8ff47..abe10321 100644 --- a/coaching/tests/unit/services/test_audit_log_service.py +++ b/coaching/tests/unit/services/test_audit_log_service.py @@ -1,384 +1,385 @@ -"""Unit tests for AuditLogService.""" - -from datetime import UTC, datetime - -import pytest -from coaching.src.services.audit_log_service import ( - AuditAction, - AuditLogEntry, - AuditLogService, -) - - -@pytest.mark.unit -class TestAuditLogServiceInit: - """Test AuditLogService initialization.""" - - def test_init_creates_instance(self): - """Test that service initializes correctly.""" - # Act - service = AuditLogService() - - # Assert - assert service is not None - - -@pytest.mark.unit -class TestAuditLogEntry: - """Test AuditLogEntry model.""" - - def test_create_audit_log_entry_with_required_fields(self): - """Test creating audit log entry with required fields.""" - # Arrange & Act - entry = AuditLogEntry( - action=AuditAction.TEMPLATE_CREATED, - user_id="user-123", - tenant_id="tenant-456", - resource_type="template", - resource_id="goal_alignment/1.0.0", - ) - - # Assert - assert entry.action == AuditAction.TEMPLATE_CREATED - assert entry.user_id == "user-123" - assert entry.tenant_id == "tenant-456" - assert entry.resource_type == "template" - assert entry.resource_id == "goal_alignment/1.0.0" - assert entry.details == {} - assert entry.ip_address is None - assert entry.user_agent is None - assert isinstance(entry.timestamp, datetime) - - def test_create_audit_log_entry_with_all_fields(self): - """Test creating audit log entry with all fields.""" - # Arrange - timestamp = datetime.now(UTC) - - # Act - entry = AuditLogEntry( - timestamp=timestamp, - action=AuditAction.TEMPLATE_UPDATED, - user_id="user-123", - tenant_id="tenant-456", - resource_type="template", - resource_id="goal_alignment/1.0.0", - details={"changes": {"system_prompt": "updated"}}, - ip_address="192.168.1.1", - user_agent="Mozilla/5.0", - ) - - # Assert - assert entry.timestamp == timestamp - assert entry.details == {"changes": {"system_prompt": "updated"}} - assert entry.ip_address == "192.168.1.1" - assert entry.user_agent == "Mozilla/5.0" - - -@pytest.mark.unit -class TestAuditAction: - """Test AuditAction enum.""" - - def test_audit_action_values(self): - """Test that all audit action values are defined.""" - # Assert - assert AuditAction.TEMPLATE_CREATED.value == "template_created" - assert AuditAction.TEMPLATE_UPDATED.value == "template_updated" - assert AuditAction.TEMPLATE_DELETED.value == "template_deleted" - assert AuditAction.VERSION_ACTIVATED.value == "version_activated" - assert AuditAction.MODEL_UPDATED.value == "model_updated" - assert AuditAction.TEMPLATE_TESTED.value == "template_tested" - - -@pytest.mark.unit -class TestLogTemplateCreated: - """Test log_template_created method.""" - - @pytest.fixture - def service(self): - """Create audit log service.""" - return AuditLogService() - - async def test_log_template_created_basic(self, service): - """Test logging template creation with basic info.""" - # Act - await service.log_template_created( - user_id="admin-123", - tenant_id="tenant-456", - topic="goal_alignment", - version="1.0.0", - ) - - # Assert - should not raise - - async def test_log_template_created_with_source_version(self, service): - """Test logging template creation with source version.""" - # Act - await service.log_template_created( - user_id="admin-123", - tenant_id="tenant-456", - topic="goal_alignment", - version="2.0.0", - source_version="1.0.0", - ) - - # Assert - should not raise - - async def test_log_template_created_with_ip_address(self, service): - """Test logging template creation with IP address.""" - # Act - await service.log_template_created( - user_id="admin-123", - tenant_id="tenant-456", - topic="goal_alignment", - version="1.0.0", - ip_address="10.0.0.1", - ) - - # Assert - should not raise - - -@pytest.mark.unit -class TestLogTemplateUpdated: - """Test log_template_updated method.""" - - @pytest.fixture - def service(self): - """Create audit log service.""" - return AuditLogService() - - async def test_log_template_updated_basic(self, service): - """Test logging template update with basic info.""" - # Arrange - changes = {"system_prompt": "updated"} - - # Act - await service.log_template_updated( - user_id="admin-123", - tenant_id="tenant-456", - topic="goal_alignment", - version="1.0.0", - changes=changes, - ) - - # Assert - should not raise - - async def test_log_template_updated_with_reason(self, service): - """Test logging template update with reason.""" - # Arrange - changes = {"system_prompt": "updated", "model": "changed"} - - # Act - await service.log_template_updated( - user_id="admin-123", - tenant_id="tenant-456", - topic="goal_alignment", - version="1.0.0", - changes=changes, - reason="Improved prompt clarity", - ) - - # Assert - should not raise - - async def test_log_template_updated_with_ip_address(self, service): - """Test logging template update with IP address.""" - # Arrange - changes = {"model": "updated"} - - # Act - await service.log_template_updated( - user_id="admin-123", - tenant_id="tenant-456", - topic="goal_alignment", - version="1.0.0", - changes=changes, - ip_address="10.0.0.1", - ) - - # Assert - should not raise - - -@pytest.mark.unit -class TestLogTemplateDeleted: - """Test log_template_deleted method.""" - - @pytest.fixture - def service(self): - """Create audit log service.""" - return AuditLogService() - - async def test_log_template_deleted_basic(self, service): - """Test logging template deletion with basic info.""" - # Act - await service.log_template_deleted( - user_id="admin-123", - tenant_id="tenant-456", - topic="goal_alignment", - version="0.9.0", - ) - - # Assert - should not raise - - async def test_log_template_deleted_with_reason(self, service): - """Test logging template deletion with reason.""" - # Act - await service.log_template_deleted( - user_id="admin-123", - tenant_id="tenant-456", - topic="goal_alignment", - version="0.9.0", - reason="Obsolete version", - ) - - # Assert - should not raise - - -@pytest.mark.unit -class TestLogVersionActivated: - """Test log_version_activated method.""" - - @pytest.fixture - def service(self): - """Create audit log service.""" - return AuditLogService() - - async def test_log_version_activated_basic(self, service): - """Test logging version activation with basic info.""" - # Act - await service.log_version_activated( - user_id="admin-123", - tenant_id="tenant-456", - topic="goal_alignment", - new_version="2.0.0", - ) - - # Assert - should not raise - - async def test_log_version_activated_with_previous_version(self, service): - """Test logging version activation with previous version.""" - # Act - await service.log_version_activated( - user_id="admin-123", - tenant_id="tenant-456", - topic="goal_alignment", - new_version="2.0.0", - previous_version="1.0.0", - ) - - # Assert - should not raise - - async def test_log_version_activated_with_reason(self, service): - """Test logging version activation with reason.""" - # Act - await service.log_version_activated( - user_id="admin-123", - tenant_id="tenant-456", - topic="goal_alignment", - new_version="2.0.0", - previous_version="1.0.0", - reason="Completed testing", - ) - - # Assert - should not raise - - -@pytest.mark.unit -class TestLogModelUpdated: - """Test log_model_updated method.""" - - @pytest.fixture - def service(self): - """Create audit log service.""" - return AuditLogService() - - async def test_log_model_updated_basic(self, service): - """Test logging model update with basic info.""" - # Arrange - changes = {"is_active": "true"} - - # Act - await service.log_model_updated( - user_id="admin-123", - tenant_id="tenant-456", - model_id="anthropic.claude-3-5-sonnet", - changes=changes, - ) - - # Assert - should not raise - - async def test_log_model_updated_with_reason(self, service): - """Test logging model update with reason.""" - # Arrange - changes = {"cost_per_1k_tokens": {"input": 0.003, "output": 0.015}} - - # Act - await service.log_model_updated( - user_id="admin-123", - tenant_id="tenant-456", - model_id="anthropic.claude-3-5-sonnet", - changes=changes, - reason="Updated pricing", - ) - - # Assert - should not raise - - -@pytest.mark.unit -class TestLogTemplateTested: - """Test log_template_tested method.""" - - @pytest.fixture - def service(self): - """Create audit log service.""" - return AuditLogService() - - async def test_log_template_tested_success(self, service): - """Test logging successful template test.""" - # Arrange - test_parameters = {"goal": "Test goal", "purpose": "Test purpose"} - - # Act - await service.log_template_tested( - user_id="admin-123", - tenant_id="tenant-456", - topic="goal_alignment", - version="2.0.0", - test_parameters=test_parameters, - success=True, - ) - - # Assert - should not raise - - async def test_log_template_tested_failure(self, service): - """Test logging failed template test.""" - # Arrange - test_parameters = {"goal": "Test goal"} - - # Act - await service.log_template_tested( - user_id="admin-123", - tenant_id="tenant-456", - topic="goal_alignment", - version="2.0.0", - test_parameters=test_parameters, - success=False, - ) - - # Assert - should not raise - - async def test_log_template_tested_with_ip_address(self, service): - """Test logging template test with IP address.""" - # Arrange - test_parameters = {"goal": "Test goal"} - - # Act - await service.log_template_tested( - user_id="admin-123", - tenant_id="tenant-456", - topic="goal_alignment", - version="2.0.0", - test_parameters=test_parameters, - success=True, - ip_address="10.0.0.1", - ) - - # Assert - should not raise +"""Unit tests for AuditLogService.""" + +from datetime import UTC, datetime + +import pytest + +from coaching.src.services.audit_log_service import ( + AuditAction, + AuditLogEntry, + AuditLogService, +) + + +@pytest.mark.unit +class TestAuditLogServiceInit: + """Test AuditLogService initialization.""" + + def test_init_creates_instance(self): + """Test that service initializes correctly.""" + # Act + service = AuditLogService() + + # Assert + assert service is not None + + +@pytest.mark.unit +class TestAuditLogEntry: + """Test AuditLogEntry model.""" + + def test_create_audit_log_entry_with_required_fields(self): + """Test creating audit log entry with required fields.""" + # Arrange & Act + entry = AuditLogEntry( + action=AuditAction.TEMPLATE_CREATED, + user_id="user-123", + tenant_id="tenant-456", + resource_type="template", + resource_id="goal_alignment/1.0.0", + ) + + # Assert + assert entry.action == AuditAction.TEMPLATE_CREATED + assert entry.user_id == "user-123" + assert entry.tenant_id == "tenant-456" + assert entry.resource_type == "template" + assert entry.resource_id == "goal_alignment/1.0.0" + assert entry.details == {} + assert entry.ip_address is None + assert entry.user_agent is None + assert isinstance(entry.timestamp, datetime) + + def test_create_audit_log_entry_with_all_fields(self): + """Test creating audit log entry with all fields.""" + # Arrange + timestamp = datetime.now(UTC) + + # Act + entry = AuditLogEntry( + timestamp=timestamp, + action=AuditAction.TEMPLATE_UPDATED, + user_id="user-123", + tenant_id="tenant-456", + resource_type="template", + resource_id="goal_alignment/1.0.0", + details={"changes": {"system_prompt": "updated"}}, + ip_address="192.168.1.1", + user_agent="Mozilla/5.0", + ) + + # Assert + assert entry.timestamp == timestamp + assert entry.details == {"changes": {"system_prompt": "updated"}} + assert entry.ip_address == "192.168.1.1" + assert entry.user_agent == "Mozilla/5.0" + + +@pytest.mark.unit +class TestAuditAction: + """Test AuditAction enum.""" + + def test_audit_action_values(self): + """Test that all audit action values are defined.""" + # Assert + assert AuditAction.TEMPLATE_CREATED.value == "template_created" + assert AuditAction.TEMPLATE_UPDATED.value == "template_updated" + assert AuditAction.TEMPLATE_DELETED.value == "template_deleted" + assert AuditAction.VERSION_ACTIVATED.value == "version_activated" + assert AuditAction.MODEL_UPDATED.value == "model_updated" + assert AuditAction.TEMPLATE_TESTED.value == "template_tested" + + +@pytest.mark.unit +class TestLogTemplateCreated: + """Test log_template_created method.""" + + @pytest.fixture + def service(self): + """Create audit log service.""" + return AuditLogService() + + async def test_log_template_created_basic(self, service): + """Test logging template creation with basic info.""" + # Act + await service.log_template_created( + user_id="admin-123", + tenant_id="tenant-456", + topic="goal_alignment", + version="1.0.0", + ) + + # Assert - should not raise + + async def test_log_template_created_with_source_version(self, service): + """Test logging template creation with source version.""" + # Act + await service.log_template_created( + user_id="admin-123", + tenant_id="tenant-456", + topic="goal_alignment", + version="2.0.0", + source_version="1.0.0", + ) + + # Assert - should not raise + + async def test_log_template_created_with_ip_address(self, service): + """Test logging template creation with IP address.""" + # Act + await service.log_template_created( + user_id="admin-123", + tenant_id="tenant-456", + topic="goal_alignment", + version="1.0.0", + ip_address="10.0.0.1", + ) + + # Assert - should not raise + + +@pytest.mark.unit +class TestLogTemplateUpdated: + """Test log_template_updated method.""" + + @pytest.fixture + def service(self): + """Create audit log service.""" + return AuditLogService() + + async def test_log_template_updated_basic(self, service): + """Test logging template update with basic info.""" + # Arrange + changes = {"system_prompt": "updated"} + + # Act + await service.log_template_updated( + user_id="admin-123", + tenant_id="tenant-456", + topic="goal_alignment", + version="1.0.0", + changes=changes, + ) + + # Assert - should not raise + + async def test_log_template_updated_with_reason(self, service): + """Test logging template update with reason.""" + # Arrange + changes = {"system_prompt": "updated", "model": "changed"} + + # Act + await service.log_template_updated( + user_id="admin-123", + tenant_id="tenant-456", + topic="goal_alignment", + version="1.0.0", + changes=changes, + reason="Improved prompt clarity", + ) + + # Assert - should not raise + + async def test_log_template_updated_with_ip_address(self, service): + """Test logging template update with IP address.""" + # Arrange + changes = {"model": "updated"} + + # Act + await service.log_template_updated( + user_id="admin-123", + tenant_id="tenant-456", + topic="goal_alignment", + version="1.0.0", + changes=changes, + ip_address="10.0.0.1", + ) + + # Assert - should not raise + + +@pytest.mark.unit +class TestLogTemplateDeleted: + """Test log_template_deleted method.""" + + @pytest.fixture + def service(self): + """Create audit log service.""" + return AuditLogService() + + async def test_log_template_deleted_basic(self, service): + """Test logging template deletion with basic info.""" + # Act + await service.log_template_deleted( + user_id="admin-123", + tenant_id="tenant-456", + topic="goal_alignment", + version="0.9.0", + ) + + # Assert - should not raise + + async def test_log_template_deleted_with_reason(self, service): + """Test logging template deletion with reason.""" + # Act + await service.log_template_deleted( + user_id="admin-123", + tenant_id="tenant-456", + topic="goal_alignment", + version="0.9.0", + reason="Obsolete version", + ) + + # Assert - should not raise + + +@pytest.mark.unit +class TestLogVersionActivated: + """Test log_version_activated method.""" + + @pytest.fixture + def service(self): + """Create audit log service.""" + return AuditLogService() + + async def test_log_version_activated_basic(self, service): + """Test logging version activation with basic info.""" + # Act + await service.log_version_activated( + user_id="admin-123", + tenant_id="tenant-456", + topic="goal_alignment", + new_version="2.0.0", + ) + + # Assert - should not raise + + async def test_log_version_activated_with_previous_version(self, service): + """Test logging version activation with previous version.""" + # Act + await service.log_version_activated( + user_id="admin-123", + tenant_id="tenant-456", + topic="goal_alignment", + new_version="2.0.0", + previous_version="1.0.0", + ) + + # Assert - should not raise + + async def test_log_version_activated_with_reason(self, service): + """Test logging version activation with reason.""" + # Act + await service.log_version_activated( + user_id="admin-123", + tenant_id="tenant-456", + topic="goal_alignment", + new_version="2.0.0", + previous_version="1.0.0", + reason="Completed testing", + ) + + # Assert - should not raise + + +@pytest.mark.unit +class TestLogModelUpdated: + """Test log_model_updated method.""" + + @pytest.fixture + def service(self): + """Create audit log service.""" + return AuditLogService() + + async def test_log_model_updated_basic(self, service): + """Test logging model update with basic info.""" + # Arrange + changes = {"is_active": "true"} + + # Act + await service.log_model_updated( + user_id="admin-123", + tenant_id="tenant-456", + model_id="anthropic.claude-3-5-sonnet", + changes=changes, + ) + + # Assert - should not raise + + async def test_log_model_updated_with_reason(self, service): + """Test logging model update with reason.""" + # Arrange + changes = {"cost_per_1k_tokens": {"input": 0.003, "output": 0.015}} + + # Act + await service.log_model_updated( + user_id="admin-123", + tenant_id="tenant-456", + model_id="anthropic.claude-3-5-sonnet", + changes=changes, + reason="Updated pricing", + ) + + # Assert - should not raise + + +@pytest.mark.unit +class TestLogTemplateTested: + """Test log_template_tested method.""" + + @pytest.fixture + def service(self): + """Create audit log service.""" + return AuditLogService() + + async def test_log_template_tested_success(self, service): + """Test logging successful template test.""" + # Arrange + test_parameters = {"goal": "Test goal", "purpose": "Test purpose"} + + # Act + await service.log_template_tested( + user_id="admin-123", + tenant_id="tenant-456", + topic="goal_alignment", + version="2.0.0", + test_parameters=test_parameters, + success=True, + ) + + # Assert - should not raise + + async def test_log_template_tested_failure(self, service): + """Test logging failed template test.""" + # Arrange + test_parameters = {"goal": "Test goal"} + + # Act + await service.log_template_tested( + user_id="admin-123", + tenant_id="tenant-456", + topic="goal_alignment", + version="2.0.0", + test_parameters=test_parameters, + success=False, + ) + + # Assert - should not raise + + async def test_log_template_tested_with_ip_address(self, service): + """Test logging template test with IP address.""" + # Arrange + test_parameters = {"goal": "Test goal"} + + # Act + await service.log_template_tested( + user_id="admin-123", + tenant_id="tenant-456", + topic="goal_alignment", + version="2.0.0", + test_parameters=test_parameters, + success=True, + ip_address="10.0.0.1", + ) + + # Assert - should not raise diff --git a/coaching/tests/unit/services/test_coaching_session_service.py b/coaching/tests/unit/services/test_coaching_session_service.py index a92b9444..20470879 100644 --- a/coaching/tests/unit/services/test_coaching_session_service.py +++ b/coaching/tests/unit/services/test_coaching_session_service.py @@ -16,6 +16,7 @@ from unittest.mock import AsyncMock, Mock, patch import pytest + from coaching.src.core.constants import ConversationStatus, TopicCategory, TopicType from coaching.src.core.topic_registry import ( TemplateType, diff --git a/coaching/tests/unit/services/test_conversation_service.py b/coaching/tests/unit/services/test_conversation_service.py index 710ea0ae..7303a11d 100644 --- a/coaching/tests/unit/services/test_conversation_service.py +++ b/coaching/tests/unit/services/test_conversation_service.py @@ -2,6 +2,7 @@ from unittest.mock import AsyncMock, Mock import pytest + from coaching.src.core.constants import CoachingTopic, ConversationStatus from coaching.src.core.exceptions import ConversationNotFoundError from coaching.src.domain.entities.prompt_template import PromptTemplate diff --git a/coaching/tests/unit/services/test_llm_template_service.py b/coaching/tests/unit/services/test_llm_template_service.py index 790d026a..9a0929a8 100644 --- a/coaching/tests/unit/services/test_llm_template_service.py +++ b/coaching/tests/unit/services/test_llm_template_service.py @@ -1,214 +1,215 @@ -from datetime import UTC, datetime -from unittest.mock import AsyncMock, MagicMock - -import pytest -from botocore.exceptions import ClientError -from coaching.src.domain.entities.llm_config.template_metadata import TemplateMetadata -from coaching.src.infrastructure.repositories.llm_config.template_metadata_repository import ( - TemplateMetadataRepository, -) -from coaching.src.services.cache_service import CacheService -from coaching.src.services.llm_template_service import ( - InvalidTemplateSyntaxError, - LLMTemplateService, - TemplateNotFoundError, - TemplateRenderError, -) - - -class TestLLMTemplateService: - @pytest.fixture - def mock_repo(self): - return AsyncMock(spec=TemplateMetadataRepository) - - @pytest.fixture - def mock_s3_client(self): - return MagicMock() - - @pytest.fixture - def mock_cache_service(self): - return AsyncMock(spec=CacheService) - - @pytest.fixture - def service(self, mock_repo, mock_s3_client, mock_cache_service): - return LLMTemplateService( - template_repository=mock_repo, - s3_client=mock_s3_client, - cache_service=mock_cache_service, - ) - - @pytest.fixture - def sample_metadata(self): - return TemplateMetadata( - template_id="tmpl-123", - template_code="COACHING_RESPONSE_V1", - interaction_code="COACHING_RESPONSE", - name="Coaching Response Template", - description="Template for coaching responses", - version="1.0", - s3_bucket="test-bucket", - s3_key="templates/tmpl-123.j2", - created_by="user-123", - created_at=datetime.now(UTC), - updated_at=datetime.now(UTC), - is_active=True, - ) - - @pytest.mark.asyncio - async def test_get_template_by_id_success( - self, service, mock_repo, mock_s3_client, mock_cache_service, sample_metadata - ): - # Arrange - mock_repo.get_by_id.return_value = sample_metadata - mock_cache_service.get.return_value = None - - mock_body = MagicMock() - mock_body.read.return_value = b"Hello {{ name }}!" - mock_s3_client.get_object.return_value = {"Body": mock_body} - - # Act - metadata, content = await service.get_template_by_id("tmpl-123") - - # Assert - assert metadata == sample_metadata - assert content == "Hello {{ name }}!" - mock_repo.get_by_id.assert_called_once_with("tmpl-123") - mock_s3_client.get_object.assert_called_once_with( - Bucket="test-bucket", Key="templates/tmpl-123.j2" - ) - mock_cache_service.set.assert_called_once() - - @pytest.mark.asyncio - async def test_get_template_by_id_not_found_metadata(self, service, mock_repo): - # Arrange - mock_repo.get_by_id.return_value = None - - # Act & Assert - with pytest.raises(TemplateNotFoundError): - await service.get_template_by_id("non-existent") - - @pytest.mark.asyncio - async def test_get_template_by_id_s3_not_found( - self, service, mock_repo, mock_s3_client, sample_metadata - ): - # Arrange - mock_repo.get_by_id.return_value = sample_metadata - - error_response = {"Error": {"Code": "NoSuchKey"}} - mock_s3_client.get_object.side_effect = ClientError(error_response, "GetObject") - - # Act & Assert - with pytest.raises(TemplateNotFoundError): - await service.get_template_by_id("tmpl-123") - - @pytest.mark.asyncio - async def test_get_active_template_for_interaction( - self, service, mock_repo, mock_s3_client, sample_metadata - ): - # Arrange - mock_repo.get_active_for_interaction.return_value = sample_metadata - - mock_body = MagicMock() - mock_body.read.return_value = b"Hello {{ name }}!" - mock_s3_client.get_object.return_value = {"Body": mock_body} - - # Act - metadata, content = await service.get_active_template_for_interaction("COACHING_SESSION") - - # Assert - assert metadata == sample_metadata - assert content == "Hello {{ name }}!" - mock_repo.get_active_for_interaction.assert_called_once_with("COACHING_SESSION") - - @pytest.mark.asyncio - async def test_render_template_success( - self, service, mock_repo, mock_s3_client, sample_metadata - ): - # Arrange - mock_repo.get_by_id.return_value = sample_metadata - - mock_body = MagicMock() - mock_body.read.return_value = ( - b"Context: {{ conversation_context }}\nMessage: {{ user_message }}" - ) - mock_s3_client.get_object.return_value = {"Body": mock_body} - - # Act - result = await service.render_template( - "tmpl-123", {"conversation_context": "History", "user_message": "Hello"} - ) - - # Assert - assert result == "Context: History\nMessage: Hello" - - @pytest.mark.asyncio - async def test_render_template_missing_params( - self, service, mock_repo, mock_s3_client, sample_metadata - ): - # Arrange - mock_repo.get_by_id.return_value = sample_metadata - - mock_body = MagicMock() - mock_body.read.return_value = b"Context: {{ conversation_context }}" - mock_s3_client.get_object.return_value = {"Body": mock_body} - - # Act & Assert - with pytest.raises(TemplateRenderError) as exc: - await service.render_template("tmpl-123", {}) - - assert "Missing required parameters" in str(exc.value) - - @pytest.mark.asyncio - async def test_render_template_syntax_error( - self, service, mock_repo, mock_s3_client, sample_metadata - ): - # Arrange - mock_repo.get_by_id.return_value = sample_metadata - - mock_body = MagicMock() - mock_body.read.return_value = b"Hello {{ name " # Invalid syntax - mock_s3_client.get_object.return_value = {"Body": mock_body} - - # Act & Assert - with pytest.raises(InvalidTemplateSyntaxError): - await service.render_template( - "tmpl-123", {"conversation_context": "History", "user_message": "Hello"} - ) - - @pytest.mark.asyncio - async def test_render_template_cache_hit(self, service, mock_cache_service): - # Arrange - mock_cache_service.get.return_value = "Cached Result" - - # Act - result = await service.render_template("tmpl-123", {"name": "World"}) - - # Assert - assert result == "Cached Result" - # Should not call repo or s3 if cache hit - service.repository.get_by_id.assert_not_called() - - @pytest.mark.asyncio - async def test_validate_template_syntax_valid( - self, service, mock_repo, mock_s3_client, sample_metadata - ): - # Arrange - mock_repo.get_by_id.return_value = sample_metadata - - mock_body = MagicMock() - mock_body.read.return_value = b"Hello {{ name }}!" - mock_s3_client.get_object.return_value = {"Body": mock_body} - - # Act - result = await service.validate_template_syntax("tmpl-123") - - # Assert - assert result is True - - @pytest.mark.asyncio - async def test_invalidate_cache(self, service, mock_cache_service): - # Act - await service.invalidate_cache("tmpl-123") - - # Assert - mock_cache_service.delete.assert_called_once() +from datetime import UTC, datetime +from unittest.mock import AsyncMock, MagicMock + +import pytest +from botocore.exceptions import ClientError + +from coaching.src.domain.entities.llm_config.template_metadata import TemplateMetadata +from coaching.src.infrastructure.repositories.llm_config.template_metadata_repository import ( + TemplateMetadataRepository, +) +from coaching.src.services.cache_service import CacheService +from coaching.src.services.llm_template_service import ( + InvalidTemplateSyntaxError, + LLMTemplateService, + TemplateNotFoundError, + TemplateRenderError, +) + + +class TestLLMTemplateService: + @pytest.fixture + def mock_repo(self): + return AsyncMock(spec=TemplateMetadataRepository) + + @pytest.fixture + def mock_s3_client(self): + return MagicMock() + + @pytest.fixture + def mock_cache_service(self): + return AsyncMock(spec=CacheService) + + @pytest.fixture + def service(self, mock_repo, mock_s3_client, mock_cache_service): + return LLMTemplateService( + template_repository=mock_repo, + s3_client=mock_s3_client, + cache_service=mock_cache_service, + ) + + @pytest.fixture + def sample_metadata(self): + return TemplateMetadata( + template_id="tmpl-123", + template_code="COACHING_RESPONSE_V1", + interaction_code="COACHING_RESPONSE", + name="Coaching Response Template", + description="Template for coaching responses", + version="1.0", + s3_bucket="test-bucket", + s3_key="templates/tmpl-123.j2", + created_by="user-123", + created_at=datetime.now(UTC), + updated_at=datetime.now(UTC), + is_active=True, + ) + + @pytest.mark.asyncio + async def test_get_template_by_id_success( + self, service, mock_repo, mock_s3_client, mock_cache_service, sample_metadata + ): + # Arrange + mock_repo.get_by_id.return_value = sample_metadata + mock_cache_service.get.return_value = None + + mock_body = MagicMock() + mock_body.read.return_value = b"Hello {{ name }}!" + mock_s3_client.get_object.return_value = {"Body": mock_body} + + # Act + metadata, content = await service.get_template_by_id("tmpl-123") + + # Assert + assert metadata == sample_metadata + assert content == "Hello {{ name }}!" + mock_repo.get_by_id.assert_called_once_with("tmpl-123") + mock_s3_client.get_object.assert_called_once_with( + Bucket="test-bucket", Key="templates/tmpl-123.j2" + ) + mock_cache_service.set.assert_called_once() + + @pytest.mark.asyncio + async def test_get_template_by_id_not_found_metadata(self, service, mock_repo): + # Arrange + mock_repo.get_by_id.return_value = None + + # Act & Assert + with pytest.raises(TemplateNotFoundError): + await service.get_template_by_id("non-existent") + + @pytest.mark.asyncio + async def test_get_template_by_id_s3_not_found( + self, service, mock_repo, mock_s3_client, sample_metadata + ): + # Arrange + mock_repo.get_by_id.return_value = sample_metadata + + error_response = {"Error": {"Code": "NoSuchKey"}} + mock_s3_client.get_object.side_effect = ClientError(error_response, "GetObject") + + # Act & Assert + with pytest.raises(TemplateNotFoundError): + await service.get_template_by_id("tmpl-123") + + @pytest.mark.asyncio + async def test_get_active_template_for_interaction( + self, service, mock_repo, mock_s3_client, sample_metadata + ): + # Arrange + mock_repo.get_active_for_interaction.return_value = sample_metadata + + mock_body = MagicMock() + mock_body.read.return_value = b"Hello {{ name }}!" + mock_s3_client.get_object.return_value = {"Body": mock_body} + + # Act + metadata, content = await service.get_active_template_for_interaction("COACHING_SESSION") + + # Assert + assert metadata == sample_metadata + assert content == "Hello {{ name }}!" + mock_repo.get_active_for_interaction.assert_called_once_with("COACHING_SESSION") + + @pytest.mark.asyncio + async def test_render_template_success( + self, service, mock_repo, mock_s3_client, sample_metadata + ): + # Arrange + mock_repo.get_by_id.return_value = sample_metadata + + mock_body = MagicMock() + mock_body.read.return_value = ( + b"Context: {{ conversation_context }}\nMessage: {{ user_message }}" + ) + mock_s3_client.get_object.return_value = {"Body": mock_body} + + # Act + result = await service.render_template( + "tmpl-123", {"conversation_context": "History", "user_message": "Hello"} + ) + + # Assert + assert result == "Context: History\nMessage: Hello" + + @pytest.mark.asyncio + async def test_render_template_missing_params( + self, service, mock_repo, mock_s3_client, sample_metadata + ): + # Arrange + mock_repo.get_by_id.return_value = sample_metadata + + mock_body = MagicMock() + mock_body.read.return_value = b"Context: {{ conversation_context }}" + mock_s3_client.get_object.return_value = {"Body": mock_body} + + # Act & Assert + with pytest.raises(TemplateRenderError) as exc: + await service.render_template("tmpl-123", {}) + + assert "Missing required parameters" in str(exc.value) + + @pytest.mark.asyncio + async def test_render_template_syntax_error( + self, service, mock_repo, mock_s3_client, sample_metadata + ): + # Arrange + mock_repo.get_by_id.return_value = sample_metadata + + mock_body = MagicMock() + mock_body.read.return_value = b"Hello {{ name " # Invalid syntax + mock_s3_client.get_object.return_value = {"Body": mock_body} + + # Act & Assert + with pytest.raises(InvalidTemplateSyntaxError): + await service.render_template( + "tmpl-123", {"conversation_context": "History", "user_message": "Hello"} + ) + + @pytest.mark.asyncio + async def test_render_template_cache_hit(self, service, mock_cache_service): + # Arrange + mock_cache_service.get.return_value = "Cached Result" + + # Act + result = await service.render_template("tmpl-123", {"name": "World"}) + + # Assert + assert result == "Cached Result" + # Should not call repo or s3 if cache hit + service.repository.get_by_id.assert_not_called() + + @pytest.mark.asyncio + async def test_validate_template_syntax_valid( + self, service, mock_repo, mock_s3_client, sample_metadata + ): + # Arrange + mock_repo.get_by_id.return_value = sample_metadata + + mock_body = MagicMock() + mock_body.read.return_value = b"Hello {{ name }}!" + mock_s3_client.get_object.return_value = {"Body": mock_body} + + # Act + result = await service.validate_template_syntax("tmpl-123") + + # Assert + assert result is True + + @pytest.mark.asyncio + async def test_invalidate_cache(self, service, mock_cache_service): + # Act + await service.invalidate_cache("tmpl-123") + + # Assert + mock_cache_service.delete.assert_called_once() diff --git a/coaching/tests/unit/services/test_multitenant_conversation_service.py b/coaching/tests/unit/services/test_multitenant_conversation_service.py index 5568bc6f..785fe4fe 100644 --- a/coaching/tests/unit/services/test_multitenant_conversation_service.py +++ b/coaching/tests/unit/services/test_multitenant_conversation_service.py @@ -3,6 +3,7 @@ from unittest.mock import AsyncMock, Mock, patch import pytest + from coaching.src.models.conversation import Conversation, Message from coaching.src.models.prompt import LLMConfig, PromptTemplate from coaching.src.models.responses import ( diff --git a/coaching/tests/unit/services/test_parameter_gathering_service.py b/coaching/tests/unit/services/test_parameter_gathering_service.py index 66eb3246..2a09b03e 100644 --- a/coaching/tests/unit/services/test_parameter_gathering_service.py +++ b/coaching/tests/unit/services/test_parameter_gathering_service.py @@ -4,6 +4,7 @@ from unittest.mock import AsyncMock, MagicMock import pytest + from coaching.src.core.constants import ParameterSource, TopicCategory, TopicType from coaching.src.core.topic_registry import ParameterRef, TopicDefinition from coaching.src.services.parameter_gathering_service import ParameterGatheringService diff --git a/coaching/tests/unit/services/test_prompt_service.py b/coaching/tests/unit/services/test_prompt_service.py index 4c564d70..60c51210 100644 --- a/coaching/tests/unit/services/test_prompt_service.py +++ b/coaching/tests/unit/services/test_prompt_service.py @@ -31,6 +31,7 @@ def __call__(self, *args: object, **kwargs: object) -> "_DummyCondition": sys.modules["boto3.dynamodb.conditions"] = conditions_module import pytest + from coaching.src.domain.entities.llm_topic import LLMTopic from coaching.src.domain.exceptions.topic_exceptions import TopicNotFoundError from coaching.src.models.prompt import PromptTemplate diff --git a/coaching/tests/unit/services/test_s3_prompt_storage.py b/coaching/tests/unit/services/test_s3_prompt_storage.py index 55329369..0531f4c1 100644 --- a/coaching/tests/unit/services/test_s3_prompt_storage.py +++ b/coaching/tests/unit/services/test_s3_prompt_storage.py @@ -1,250 +1,251 @@ -"""Unit tests for S3PromptStorage service.""" - -from unittest.mock import MagicMock - -import pytest -from botocore.exceptions import ClientError -from coaching.src.domain.exceptions.topic_exceptions import S3StorageError -from coaching.src.services.s3_prompt_storage import S3PromptStorage - - -@pytest.fixture -def mock_s3_client() -> MagicMock: - """Create mock S3 client.""" - return MagicMock() - - -@pytest.fixture -def storage(mock_s3_client: MagicMock) -> S3PromptStorage: - """Create storage service with mocked S3 client.""" - return S3PromptStorage( - bucket_name="test-bucket", - s3_client=mock_s3_client, - ) - - -class TestS3PromptStorageSave: - """Tests for save_prompt method.""" - - @pytest.mark.asyncio - async def test_save_prompt_success( - self, - storage: S3PromptStorage, - mock_s3_client: MagicMock, - ) -> None: - """Test successfully saving prompt.""" - content = "# System Prompt\n\nYou are a helpful assistant." - - key = await storage.save_prompt( - topic_id="test_topic", - prompt_type="system", - content=content, - ) - - assert key == "prompts/test_topic/system.md" - mock_s3_client.put_object.assert_called_once() - call_kwargs = mock_s3_client.put_object.call_args.kwargs - assert call_kwargs["Bucket"] == "test-bucket" - assert call_kwargs["Key"] == "prompts/test_topic/system.md" - assert call_kwargs["ContentType"] == "text/markdown" - - @pytest.mark.asyncio - async def test_save_prompt_client_error( - self, - storage: S3PromptStorage, - mock_s3_client: MagicMock, - ) -> None: - """Test save prompt with S3 client error.""" - mock_s3_client.put_object.side_effect = ClientError( - {"Error": {"Code": "AccessDenied", "Message": "Access denied"}}, - "PutObject", - ) - - with pytest.raises(S3StorageError) as exc_info: - await storage.save_prompt( - topic_id="test", - prompt_type="system", - content="content", - ) - - assert exc_info.value.code == "S3_STORAGE_ERROR" - - -class TestS3PromptStorageGet: - """Tests for get_prompt method.""" - - @pytest.mark.asyncio - async def test_get_prompt_success( - self, - storage: S3PromptStorage, - mock_s3_client: MagicMock, - ) -> None: - """Test successfully getting prompt.""" - content = "# Prompt Content" - mock_s3_client.get_object.return_value = { - "Body": MagicMock(read=lambda: content.encode("utf-8")) - } - - result = await storage.get_prompt( - topic_id="test_topic", - prompt_type="system", - ) - - assert result == content - mock_s3_client.get_object.assert_called_once_with( - Bucket="test-bucket", - Key="prompts/test_topic/system.md", - ) - - @pytest.mark.asyncio - async def test_get_prompt_not_found( - self, - storage: S3PromptStorage, - mock_s3_client: MagicMock, - ) -> None: - """Test getting non-existent prompt returns None.""" - mock_s3_client.get_object.side_effect = ClientError( - {"Error": {"Code": "NoSuchKey", "Message": "Not found"}}, - "GetObject", - ) - - result = await storage.get_prompt( - topic_id="test", - prompt_type="system", - ) - - assert result is None - - @pytest.mark.asyncio - async def test_get_prompt_client_error( - self, - storage: S3PromptStorage, - mock_s3_client: MagicMock, - ) -> None: - """Test get prompt with S3 client error.""" - mock_s3_client.get_object.side_effect = ClientError( - {"Error": {"Code": "AccessDenied", "Message": "Access denied"}}, - "GetObject", - ) - - with pytest.raises(S3StorageError): - await storage.get_prompt( - topic_id="test", - prompt_type="system", - ) - - -class TestS3PromptStorageDelete: - """Tests for delete_prompt method.""" - - @pytest.mark.asyncio - async def test_delete_prompt_success( - self, - storage: S3PromptStorage, - mock_s3_client: MagicMock, - ) -> None: - """Test successfully deleting prompt.""" - result = await storage.delete_prompt( - topic_id="test_topic", - prompt_type="system", - ) - - assert result is True - mock_s3_client.delete_object.assert_called_once_with( - Bucket="test-bucket", - Key="prompts/test_topic/system.md", - ) - - @pytest.mark.asyncio - async def test_delete_prompt_client_error( - self, - storage: S3PromptStorage, - mock_s3_client: MagicMock, - ) -> None: - """Test delete prompt with S3 client error.""" - mock_s3_client.delete_object.side_effect = ClientError( - {"Error": {"Code": "AccessDenied", "Message": "Access denied"}}, - "DeleteObject", - ) - - with pytest.raises(S3StorageError): - await storage.delete_prompt( - topic_id="test", - prompt_type="system", - ) - - -class TestS3PromptStorageList: - """Tests for list_prompts method.""" - - @pytest.mark.asyncio - async def test_list_prompts_success( - self, - storage: S3PromptStorage, - mock_s3_client: MagicMock, - ) -> None: - """Test successfully listing prompts.""" - mock_s3_client.list_objects_v2.return_value = { - "Contents": [ - {"Key": "prompts/test_topic/system.md"}, - {"Key": "prompts/test_topic/user.md"}, - ] - } - - result = await storage.list_prompts(topic_id="test_topic") - - assert len(result) == 2 - assert "system" in result - assert "user" in result - - @pytest.mark.asyncio - async def test_list_prompts_empty( - self, - storage: S3PromptStorage, - mock_s3_client: MagicMock, - ) -> None: - """Test listing prompts for topic with no prompts.""" - mock_s3_client.list_objects_v2.return_value = {} - - result = await storage.list_prompts(topic_id="empty_topic") - - assert result == [] - - -class TestS3PromptStorageExists: - """Tests for prompt_exists method.""" - - @pytest.mark.asyncio - async def test_prompt_exists_true( - self, - storage: S3PromptStorage, - mock_s3_client: MagicMock, - ) -> None: - """Test checking existing prompt.""" - mock_s3_client.head_object.return_value = {} - - result = await storage.prompt_exists( - topic_id="test_topic", - prompt_type="system", - ) - - assert result is True - - @pytest.mark.asyncio - async def test_prompt_exists_false( - self, - storage: S3PromptStorage, - mock_s3_client: MagicMock, - ) -> None: - """Test checking non-existent prompt.""" - mock_s3_client.head_object.side_effect = ClientError( - {"Error": {"Code": "404", "Message": "Not found"}}, - "HeadObject", - ) - - result = await storage.prompt_exists( - topic_id="test_topic", - prompt_type="nonexistent", - ) - - assert result is False +"""Unit tests for S3PromptStorage service.""" + +from unittest.mock import MagicMock + +import pytest +from botocore.exceptions import ClientError + +from coaching.src.domain.exceptions.topic_exceptions import S3StorageError +from coaching.src.services.s3_prompt_storage import S3PromptStorage + + +@pytest.fixture +def mock_s3_client() -> MagicMock: + """Create mock S3 client.""" + return MagicMock() + + +@pytest.fixture +def storage(mock_s3_client: MagicMock) -> S3PromptStorage: + """Create storage service with mocked S3 client.""" + return S3PromptStorage( + bucket_name="test-bucket", + s3_client=mock_s3_client, + ) + + +class TestS3PromptStorageSave: + """Tests for save_prompt method.""" + + @pytest.mark.asyncio + async def test_save_prompt_success( + self, + storage: S3PromptStorage, + mock_s3_client: MagicMock, + ) -> None: + """Test successfully saving prompt.""" + content = "# System Prompt\n\nYou are a helpful assistant." + + key = await storage.save_prompt( + topic_id="test_topic", + prompt_type="system", + content=content, + ) + + assert key == "prompts/test_topic/system.md" + mock_s3_client.put_object.assert_called_once() + call_kwargs = mock_s3_client.put_object.call_args.kwargs + assert call_kwargs["Bucket"] == "test-bucket" + assert call_kwargs["Key"] == "prompts/test_topic/system.md" + assert call_kwargs["ContentType"] == "text/markdown" + + @pytest.mark.asyncio + async def test_save_prompt_client_error( + self, + storage: S3PromptStorage, + mock_s3_client: MagicMock, + ) -> None: + """Test save prompt with S3 client error.""" + mock_s3_client.put_object.side_effect = ClientError( + {"Error": {"Code": "AccessDenied", "Message": "Access denied"}}, + "PutObject", + ) + + with pytest.raises(S3StorageError) as exc_info: + await storage.save_prompt( + topic_id="test", + prompt_type="system", + content="content", + ) + + assert exc_info.value.code == "S3_STORAGE_ERROR" + + +class TestS3PromptStorageGet: + """Tests for get_prompt method.""" + + @pytest.mark.asyncio + async def test_get_prompt_success( + self, + storage: S3PromptStorage, + mock_s3_client: MagicMock, + ) -> None: + """Test successfully getting prompt.""" + content = "# Prompt Content" + mock_s3_client.get_object.return_value = { + "Body": MagicMock(read=lambda: content.encode("utf-8")) + } + + result = await storage.get_prompt( + topic_id="test_topic", + prompt_type="system", + ) + + assert result == content + mock_s3_client.get_object.assert_called_once_with( + Bucket="test-bucket", + Key="prompts/test_topic/system.md", + ) + + @pytest.mark.asyncio + async def test_get_prompt_not_found( + self, + storage: S3PromptStorage, + mock_s3_client: MagicMock, + ) -> None: + """Test getting non-existent prompt returns None.""" + mock_s3_client.get_object.side_effect = ClientError( + {"Error": {"Code": "NoSuchKey", "Message": "Not found"}}, + "GetObject", + ) + + result = await storage.get_prompt( + topic_id="test", + prompt_type="system", + ) + + assert result is None + + @pytest.mark.asyncio + async def test_get_prompt_client_error( + self, + storage: S3PromptStorage, + mock_s3_client: MagicMock, + ) -> None: + """Test get prompt with S3 client error.""" + mock_s3_client.get_object.side_effect = ClientError( + {"Error": {"Code": "AccessDenied", "Message": "Access denied"}}, + "GetObject", + ) + + with pytest.raises(S3StorageError): + await storage.get_prompt( + topic_id="test", + prompt_type="system", + ) + + +class TestS3PromptStorageDelete: + """Tests for delete_prompt method.""" + + @pytest.mark.asyncio + async def test_delete_prompt_success( + self, + storage: S3PromptStorage, + mock_s3_client: MagicMock, + ) -> None: + """Test successfully deleting prompt.""" + result = await storage.delete_prompt( + topic_id="test_topic", + prompt_type="system", + ) + + assert result is True + mock_s3_client.delete_object.assert_called_once_with( + Bucket="test-bucket", + Key="prompts/test_topic/system.md", + ) + + @pytest.mark.asyncio + async def test_delete_prompt_client_error( + self, + storage: S3PromptStorage, + mock_s3_client: MagicMock, + ) -> None: + """Test delete prompt with S3 client error.""" + mock_s3_client.delete_object.side_effect = ClientError( + {"Error": {"Code": "AccessDenied", "Message": "Access denied"}}, + "DeleteObject", + ) + + with pytest.raises(S3StorageError): + await storage.delete_prompt( + topic_id="test", + prompt_type="system", + ) + + +class TestS3PromptStorageList: + """Tests for list_prompts method.""" + + @pytest.mark.asyncio + async def test_list_prompts_success( + self, + storage: S3PromptStorage, + mock_s3_client: MagicMock, + ) -> None: + """Test successfully listing prompts.""" + mock_s3_client.list_objects_v2.return_value = { + "Contents": [ + {"Key": "prompts/test_topic/system.md"}, + {"Key": "prompts/test_topic/user.md"}, + ] + } + + result = await storage.list_prompts(topic_id="test_topic") + + assert len(result) == 2 + assert "system" in result + assert "user" in result + + @pytest.mark.asyncio + async def test_list_prompts_empty( + self, + storage: S3PromptStorage, + mock_s3_client: MagicMock, + ) -> None: + """Test listing prompts for topic with no prompts.""" + mock_s3_client.list_objects_v2.return_value = {} + + result = await storage.list_prompts(topic_id="empty_topic") + + assert result == [] + + +class TestS3PromptStorageExists: + """Tests for prompt_exists method.""" + + @pytest.mark.asyncio + async def test_prompt_exists_true( + self, + storage: S3PromptStorage, + mock_s3_client: MagicMock, + ) -> None: + """Test checking existing prompt.""" + mock_s3_client.head_object.return_value = {} + + result = await storage.prompt_exists( + topic_id="test_topic", + prompt_type="system", + ) + + assert result is True + + @pytest.mark.asyncio + async def test_prompt_exists_false( + self, + storage: S3PromptStorage, + mock_s3_client: MagicMock, + ) -> None: + """Test checking non-existent prompt.""" + mock_s3_client.head_object.side_effect = ClientError( + {"Error": {"Code": "404", "Message": "Not found"}}, + "HeadObject", + ) + + result = await storage.prompt_exists( + topic_id="test_topic", + prompt_type="nonexistent", + ) + + assert result is False diff --git a/coaching/tests/unit/services/test_template_parameter_processor.py b/coaching/tests/unit/services/test_template_parameter_processor.py index 9840dcab..a18263db 100644 --- a/coaching/tests/unit/services/test_template_parameter_processor.py +++ b/coaching/tests/unit/services/test_template_parameter_processor.py @@ -1,644 +1,645 @@ -"""Tests for TemplateParameterProcessor. - -Tests the core template processing functionality: -- Template parsing for {{parameter}} placeholders (Jinja2-style) -- Template parsing for {parameter} placeholders (Python-style, backward compatible) -- Grouping parameters by retrieval method -- Calling retrieval methods efficiently -- Extracting values using extraction_path -- Parameter substitution -""" - -from typing import Any -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest -from coaching.src.core.parameter_registry import ParameterDefinition, ParameterType -from coaching.src.core.retrieval_method_registry import RetrievalContext -from coaching.src.services.template_parameter_processor import ( - PARAMETER_PATTERN_DOUBLE, - PARAMETER_PATTERN_SINGLE, - ParameterExtractionResult, - ParameterRequirement, - TemplateParameterProcessor, -) - -# ============================================================================= -# Fixtures -# ============================================================================= - - -@pytest.fixture -def mock_business_client() -> MagicMock: - """Create a mock BusinessApiClient.""" - client = MagicMock() - client.get_organizational_context = AsyncMock( - return_value={ - "vision": "Test vision statement", - "purpose": "Test purpose statement", - "core_values": ["integrity", "innovation"], - "industry": "Technology", - } - ) - client.get_user_goals = AsyncMock( - return_value=[ - {"id": "goal-1", "title": "Goal One", "status": "in_progress"}, - {"id": "goal-2", "title": "Goal Two", "status": "completed"}, - ] - ) - return client - - -@pytest.fixture -def processor(mock_business_client: MagicMock) -> TemplateParameterProcessor: - """Create a TemplateParameterProcessor instance.""" - return TemplateParameterProcessor(mock_business_client) - - -# ============================================================================= -# Test: PARAMETER_PATTERN regex (both double and single brace styles) -# ============================================================================= - - -class TestParameterPattern: - """Tests for the parameter detection regex patterns.""" - - def test_double_brace_matches_simple_param(self) -> None: - """Test: Double braces match simple parameter names.""" - matches = PARAMETER_PATTERN_DOUBLE.findall("Hello {{name}}!") - assert matches == ["name"] - - def test_double_brace_matches_underscored_param(self) -> None: - """Test: Double braces match parameter names with underscores.""" - matches = PARAMETER_PATTERN_DOUBLE.findall("User: {{user_name}}") - assert matches == ["user_name"] - - def test_double_brace_matches_numbered_param(self) -> None: - """Test: Double braces match parameter names with numbers.""" - matches = PARAMETER_PATTERN_DOUBLE.findall("Value: {{param_123}}") - assert matches == ["param_123"] - - def test_double_brace_matches_multiple_params(self) -> None: - """Test: Double braces match multiple parameters in one string.""" - template = "Hello {{first_name}} {{last_name}}, your ID is {{user_id}}." - matches = PARAMETER_PATTERN_DOUBLE.findall(template) - assert set(matches) == {"first_name", "last_name", "user_id"} - - def test_single_brace_matches_simple_param(self) -> None: - """Test: Single braces match simple parameter names (backward compat).""" - matches = PARAMETER_PATTERN_SINGLE.findall("Hello {name}!") - assert matches == ["name"] - - def test_single_brace_matches_underscored_param(self) -> None: - """Test: Single braces match parameter names with underscores.""" - matches = PARAMETER_PATTERN_SINGLE.findall("User: {user_name}") - assert matches == ["user_name"] - - def test_single_brace_does_not_match_double_brace(self) -> None: - """Test: Single brace pattern does not match double braces.""" - matches = PARAMETER_PATTERN_SINGLE.findall("Hello {{name}}!") - assert matches == [] - - def test_ignores_invalid_param_names(self) -> None: - """Test: Does not match content without valid parameter name format.""" - matches = PARAMETER_PATTERN_DOUBLE.findall("Value: {{123invalid}}") - assert matches == [] - - def test_no_match_for_dot_notation(self) -> None: - """Test: Does not match dot-notation paths at all.""" - # {{user.name}} doesn't match because '.' is not in [a-zA-Z0-9_] - matches = PARAMETER_PATTERN_DOUBLE.findall("Hello {{user.name}}!") - assert matches == [] # No match because of the dot - - -# ============================================================================= -# Test: extract_parameters_from_template -# ============================================================================= - - -class TestExtractParametersFromTemplate: - """Tests for template parameter extraction.""" - - def test_extracts_unique_params_double_braces( - self, processor: TemplateParameterProcessor - ) -> None: - """Test: Returns unique set of parameter names from double braces.""" - template = "Hello {{name}}! Your name is {{name}} and id is {{user_id}}." - params = processor.extract_parameters_from_template(template) - assert params == {"name", "user_id"} - - def test_extracts_unique_params_single_braces( - self, processor: TemplateParameterProcessor - ) -> None: - """Test: Returns unique set of parameter names from single braces (backward compat).""" - template = "Hello {name}! Your name is {name} and id is {user_id}." - params = processor.extract_parameters_from_template(template) - assert params == {"name", "user_id"} - - def test_extracts_mixed_brace_styles(self, processor: TemplateParameterProcessor) -> None: - """Test: Returns params from both double and single brace styles.""" - template = "Hello {{name}}! Your id is {user_id}." - params = processor.extract_parameters_from_template(template) - assert params == {"name", "user_id"} - - def test_empty_template(self, processor: TemplateParameterProcessor) -> None: - """Test: Returns empty set for template without params.""" - params = processor.extract_parameters_from_template("Hello world!") - assert params == set() - - def test_complex_template(self, processor: TemplateParameterProcessor) -> None: - """Test: Handles complex template with many parameters.""" - template = """ - Welcome {{user_name}}! - - Your goals: - {{goals}} - - Based on your {{vision}} and {{core_values}}, we recommend: - {{recommendations}} - """ - params = processor.extract_parameters_from_template(template) - assert params == {"user_name", "goals", "vision", "core_values", "recommendations"} - - -# ============================================================================= -# Test: _extract_value -# ============================================================================= - - -class TestExtractValue: - """Tests for value extraction using paths.""" - - def test_simple_key(self, processor: TemplateParameterProcessor) -> None: - """Test: Extracts value with simple key.""" - data = {"name": "John", "age": 30} - value = processor._extract_value(data, "name", "fallback") - assert value == "John" - - def test_nested_path(self, processor: TemplateParameterProcessor) -> None: - """Test: Extracts value with dot notation path.""" - data = {"user": {"profile": {"name": "John"}}} - value = processor._extract_value(data, "user.profile.name", "fallback") - assert value == "John" - - def test_uses_param_name_when_path_empty(self, processor: TemplateParameterProcessor) -> None: - """Test: Uses param_name as key when extraction_path is empty.""" - data = {"my_param": "value"} - value = processor._extract_value(data, "", "my_param") - assert value == "value" - - def test_returns_none_for_missing_key(self, processor: TemplateParameterProcessor) -> None: - """Test: Returns None when key not found.""" - data = {"name": "John"} - value = processor._extract_value(data, "missing_key", "fallback") - assert value is None - - def test_returns_none_for_partial_path(self, processor: TemplateParameterProcessor) -> None: - """Test: Returns None when path is only partially valid.""" - data = {"user": {"name": "John"}} - value = processor._extract_value(data, "user.profile.name", "fallback") - assert value is None - - def test_handles_list_index(self, processor: TemplateParameterProcessor) -> None: - """Test: Handles numeric index in path for lists.""" - data = {"items": ["first", "second", "third"]} - value = processor._extract_value(data, "items.1", "fallback") - assert value == "second" - - def test_returns_none_for_empty_data(self, processor: TemplateParameterProcessor) -> None: - """Test: Returns None when data is empty.""" - value = processor._extract_value({}, "key", "fallback") - assert value is None - - def test_returns_none_for_none_data(self, processor: TemplateParameterProcessor) -> None: - """Test: Returns None when data is None.""" - value = processor._extract_value(None, "key", "fallback") # type: ignore - assert value is None - - -# ============================================================================= -# Test: substitute_parameters -# ============================================================================= - - -class TestSubstituteParameters: - """Tests for parameter substitution in templates.""" - - def test_simple_substitution(self, processor: TemplateParameterProcessor) -> None: - """Test: Substitutes simple string value.""" - template = "Hello {name}!" - result = processor.substitute_parameters(template, {"name": "World"}) - assert result == "Hello World!" - - def test_multiple_substitutions(self, processor: TemplateParameterProcessor) -> None: - """Test: Substitutes multiple parameters.""" - template = "{greeting} {name}! Your ID is {user_id}." - params = {"greeting": "Hello", "name": "John", "user_id": "123"} - result = processor.substitute_parameters(template, params) - assert result == "Hello John! Your ID is 123." - - def test_repeated_param(self, processor: TemplateParameterProcessor) -> None: - """Test: Substitutes repeated parameter occurrences.""" - template = "{name} is {name}." - result = processor.substitute_parameters(template, {"name": "Same"}) - assert result == "Same is Same." - - def test_missing_param_unchanged(self, processor: TemplateParameterProcessor) -> None: - """Test: Leaves placeholder when param not provided.""" - template = "Hello {name}!" - result = processor.substitute_parameters(template, {}) - assert result == "Hello {name}!" - - def test_list_value_joined(self, processor: TemplateParameterProcessor) -> None: - """Test: Joins list values with comma.""" - template = "Values: {core_values}" - result = processor.substitute_parameters( - template, {"core_values": ["integrity", "innovation"]} - ) - assert result == "Values: integrity, innovation" - - def test_dict_value_stringified(self, processor: TemplateParameterProcessor) -> None: - """Test: Converts dict to string.""" - template = "Data: {data}" - result = processor.substitute_parameters(template, {"data": {"key": "value"}}) - assert "key" in result and "value" in result - - def test_numeric_value(self, processor: TemplateParameterProcessor) -> None: - """Test: Converts numeric values to string.""" - template = "Count: {count}, Rate: {rate}" - result = processor.substitute_parameters(template, {"count": 42, "rate": 3.14}) - assert result == "Count: 42, Rate: 3.14" - - -# ============================================================================= -# Test: _group_by_retrieval_method -# ============================================================================= - - -class TestGroupByRetrievalMethod: - """Tests for grouping parameters by retrieval method.""" - - def test_groups_by_method(self, processor: TemplateParameterProcessor) -> None: - """Test: Groups requirements by retrieval_method.""" - requirements = [ - ParameterRequirement( - name="vision", - definition=ParameterDefinition( - name="vision", - param_type=ParameterType.STRING, - retrieval_method="get_business_foundation", - ), - ), - ParameterRequirement( - name="purpose", - definition=ParameterDefinition( - name="purpose", - param_type=ParameterType.STRING, - retrieval_method="get_business_foundation", - ), - ), - ParameterRequirement( - name="goals", - definition=ParameterDefinition( - name="goals", - param_type=ParameterType.LIST, - retrieval_method="get_all_goals", - ), - ), - ] - - grouped = processor._group_by_retrieval_method(requirements) - - assert len(grouped) == 2 - assert "get_business_foundation" in grouped - assert "get_all_goals" in grouped - assert len(grouped["get_business_foundation"]) == 2 - assert len(grouped["get_all_goals"]) == 1 - - def test_ignores_params_without_method(self, processor: TemplateParameterProcessor) -> None: - """Test: Excludes params without retrieval_method.""" - requirements = [ - ParameterRequirement( - name="user_id", - definition=ParameterDefinition( - name="user_id", - param_type=ParameterType.STRING, - # No retrieval_method - ), - ), - ParameterRequirement( - name="vision", - definition=ParameterDefinition( - name="vision", - param_type=ParameterType.STRING, - retrieval_method="get_business_foundation", - ), - ), - ] - - grouped = processor._group_by_retrieval_method(requirements) - - assert len(grouped) == 1 - assert "get_business_foundation" in grouped - - def test_ignores_params_without_definition(self, processor: TemplateParameterProcessor) -> None: - """Test: Excludes params without definition.""" - requirements = [ - ParameterRequirement(name="unknown_param", definition=None), - ] - - grouped = processor._group_by_retrieval_method(requirements) - - assert len(grouped) == 0 - - -# ============================================================================= -# Test: process_template_parameters -# ============================================================================= - - -class TestProcessTemplateParameters: - """Tests for the main processing flow.""" - - @pytest.mark.asyncio - async def test_uses_payload_values(self, processor: TemplateParameterProcessor) -> None: - """Test: Uses values provided in payload directly.""" - template = "Hello {user_name}!" - payload = {"user_name": "John"} - - result = await processor.process_template_parameters( - template=template, - payload=payload, - user_id="user-1", - tenant_id="tenant-1", - ) - - assert result.parameters["user_name"] == "John" - assert not result.missing_required - assert not result.warnings - - @pytest.mark.asyncio - async def test_reports_missing_required(self, processor: TemplateParameterProcessor) -> None: - """Test: Reports required params that can't be resolved.""" - template = "Hello {unknown_required_param}!" - payload: dict[str, Any] = {} - - result = await processor.process_template_parameters( - template=template, - payload=payload, - user_id="user-1", - tenant_id="tenant-1", - required_params={"unknown_required_param"}, - ) - - assert "unknown_required_param" in result.missing_required - - @pytest.mark.asyncio - async def test_applies_default_values(self, processor: TemplateParameterProcessor) -> None: - """Test: Applies default values for optional params.""" - template = "Hello {user_name}!" - - # Patch get_parameter_definition to return a param with default - with patch( - "coaching.src.services.template_parameter_processor.get_parameter_definition" - ) as mock_get: - mock_get.return_value = ParameterDefinition( - name="user_name", - param_type=ParameterType.STRING, - default="Guest", - ) - - result = await processor.process_template_parameters( - template=template, - payload={}, - user_id="user-1", - tenant_id="tenant-1", - ) - - assert result.parameters["user_name"] == "Guest" - - @pytest.mark.asyncio - async def test_calls_retrieval_method_once( - self, processor: TemplateParameterProcessor, mock_business_client: MagicMock - ) -> None: - """Test: Calls retrieval method only once for multiple params.""" - template = "Vision: {vision}, Purpose: {purpose}" - - # Patch to simulate params with retrieval method - def mock_get_param(name: str) -> ParameterDefinition | None: - if name == "vision": - return ParameterDefinition( - name="vision", - param_type=ParameterType.STRING, - retrieval_method="get_business_foundation", - extraction_path="vision", - ) - if name == "purpose": - return ParameterDefinition( - name="purpose", - param_type=ParameterType.STRING, - retrieval_method="get_business_foundation", - extraction_path="purpose", - ) - return None - - # Mock retrieval method - async def mock_retrieval_method(ctx: RetrievalContext) -> dict[str, Any]: - return { - "vision": "Our vision", - "purpose": "Our purpose", - } - - with ( - patch( - "coaching.src.services.template_parameter_processor.get_parameter_definition", - side_effect=mock_get_param, - ), - patch( - "coaching.src.services.template_parameter_processor.get_retrieval_method", - return_value=mock_retrieval_method, - ), - patch( - "coaching.src.services.template_parameter_processor.get_retrieval_method_definition", - return_value=None, - ), - ): - result = await processor.process_template_parameters( - template=template, - payload={}, - user_id="user-1", - tenant_id="tenant-1", - ) - - assert result.parameters["vision"] == "Our vision" - assert result.parameters["purpose"] == "Our purpose" - - @pytest.mark.asyncio - async def test_handles_empty_template(self, processor: TemplateParameterProcessor) -> None: - """Test: Handles template with no parameters.""" - template = "Hello world!" - - result = await processor.process_template_parameters( - template=template, - payload={}, - user_id="user-1", - tenant_id="tenant-1", - ) - - assert result.parameters == {} - assert not result.missing_required - assert not result.warnings - - @pytest.mark.asyncio - async def test_warns_on_unknown_param(self, processor: TemplateParameterProcessor) -> None: - """Test: Adds warning for params not in registry.""" - template = "Hello {totally_unknown_param}!" - - with patch( - "coaching.src.services.template_parameter_processor.get_parameter_definition", - return_value=None, - ): - result = await processor.process_template_parameters( - template=template, - payload={}, - user_id="user-1", - tenant_id="tenant-1", - ) - - assert any("totally_unknown_param" in w for w in result.warnings) - - -# ============================================================================= -# Test: _enrich_parameters -# ============================================================================= - - -class TestEnrichParameters: - """Tests for parameter enrichment via retrieval methods.""" - - @pytest.mark.asyncio - async def test_handles_retrieval_method_failure( - self, processor: TemplateParameterProcessor - ) -> None: - """Test: Handles failures gracefully and applies defaults.""" - requirements = [ - ParameterRequirement( - name="vision", - definition=ParameterDefinition( - name="vision", - param_type=ParameterType.STRING, - retrieval_method="get_business_foundation", - default="Default vision", - ), - ), - ] - - async def failing_method(ctx: RetrievalContext) -> dict[str, Any]: - raise RuntimeError("API Error") - - with ( - patch( - "coaching.src.services.template_parameter_processor.get_retrieval_method", - return_value=failing_method, - ), - patch( - "coaching.src.services.template_parameter_processor.get_retrieval_method_definition", - return_value=None, - ), - ): - result = await processor._enrich_parameters( - params_by_method={"get_business_foundation": requirements}, - payload={}, - user_id="user-1", - tenant_id="tenant-1", - ) - - # Should apply default when retrieval fails - assert result["vision"] == "Default vision" - - @pytest.mark.asyncio - async def test_skips_unknown_retrieval_method( - self, processor: TemplateParameterProcessor - ) -> None: - """Test: Skips params with unknown retrieval method.""" - requirements = [ - ParameterRequirement( - name="something", - definition=ParameterDefinition( - name="something", - param_type=ParameterType.STRING, - retrieval_method="nonexistent_method", - ), - ), - ] - - with patch( - "coaching.src.services.template_parameter_processor.get_retrieval_method", - return_value=None, - ): - result = await processor._enrich_parameters( - params_by_method={"nonexistent_method": requirements}, - payload={}, - user_id="user-1", - tenant_id="tenant-1", - ) - - assert "something" not in result - - -# ============================================================================= -# Test: ParameterExtractionResult dataclass -# ============================================================================= - - -class TestParameterExtractionResult: - """Tests for the result dataclass.""" - - def test_default_values(self) -> None: - """Test: Has sensible defaults.""" - result = ParameterExtractionResult() - assert result.parameters == {} - assert result.missing_required == [] - assert result.warnings == [] - - def test_can_set_values(self) -> None: - """Test: Can be constructed with values.""" - result = ParameterExtractionResult( - parameters={"key": "value"}, - missing_required=["missing"], - warnings=["A warning"], - ) - assert result.parameters == {"key": "value"} - assert result.missing_required == ["missing"] - assert result.warnings == ["A warning"] - - -# ============================================================================= -# Test: ParameterRequirement dataclass -# ============================================================================= - - -class TestParameterRequirement: - """Tests for the requirement dataclass.""" - - def test_default_values(self) -> None: - """Test: Has sensible defaults.""" - req = ParameterRequirement(name="test", definition=None) - assert req.name == "test" - assert req.definition is None - assert req.required is False - assert req.provided_value is None - - def test_with_all_values(self) -> None: - """Test: Can be constructed with all values.""" - definition = ParameterDefinition(name="test", param_type=ParameterType.STRING) - req = ParameterRequirement( - name="test", - definition=definition, - required=True, - provided_value="value", - ) - assert req.name == "test" - assert req.definition == definition - assert req.required is True - assert req.provided_value == "value" +"""Tests for TemplateParameterProcessor. + +Tests the core template processing functionality: +- Template parsing for {{parameter}} placeholders (Jinja2-style) +- Template parsing for {parameter} placeholders (Python-style, backward compatible) +- Grouping parameters by retrieval method +- Calling retrieval methods efficiently +- Extracting values using extraction_path +- Parameter substitution +""" + +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from coaching.src.core.parameter_registry import ParameterDefinition, ParameterType +from coaching.src.core.retrieval_method_registry import RetrievalContext +from coaching.src.services.template_parameter_processor import ( + PARAMETER_PATTERN_DOUBLE, + PARAMETER_PATTERN_SINGLE, + ParameterExtractionResult, + ParameterRequirement, + TemplateParameterProcessor, +) + +# ============================================================================= +# Fixtures +# ============================================================================= + + +@pytest.fixture +def mock_business_client() -> MagicMock: + """Create a mock BusinessApiClient.""" + client = MagicMock() + client.get_organizational_context = AsyncMock( + return_value={ + "vision": "Test vision statement", + "purpose": "Test purpose statement", + "core_values": ["integrity", "innovation"], + "industry": "Technology", + } + ) + client.get_user_goals = AsyncMock( + return_value=[ + {"id": "goal-1", "title": "Goal One", "status": "in_progress"}, + {"id": "goal-2", "title": "Goal Two", "status": "completed"}, + ] + ) + return client + + +@pytest.fixture +def processor(mock_business_client: MagicMock) -> TemplateParameterProcessor: + """Create a TemplateParameterProcessor instance.""" + return TemplateParameterProcessor(mock_business_client) + + +# ============================================================================= +# Test: PARAMETER_PATTERN regex (both double and single brace styles) +# ============================================================================= + + +class TestParameterPattern: + """Tests for the parameter detection regex patterns.""" + + def test_double_brace_matches_simple_param(self) -> None: + """Test: Double braces match simple parameter names.""" + matches = PARAMETER_PATTERN_DOUBLE.findall("Hello {{name}}!") + assert matches == ["name"] + + def test_double_brace_matches_underscored_param(self) -> None: + """Test: Double braces match parameter names with underscores.""" + matches = PARAMETER_PATTERN_DOUBLE.findall("User: {{user_name}}") + assert matches == ["user_name"] + + def test_double_brace_matches_numbered_param(self) -> None: + """Test: Double braces match parameter names with numbers.""" + matches = PARAMETER_PATTERN_DOUBLE.findall("Value: {{param_123}}") + assert matches == ["param_123"] + + def test_double_brace_matches_multiple_params(self) -> None: + """Test: Double braces match multiple parameters in one string.""" + template = "Hello {{first_name}} {{last_name}}, your ID is {{user_id}}." + matches = PARAMETER_PATTERN_DOUBLE.findall(template) + assert set(matches) == {"first_name", "last_name", "user_id"} + + def test_single_brace_matches_simple_param(self) -> None: + """Test: Single braces match simple parameter names (backward compat).""" + matches = PARAMETER_PATTERN_SINGLE.findall("Hello {name}!") + assert matches == ["name"] + + def test_single_brace_matches_underscored_param(self) -> None: + """Test: Single braces match parameter names with underscores.""" + matches = PARAMETER_PATTERN_SINGLE.findall("User: {user_name}") + assert matches == ["user_name"] + + def test_single_brace_does_not_match_double_brace(self) -> None: + """Test: Single brace pattern does not match double braces.""" + matches = PARAMETER_PATTERN_SINGLE.findall("Hello {{name}}!") + assert matches == [] + + def test_ignores_invalid_param_names(self) -> None: + """Test: Does not match content without valid parameter name format.""" + matches = PARAMETER_PATTERN_DOUBLE.findall("Value: {{123invalid}}") + assert matches == [] + + def test_no_match_for_dot_notation(self) -> None: + """Test: Does not match dot-notation paths at all.""" + # {{user.name}} doesn't match because '.' is not in [a-zA-Z0-9_] + matches = PARAMETER_PATTERN_DOUBLE.findall("Hello {{user.name}}!") + assert matches == [] # No match because of the dot + + +# ============================================================================= +# Test: extract_parameters_from_template +# ============================================================================= + + +class TestExtractParametersFromTemplate: + """Tests for template parameter extraction.""" + + def test_extracts_unique_params_double_braces( + self, processor: TemplateParameterProcessor + ) -> None: + """Test: Returns unique set of parameter names from double braces.""" + template = "Hello {{name}}! Your name is {{name}} and id is {{user_id}}." + params = processor.extract_parameters_from_template(template) + assert params == {"name", "user_id"} + + def test_extracts_unique_params_single_braces( + self, processor: TemplateParameterProcessor + ) -> None: + """Test: Returns unique set of parameter names from single braces (backward compat).""" + template = "Hello {name}! Your name is {name} and id is {user_id}." + params = processor.extract_parameters_from_template(template) + assert params == {"name", "user_id"} + + def test_extracts_mixed_brace_styles(self, processor: TemplateParameterProcessor) -> None: + """Test: Returns params from both double and single brace styles.""" + template = "Hello {{name}}! Your id is {user_id}." + params = processor.extract_parameters_from_template(template) + assert params == {"name", "user_id"} + + def test_empty_template(self, processor: TemplateParameterProcessor) -> None: + """Test: Returns empty set for template without params.""" + params = processor.extract_parameters_from_template("Hello world!") + assert params == set() + + def test_complex_template(self, processor: TemplateParameterProcessor) -> None: + """Test: Handles complex template with many parameters.""" + template = """ + Welcome {{user_name}}! + + Your goals: + {{goals}} + + Based on your {{vision}} and {{core_values}}, we recommend: + {{recommendations}} + """ + params = processor.extract_parameters_from_template(template) + assert params == {"user_name", "goals", "vision", "core_values", "recommendations"} + + +# ============================================================================= +# Test: _extract_value +# ============================================================================= + + +class TestExtractValue: + """Tests for value extraction using paths.""" + + def test_simple_key(self, processor: TemplateParameterProcessor) -> None: + """Test: Extracts value with simple key.""" + data = {"name": "John", "age": 30} + value = processor._extract_value(data, "name", "fallback") + assert value == "John" + + def test_nested_path(self, processor: TemplateParameterProcessor) -> None: + """Test: Extracts value with dot notation path.""" + data = {"user": {"profile": {"name": "John"}}} + value = processor._extract_value(data, "user.profile.name", "fallback") + assert value == "John" + + def test_uses_param_name_when_path_empty(self, processor: TemplateParameterProcessor) -> None: + """Test: Uses param_name as key when extraction_path is empty.""" + data = {"my_param": "value"} + value = processor._extract_value(data, "", "my_param") + assert value == "value" + + def test_returns_none_for_missing_key(self, processor: TemplateParameterProcessor) -> None: + """Test: Returns None when key not found.""" + data = {"name": "John"} + value = processor._extract_value(data, "missing_key", "fallback") + assert value is None + + def test_returns_none_for_partial_path(self, processor: TemplateParameterProcessor) -> None: + """Test: Returns None when path is only partially valid.""" + data = {"user": {"name": "John"}} + value = processor._extract_value(data, "user.profile.name", "fallback") + assert value is None + + def test_handles_list_index(self, processor: TemplateParameterProcessor) -> None: + """Test: Handles numeric index in path for lists.""" + data = {"items": ["first", "second", "third"]} + value = processor._extract_value(data, "items.1", "fallback") + assert value == "second" + + def test_returns_none_for_empty_data(self, processor: TemplateParameterProcessor) -> None: + """Test: Returns None when data is empty.""" + value = processor._extract_value({}, "key", "fallback") + assert value is None + + def test_returns_none_for_none_data(self, processor: TemplateParameterProcessor) -> None: + """Test: Returns None when data is None.""" + value = processor._extract_value(None, "key", "fallback") # type: ignore + assert value is None + + +# ============================================================================= +# Test: substitute_parameters +# ============================================================================= + + +class TestSubstituteParameters: + """Tests for parameter substitution in templates.""" + + def test_simple_substitution(self, processor: TemplateParameterProcessor) -> None: + """Test: Substitutes simple string value.""" + template = "Hello {name}!" + result = processor.substitute_parameters(template, {"name": "World"}) + assert result == "Hello World!" + + def test_multiple_substitutions(self, processor: TemplateParameterProcessor) -> None: + """Test: Substitutes multiple parameters.""" + template = "{greeting} {name}! Your ID is {user_id}." + params = {"greeting": "Hello", "name": "John", "user_id": "123"} + result = processor.substitute_parameters(template, params) + assert result == "Hello John! Your ID is 123." + + def test_repeated_param(self, processor: TemplateParameterProcessor) -> None: + """Test: Substitutes repeated parameter occurrences.""" + template = "{name} is {name}." + result = processor.substitute_parameters(template, {"name": "Same"}) + assert result == "Same is Same." + + def test_missing_param_unchanged(self, processor: TemplateParameterProcessor) -> None: + """Test: Leaves placeholder when param not provided.""" + template = "Hello {name}!" + result = processor.substitute_parameters(template, {}) + assert result == "Hello {name}!" + + def test_list_value_joined(self, processor: TemplateParameterProcessor) -> None: + """Test: Joins list values with comma.""" + template = "Values: {core_values}" + result = processor.substitute_parameters( + template, {"core_values": ["integrity", "innovation"]} + ) + assert result == "Values: integrity, innovation" + + def test_dict_value_stringified(self, processor: TemplateParameterProcessor) -> None: + """Test: Converts dict to string.""" + template = "Data: {data}" + result = processor.substitute_parameters(template, {"data": {"key": "value"}}) + assert "key" in result and "value" in result + + def test_numeric_value(self, processor: TemplateParameterProcessor) -> None: + """Test: Converts numeric values to string.""" + template = "Count: {count}, Rate: {rate}" + result = processor.substitute_parameters(template, {"count": 42, "rate": 3.14}) + assert result == "Count: 42, Rate: 3.14" + + +# ============================================================================= +# Test: _group_by_retrieval_method +# ============================================================================= + + +class TestGroupByRetrievalMethod: + """Tests for grouping parameters by retrieval method.""" + + def test_groups_by_method(self, processor: TemplateParameterProcessor) -> None: + """Test: Groups requirements by retrieval_method.""" + requirements = [ + ParameterRequirement( + name="vision", + definition=ParameterDefinition( + name="vision", + param_type=ParameterType.STRING, + retrieval_method="get_business_foundation", + ), + ), + ParameterRequirement( + name="purpose", + definition=ParameterDefinition( + name="purpose", + param_type=ParameterType.STRING, + retrieval_method="get_business_foundation", + ), + ), + ParameterRequirement( + name="goals", + definition=ParameterDefinition( + name="goals", + param_type=ParameterType.LIST, + retrieval_method="get_all_goals", + ), + ), + ] + + grouped = processor._group_by_retrieval_method(requirements) + + assert len(grouped) == 2 + assert "get_business_foundation" in grouped + assert "get_all_goals" in grouped + assert len(grouped["get_business_foundation"]) == 2 + assert len(grouped["get_all_goals"]) == 1 + + def test_ignores_params_without_method(self, processor: TemplateParameterProcessor) -> None: + """Test: Excludes params without retrieval_method.""" + requirements = [ + ParameterRequirement( + name="user_id", + definition=ParameterDefinition( + name="user_id", + param_type=ParameterType.STRING, + # No retrieval_method + ), + ), + ParameterRequirement( + name="vision", + definition=ParameterDefinition( + name="vision", + param_type=ParameterType.STRING, + retrieval_method="get_business_foundation", + ), + ), + ] + + grouped = processor._group_by_retrieval_method(requirements) + + assert len(grouped) == 1 + assert "get_business_foundation" in grouped + + def test_ignores_params_without_definition(self, processor: TemplateParameterProcessor) -> None: + """Test: Excludes params without definition.""" + requirements = [ + ParameterRequirement(name="unknown_param", definition=None), + ] + + grouped = processor._group_by_retrieval_method(requirements) + + assert len(grouped) == 0 + + +# ============================================================================= +# Test: process_template_parameters +# ============================================================================= + + +class TestProcessTemplateParameters: + """Tests for the main processing flow.""" + + @pytest.mark.asyncio + async def test_uses_payload_values(self, processor: TemplateParameterProcessor) -> None: + """Test: Uses values provided in payload directly.""" + template = "Hello {user_name}!" + payload = {"user_name": "John"} + + result = await processor.process_template_parameters( + template=template, + payload=payload, + user_id="user-1", + tenant_id="tenant-1", + ) + + assert result.parameters["user_name"] == "John" + assert not result.missing_required + assert not result.warnings + + @pytest.mark.asyncio + async def test_reports_missing_required(self, processor: TemplateParameterProcessor) -> None: + """Test: Reports required params that can't be resolved.""" + template = "Hello {unknown_required_param}!" + payload: dict[str, Any] = {} + + result = await processor.process_template_parameters( + template=template, + payload=payload, + user_id="user-1", + tenant_id="tenant-1", + required_params={"unknown_required_param"}, + ) + + assert "unknown_required_param" in result.missing_required + + @pytest.mark.asyncio + async def test_applies_default_values(self, processor: TemplateParameterProcessor) -> None: + """Test: Applies default values for optional params.""" + template = "Hello {user_name}!" + + # Patch get_parameter_definition to return a param with default + with patch( + "coaching.src.services.template_parameter_processor.get_parameter_definition" + ) as mock_get: + mock_get.return_value = ParameterDefinition( + name="user_name", + param_type=ParameterType.STRING, + default="Guest", + ) + + result = await processor.process_template_parameters( + template=template, + payload={}, + user_id="user-1", + tenant_id="tenant-1", + ) + + assert result.parameters["user_name"] == "Guest" + + @pytest.mark.asyncio + async def test_calls_retrieval_method_once( + self, processor: TemplateParameterProcessor, mock_business_client: MagicMock + ) -> None: + """Test: Calls retrieval method only once for multiple params.""" + template = "Vision: {vision}, Purpose: {purpose}" + + # Patch to simulate params with retrieval method + def mock_get_param(name: str) -> ParameterDefinition | None: + if name == "vision": + return ParameterDefinition( + name="vision", + param_type=ParameterType.STRING, + retrieval_method="get_business_foundation", + extraction_path="vision", + ) + if name == "purpose": + return ParameterDefinition( + name="purpose", + param_type=ParameterType.STRING, + retrieval_method="get_business_foundation", + extraction_path="purpose", + ) + return None + + # Mock retrieval method + async def mock_retrieval_method(ctx: RetrievalContext) -> dict[str, Any]: + return { + "vision": "Our vision", + "purpose": "Our purpose", + } + + with ( + patch( + "coaching.src.services.template_parameter_processor.get_parameter_definition", + side_effect=mock_get_param, + ), + patch( + "coaching.src.services.template_parameter_processor.get_retrieval_method", + return_value=mock_retrieval_method, + ), + patch( + "coaching.src.services.template_parameter_processor.get_retrieval_method_definition", + return_value=None, + ), + ): + result = await processor.process_template_parameters( + template=template, + payload={}, + user_id="user-1", + tenant_id="tenant-1", + ) + + assert result.parameters["vision"] == "Our vision" + assert result.parameters["purpose"] == "Our purpose" + + @pytest.mark.asyncio + async def test_handles_empty_template(self, processor: TemplateParameterProcessor) -> None: + """Test: Handles template with no parameters.""" + template = "Hello world!" + + result = await processor.process_template_parameters( + template=template, + payload={}, + user_id="user-1", + tenant_id="tenant-1", + ) + + assert result.parameters == {} + assert not result.missing_required + assert not result.warnings + + @pytest.mark.asyncio + async def test_warns_on_unknown_param(self, processor: TemplateParameterProcessor) -> None: + """Test: Adds warning for params not in registry.""" + template = "Hello {totally_unknown_param}!" + + with patch( + "coaching.src.services.template_parameter_processor.get_parameter_definition", + return_value=None, + ): + result = await processor.process_template_parameters( + template=template, + payload={}, + user_id="user-1", + tenant_id="tenant-1", + ) + + assert any("totally_unknown_param" in w for w in result.warnings) + + +# ============================================================================= +# Test: _enrich_parameters +# ============================================================================= + + +class TestEnrichParameters: + """Tests for parameter enrichment via retrieval methods.""" + + @pytest.mark.asyncio + async def test_handles_retrieval_method_failure( + self, processor: TemplateParameterProcessor + ) -> None: + """Test: Handles failures gracefully and applies defaults.""" + requirements = [ + ParameterRequirement( + name="vision", + definition=ParameterDefinition( + name="vision", + param_type=ParameterType.STRING, + retrieval_method="get_business_foundation", + default="Default vision", + ), + ), + ] + + async def failing_method(ctx: RetrievalContext) -> dict[str, Any]: + raise RuntimeError("API Error") + + with ( + patch( + "coaching.src.services.template_parameter_processor.get_retrieval_method", + return_value=failing_method, + ), + patch( + "coaching.src.services.template_parameter_processor.get_retrieval_method_definition", + return_value=None, + ), + ): + result = await processor._enrich_parameters( + params_by_method={"get_business_foundation": requirements}, + payload={}, + user_id="user-1", + tenant_id="tenant-1", + ) + + # Should apply default when retrieval fails + assert result["vision"] == "Default vision" + + @pytest.mark.asyncio + async def test_skips_unknown_retrieval_method( + self, processor: TemplateParameterProcessor + ) -> None: + """Test: Skips params with unknown retrieval method.""" + requirements = [ + ParameterRequirement( + name="something", + definition=ParameterDefinition( + name="something", + param_type=ParameterType.STRING, + retrieval_method="nonexistent_method", + ), + ), + ] + + with patch( + "coaching.src.services.template_parameter_processor.get_retrieval_method", + return_value=None, + ): + result = await processor._enrich_parameters( + params_by_method={"nonexistent_method": requirements}, + payload={}, + user_id="user-1", + tenant_id="tenant-1", + ) + + assert "something" not in result + + +# ============================================================================= +# Test: ParameterExtractionResult dataclass +# ============================================================================= + + +class TestParameterExtractionResult: + """Tests for the result dataclass.""" + + def test_default_values(self) -> None: + """Test: Has sensible defaults.""" + result = ParameterExtractionResult() + assert result.parameters == {} + assert result.missing_required == [] + assert result.warnings == [] + + def test_can_set_values(self) -> None: + """Test: Can be constructed with values.""" + result = ParameterExtractionResult( + parameters={"key": "value"}, + missing_required=["missing"], + warnings=["A warning"], + ) + assert result.parameters == {"key": "value"} + assert result.missing_required == ["missing"] + assert result.warnings == ["A warning"] + + +# ============================================================================= +# Test: ParameterRequirement dataclass +# ============================================================================= + + +class TestParameterRequirement: + """Tests for the requirement dataclass.""" + + def test_default_values(self) -> None: + """Test: Has sensible defaults.""" + req = ParameterRequirement(name="test", definition=None) + assert req.name == "test" + assert req.definition is None + assert req.required is False + assert req.provided_value is None + + def test_with_all_values(self) -> None: + """Test: Can be constructed with all values.""" + definition = ParameterDefinition(name="test", param_type=ParameterType.STRING) + req = ParameterRequirement( + name="test", + definition=definition, + required=True, + provided_value="value", + ) + assert req.name == "test" + assert req.definition == definition + assert req.required is True + assert req.provided_value == "value" diff --git a/coaching/tests/unit/services/test_topic_seeding_service.py b/coaching/tests/unit/services/test_topic_seeding_service.py index 2d13597f..92b3ba1b 100644 --- a/coaching/tests/unit/services/test_topic_seeding_service.py +++ b/coaching/tests/unit/services/test_topic_seeding_service.py @@ -1,6 +1,7 @@ from unittest.mock import AsyncMock, Mock, patch import pytest + from coaching.src.core.topic_seed_data import TopicSeedData, get_seed_data_for_topic from coaching.src.domain.entities.llm_topic import LLMTopic from coaching.src.services.topic_seeding_service import ( diff --git a/coaching/tests/unit/services/test_user_limits_service.py b/coaching/tests/unit/services/test_user_limits_service.py index ddcf0852..36e27718 100644 --- a/coaching/tests/unit/services/test_user_limits_service.py +++ b/coaching/tests/unit/services/test_user_limits_service.py @@ -1,130 +1,131 @@ -import time -from unittest.mock import Mock, patch - -import pytest -from coaching.src.services.user_limits_service import UserLimitsCache, UserLimitsService - -pytestmark = pytest.mark.unit - - -class TestUserLimitsCache: - """Test suite for UserLimitsCache.""" - - def test_cache_set_and_get(self) -> None: - """Test setting and getting limits.""" - cache = UserLimitsCache(ttl_seconds=60) - user_id = "user1" - token = "token1" - limits = {"max_conversations": 10} - - cache.set(user_id, token, limits) - result = cache.get(user_id, token) - - assert result == limits - - def test_cache_miss(self) -> None: - """Test cache miss.""" - cache = UserLimitsCache() - assert cache.get("user1", "token1") is None - - def test_cache_expiration(self) -> None: - """Test cache expiration.""" - cache = UserLimitsCache(ttl_seconds=1) - user_id = "user1" - token = "token1" - limits = {"max_conversations": 10} - - cache.set(user_id, token, limits) - time.sleep(1.1) - - assert cache.get(user_id, token) is None - - def test_token_change_invalidates_cache(self) -> None: - """Test that changing the token invalidates the cache.""" - cache = UserLimitsCache() - user_id = "user1" - token1 = "token1" - token2 = "token2" - limits = {"max_conversations": 10} - - cache.set(user_id, token1, limits) - assert cache.get(user_id, token2) is None - - -class TestUserLimitsService: - """Test suite for UserLimitsService.""" - - @pytest.fixture - def service(self) -> UserLimitsService: - return UserLimitsService() - - @pytest.mark.asyncio - async def test_get_user_limits_cached(self) -> None: - """Test getting limits from cache.""" - service = UserLimitsService() - user_id = "user1" - token = "token1" - limits = {"max_conversations": 10} - - # Pre-populate cache - service._cache.set(user_id, token, limits) - - result = await service.get_user_limits(user_id, token) - assert result == limits - - @pytest.mark.asyncio - async def test_get_user_limits_api_call(self) -> None: - """Test getting limits from API when cache is empty.""" - service = UserLimitsService() - user_id = "user1" - token = "token1" - expected_limits = {"max_conversations": 10} - api_response = {"success": True, "data": expected_limits} - - with patch("httpx.AsyncClient") as mock_client_cls: - mock_client = mock_client_cls.return_value - mock_client.__aenter__.return_value = mock_client - - mock_response = Mock() - mock_response.status_code = 200 - mock_response.json.return_value = api_response - - # Make get return an awaitable - async def async_get(*args, **kwargs): - return mock_response - - mock_client.get.side_effect = async_get - - result = await service.get_user_limits(user_id, token) - - assert result == expected_limits - # Verify cache was updated - assert service._cache.get(user_id, token) == expected_limits - - @pytest.mark.asyncio - async def test_get_user_limits_api_error(self) -> None: - """Test handling API error.""" - service = UserLimitsService() - user_id = "user1" - token = "token1" - - with patch("httpx.AsyncClient") as mock_client_cls: - mock_client = mock_client_cls.return_value - mock_client.__aenter__.return_value = mock_client - - mock_response = Mock() - mock_response.status_code = 500 - - # Make get return an awaitable - async def async_get(*args, **kwargs): - return mock_response - - mock_client.get.side_effect = async_get - - # Should return default limits on error - result = await service.get_user_limits(user_id, token) - - # Check that result contains default keys - assert "goals" in result - assert "users" in result - assert "projects" in result +import time +from unittest.mock import Mock, patch + +import pytest + +from coaching.src.services.user_limits_service import UserLimitsCache, UserLimitsService + +pytestmark = pytest.mark.unit + + +class TestUserLimitsCache: + """Test suite for UserLimitsCache.""" + + def test_cache_set_and_get(self) -> None: + """Test setting and getting limits.""" + cache = UserLimitsCache(ttl_seconds=60) + user_id = "user1" + token = "token1" + limits = {"max_conversations": 10} + + cache.set(user_id, token, limits) + result = cache.get(user_id, token) + + assert result == limits + + def test_cache_miss(self) -> None: + """Test cache miss.""" + cache = UserLimitsCache() + assert cache.get("user1", "token1") is None + + def test_cache_expiration(self) -> None: + """Test cache expiration.""" + cache = UserLimitsCache(ttl_seconds=1) + user_id = "user1" + token = "token1" + limits = {"max_conversations": 10} + + cache.set(user_id, token, limits) + time.sleep(1.1) + + assert cache.get(user_id, token) is None + + def test_token_change_invalidates_cache(self) -> None: + """Test that changing the token invalidates the cache.""" + cache = UserLimitsCache() + user_id = "user1" + token1 = "token1" + token2 = "token2" + limits = {"max_conversations": 10} + + cache.set(user_id, token1, limits) + assert cache.get(user_id, token2) is None + + +class TestUserLimitsService: + """Test suite for UserLimitsService.""" + + @pytest.fixture + def service(self) -> UserLimitsService: + return UserLimitsService() + + @pytest.mark.asyncio + async def test_get_user_limits_cached(self) -> None: + """Test getting limits from cache.""" + service = UserLimitsService() + user_id = "user1" + token = "token1" + limits = {"max_conversations": 10} + + # Pre-populate cache + service._cache.set(user_id, token, limits) + + result = await service.get_user_limits(user_id, token) + assert result == limits + + @pytest.mark.asyncio + async def test_get_user_limits_api_call(self) -> None: + """Test getting limits from API when cache is empty.""" + service = UserLimitsService() + user_id = "user1" + token = "token1" + expected_limits = {"max_conversations": 10} + api_response = {"success": True, "data": expected_limits} + + with patch("httpx.AsyncClient") as mock_client_cls: + mock_client = mock_client_cls.return_value + mock_client.__aenter__.return_value = mock_client + + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = api_response + + # Make get return an awaitable + async def async_get(*args, **kwargs): + return mock_response + + mock_client.get.side_effect = async_get + + result = await service.get_user_limits(user_id, token) + + assert result == expected_limits + # Verify cache was updated + assert service._cache.get(user_id, token) == expected_limits + + @pytest.mark.asyncio + async def test_get_user_limits_api_error(self) -> None: + """Test handling API error.""" + service = UserLimitsService() + user_id = "user1" + token = "token1" + + with patch("httpx.AsyncClient") as mock_client_cls: + mock_client = mock_client_cls.return_value + mock_client.__aenter__.return_value = mock_client + + mock_response = Mock() + mock_response.status_code = 500 + + # Make get return an awaitable + async def async_get(*args, **kwargs): + return mock_response + + mock_client.get.side_effect = async_get + + # Should return default limits on error + result = await service.get_user_limits(user_id, token) + + # Check that result contains default keys + assert "goals" in result + assert "users" in result + assert "projects" in result diff --git a/coaching/tests/unit/services/test_website_analysis_service.py b/coaching/tests/unit/services/test_website_analysis_service.py index eba2d8f6..8f018788 100644 --- a/coaching/tests/unit/services/test_website_analysis_service.py +++ b/coaching/tests/unit/services/test_website_analysis_service.py @@ -1,194 +1,195 @@ -from unittest.mock import AsyncMock, Mock, patch - -import pytest -from coaching.src.llm.providers.manager import ProviderManager -from coaching.src.services.website_analysis_service import WebsiteAnalysisService - - -class TestWebsiteAnalysisService: - @pytest.fixture - def mock_provider_manager(self): - manager = Mock(spec=ProviderManager) - manager._providers = {"bedrock": AsyncMock()} - return manager - - @pytest.fixture - def mock_llm_service(self): - service = AsyncMock() - return service - - @pytest.fixture - def service(self, mock_provider_manager, mock_llm_service): - return WebsiteAnalysisService( - provider_manager=mock_provider_manager, llm_service=mock_llm_service - ) - - @pytest.mark.asyncio - async def test_analyze_website_success_with_llm_service(self, service, mock_llm_service): - # Arrange - url = "https://example.com" - # Make content longer than 100 chars - long_content = "Test content about products. " * 10 - html_content = f"Test Page

{long_content}

" - - mock_llm_response = { - "response": """ - ```json - { - "products": [{"id": "p1", "name": "Product 1", "problem": "Problem 1"}], - "niche": "Test Niche", - "ica": "Test ICA", - "value_proposition": "Test Value Prop" - } - ``` - """ - } - mock_llm_service.generate_single_shot_analysis.return_value = mock_llm_response - - with patch("requests.get") as mock_get: - mock_response = Mock() - mock_response.text = html_content - mock_response.status_code = 200 - mock_get.return_value = mock_response - - # Act - result = await service.analyze_website(url) - - # Assert - assert result["niche"] == "Test Niche" - assert len(result["products"]) == 1 - assert result["products"][0]["name"] == "Product 1" - - mock_llm_service.generate_single_shot_analysis.assert_called_once() - call_args = mock_llm_service.generate_single_shot_analysis.call_args - assert call_args.kwargs["topic"] == "website_analysis" - assert "Test Page" in call_args.kwargs["user_input"] - - @pytest.mark.asyncio - async def test_analyze_website_success_with_provider_manager(self, mock_provider_manager): - # Arrange - service = WebsiteAnalysisService(provider_manager=mock_provider_manager) - url = "https://example.com" - # Make content longer than 100 chars - long_content = "Content " * 20 - html_content = f"

{long_content}

" - - mock_provider = mock_provider_manager._providers["bedrock"] - mock_provider.invoke.return_value = ( - '{"products": [], "niche": "N", "ica": "I", "value_proposition": "V"}' - ) - - with patch("requests.get") as mock_get: - mock_response = Mock() - mock_response.text = html_content - mock_response.status_code = 200 - mock_get.return_value = mock_response - - # Act - result = await service.analyze_website(url) - - # Assert - assert result["niche"] == "N" - mock_provider.invoke.assert_called_once() - - @pytest.mark.asyncio - async def test_analyze_website_invalid_url(self, service): - # Act & Assert - with pytest.raises(ValueError, match="Invalid URL"): - await service.analyze_website("invalid-url") - - @pytest.mark.asyncio - async def test_analyze_website_fetch_failure(self, service): - # Arrange - with ( - patch("requests.get", side_effect=Exception("Connection error")), - pytest.raises(ValueError, match="Could not fetch website content"), - ): - # Act & Assert - await service.analyze_website("https://example.com") - - @pytest.mark.asyncio - async def test_analyze_website_empty_content(self, service): - # Arrange - with patch("requests.get") as mock_get: - mock_response = Mock() - mock_response.text = "" # Empty body - mock_get.return_value = mock_response - - # Act & Assert - with pytest.raises(ValueError, match="Could not extract meaningful content"): - await service.analyze_website("https://example.com") - - @pytest.mark.asyncio - async def test_analyze_website_llm_failure(self, service, mock_llm_service): - # Arrange - mock_llm_service.generate_single_shot_analysis.side_effect = Exception("LLM Error") - - with patch("requests.get") as mock_get: - mock_response = Mock() - mock_response.text = "

" + "content " * 20 + "

" - mock_get.return_value = mock_response - - # Act & Assert - with pytest.raises(RuntimeError, match="AI analysis failed"): - await service.analyze_website("https://example.com") - - def test_validate_url_security(self, service): - # Act & Assert - with pytest.raises(ValueError, match="Cannot analyze local or internal URLs"): - service._validate_url("http://localhost:8000") - - with pytest.raises(ValueError, match="Cannot analyze local or internal URLs"): - service._validate_url("http://127.0.0.1") - - def test_extract_text_content_cleaning(self, service): - # Arrange - html = """ - - - - -

Title

-

Multiple spaces

- - - - """ - - # Act - text = service._extract_text_content(html) - - # Assert - assert "var x=1" not in text - assert ".css" not in text - assert "Menu" not in text - assert "Title" in text - assert "Multiple spaces" in text - - @pytest.mark.asyncio - async def test_analyze_with_llm_json_fallback(self, service, mock_llm_service): - # Arrange - mock_llm_service.generate_single_shot_analysis.return_value = { - "response": "Invalid JSON response" - } - - # Act - result = await service._analyze_with_llm("http://url", "Title", "Desc", "Content") - - # Assert - assert result["products"][0]["id"] == "product-placeholder" # Check for fallback value - assert "Title" in result["niche"] - - @pytest.mark.asyncio - async def test_analyze_with_llm_missing_fields(self, service, mock_llm_service): - # Arrange - mock_llm_service.generate_single_shot_analysis.return_value = { - "response": '{"products": []}' # Missing other fields - } - - # Act - result = await service._analyze_with_llm("http://url", "Title", "Desc", "Content") - - # Assert - assert result["niche"] == "Not determined" - assert result["products"] == [] +from unittest.mock import AsyncMock, Mock, patch + +import pytest + +from coaching.src.llm.providers.manager import ProviderManager +from coaching.src.services.website_analysis_service import WebsiteAnalysisService + + +class TestWebsiteAnalysisService: + @pytest.fixture + def mock_provider_manager(self): + manager = Mock(spec=ProviderManager) + manager._providers = {"bedrock": AsyncMock()} + return manager + + @pytest.fixture + def mock_llm_service(self): + service = AsyncMock() + return service + + @pytest.fixture + def service(self, mock_provider_manager, mock_llm_service): + return WebsiteAnalysisService( + provider_manager=mock_provider_manager, llm_service=mock_llm_service + ) + + @pytest.mark.asyncio + async def test_analyze_website_success_with_llm_service(self, service, mock_llm_service): + # Arrange + url = "https://example.com" + # Make content longer than 100 chars + long_content = "Test content about products. " * 10 + html_content = f"Test Page

{long_content}

" + + mock_llm_response = { + "response": """ + ```json + { + "products": [{"id": "p1", "name": "Product 1", "problem": "Problem 1"}], + "niche": "Test Niche", + "ica": "Test ICA", + "value_proposition": "Test Value Prop" + } + ``` + """ + } + mock_llm_service.generate_single_shot_analysis.return_value = mock_llm_response + + with patch("requests.get") as mock_get: + mock_response = Mock() + mock_response.text = html_content + mock_response.status_code = 200 + mock_get.return_value = mock_response + + # Act + result = await service.analyze_website(url) + + # Assert + assert result["niche"] == "Test Niche" + assert len(result["products"]) == 1 + assert result["products"][0]["name"] == "Product 1" + + mock_llm_service.generate_single_shot_analysis.assert_called_once() + call_args = mock_llm_service.generate_single_shot_analysis.call_args + assert call_args.kwargs["topic"] == "website_analysis" + assert "Test Page" in call_args.kwargs["user_input"] + + @pytest.mark.asyncio + async def test_analyze_website_success_with_provider_manager(self, mock_provider_manager): + # Arrange + service = WebsiteAnalysisService(provider_manager=mock_provider_manager) + url = "https://example.com" + # Make content longer than 100 chars + long_content = "Content " * 20 + html_content = f"

{long_content}

" + + mock_provider = mock_provider_manager._providers["bedrock"] + mock_provider.invoke.return_value = ( + '{"products": [], "niche": "N", "ica": "I", "value_proposition": "V"}' + ) + + with patch("requests.get") as mock_get: + mock_response = Mock() + mock_response.text = html_content + mock_response.status_code = 200 + mock_get.return_value = mock_response + + # Act + result = await service.analyze_website(url) + + # Assert + assert result["niche"] == "N" + mock_provider.invoke.assert_called_once() + + @pytest.mark.asyncio + async def test_analyze_website_invalid_url(self, service): + # Act & Assert + with pytest.raises(ValueError, match="Invalid URL"): + await service.analyze_website("invalid-url") + + @pytest.mark.asyncio + async def test_analyze_website_fetch_failure(self, service): + # Arrange + with ( + patch("requests.get", side_effect=Exception("Connection error")), + pytest.raises(ValueError, match="Could not fetch website content"), + ): + # Act & Assert + await service.analyze_website("https://example.com") + + @pytest.mark.asyncio + async def test_analyze_website_empty_content(self, service): + # Arrange + with patch("requests.get") as mock_get: + mock_response = Mock() + mock_response.text = "" # Empty body + mock_get.return_value = mock_response + + # Act & Assert + with pytest.raises(ValueError, match="Could not extract meaningful content"): + await service.analyze_website("https://example.com") + + @pytest.mark.asyncio + async def test_analyze_website_llm_failure(self, service, mock_llm_service): + # Arrange + mock_llm_service.generate_single_shot_analysis.side_effect = Exception("LLM Error") + + with patch("requests.get") as mock_get: + mock_response = Mock() + mock_response.text = "

" + "content " * 20 + "

" + mock_get.return_value = mock_response + + # Act & Assert + with pytest.raises(RuntimeError, match="AI analysis failed"): + await service.analyze_website("https://example.com") + + def test_validate_url_security(self, service): + # Act & Assert + with pytest.raises(ValueError, match="Cannot analyze local or internal URLs"): + service._validate_url("http://localhost:8000") + + with pytest.raises(ValueError, match="Cannot analyze local or internal URLs"): + service._validate_url("http://127.0.0.1") + + def test_extract_text_content_cleaning(self, service): + # Arrange + html = """ + + + + +

Title

+

Multiple spaces

+ + + + """ + + # Act + text = service._extract_text_content(html) + + # Assert + assert "var x=1" not in text + assert ".css" not in text + assert "Menu" not in text + assert "Title" in text + assert "Multiple spaces" in text + + @pytest.mark.asyncio + async def test_analyze_with_llm_json_fallback(self, service, mock_llm_service): + # Arrange + mock_llm_service.generate_single_shot_analysis.return_value = { + "response": "Invalid JSON response" + } + + # Act + result = await service._analyze_with_llm("http://url", "Title", "Desc", "Content") + + # Assert + assert result["products"][0]["id"] == "product-placeholder" # Check for fallback value + assert "Title" in result["niche"] + + @pytest.mark.asyncio + async def test_analyze_with_llm_missing_fields(self, service, mock_llm_service): + # Arrange + mock_llm_service.generate_single_shot_analysis.return_value = { + "response": '{"products": []}' # Missing other fields + } + + # Act + result = await service._analyze_with_llm("http://url", "Title", "Desc", "Content") + + # Assert + assert result["niche"] == "Not determined" + assert result["products"] == [] diff --git a/coaching/tests/unit/test_bedrock_provider.py b/coaching/tests/unit/test_bedrock_provider.py index cc263b18..7fcce448 100644 --- a/coaching/tests/unit/test_bedrock_provider.py +++ b/coaching/tests/unit/test_bedrock_provider.py @@ -3,6 +3,7 @@ from unittest.mock import Mock import pytest + from coaching.src.domain.ports.llm_provider_port import LLMMessage from coaching.src.infrastructure.llm.bedrock_provider import BedrockLLMProvider diff --git a/coaching/tests/unit/test_business_api_client.py b/coaching/tests/unit/test_business_api_client.py index 6b1b17bb..2fd90a44 100644 --- a/coaching/tests/unit/test_business_api_client.py +++ b/coaching/tests/unit/test_business_api_client.py @@ -1,387 +1,388 @@ -"""Unit tests for BusinessApiClient (Issue #48, refactored for MVP in #52).""" - -from unittest.mock import AsyncMock, Mock - -import httpx -import pytest -from coaching.src.infrastructure.external.business_api_client import BusinessApiClient - - -@pytest.mark.unit -class TestBusinessApiClientInitialization: - """Test BusinessApiClient initialization.""" - - def test_init_with_default_values(self): - """Test initialization with default values.""" - # Arrange & Act - client = BusinessApiClient(base_url="https://api.test.com", jwt_token="test-token") - - # Assert - assert client.base_url == "https://api.test.com" - assert client.jwt_token == "test-token" - assert client.timeout == 30 - assert client.max_retries == 3 - - def test_init_strips_trailing_slash(self): - """Test that trailing slash is removed from base_url.""" - # Arrange & Act - client = BusinessApiClient( - base_url="https://api.test.com/", - ) - - # Assert - assert client.base_url == "https://api.test.com" - - def test_init_with_custom_values(self): - """Test initialization with custom timeout and retries.""" - # Arrange & Act - client = BusinessApiClient( - base_url="https://api.test.com", - jwt_token="test-token", - timeout=60, - max_retries=5, - ) - - # Assert - assert client.timeout == 60 - assert client.max_retries == 5 - - -@pytest.mark.unit -class TestBusinessApiClientHeaders: - """Test HTTP header generation.""" - - def test_get_headers_without_token(self): - """Test headers without JWT token.""" - # Arrange - client = BusinessApiClient(base_url="https://api.test.com") - - # Act - headers = client._get_headers() - - # Assert - assert headers["Content-Type"] == "application/json" - assert headers["Accept"] == "application/json" - assert "Authorization" not in headers - - def test_get_headers_with_token(self): - """Test headers with JWT token.""" - # Arrange - client = BusinessApiClient(base_url="https://api.test.com", jwt_token="test-jwt-token") - - # Act - headers = client._get_headers() - - # Assert - assert headers["Content-Type"] == "application/json" - assert headers["Accept"] == "application/json" - assert headers["Authorization"] == "Bearer test-jwt-token" - - -@pytest.mark.unit -class TestBusinessApiClientUserContext: - """Test get_user_context method.""" - - @pytest.fixture - def mock_http_client(self): - """Create mock HTTP client.""" - mock_client = AsyncMock() - mock_response = Mock() - mock_response.status_code = 200 - mock_response.json.return_value = { - "data": { - "user_id": "user-123", - "email": "test@example.com", - "first_name": "Test", - "last_name": "User", - } - } - mock_client.get = AsyncMock(return_value=mock_response) - return mock_client - - @pytest.fixture - def business_client(self, mock_http_client): - """Create BusinessApiClient with mocked HTTP client.""" - client = BusinessApiClient(base_url="https://api.test.com", jwt_token="test-token") - client.client = mock_http_client - return client - - async def test_get_user_context_success(self, business_client, mock_http_client): - """Test successful user context retrieval.""" - # Arrange - user_id = "user-123" - tenant_id = "tenant-456" - - # Act - result = await business_client.get_user_context(user_id, tenant_id) - - # Assert - assert result["user_id"] == "user-123" - assert result["email"] == "test@example.com" - assert result["role"] == "Business Owner" # MVP fallback - mock_http_client.get.assert_called_once() - call_args = mock_http_client.get.call_args - assert "/user/profile" in str(call_args) - - async def test_get_user_context_http_error(self, business_client, mock_http_client): - """Test HTTP error handling.""" - # Arrange - mock_response = Mock() - mock_response.status_code = 404 - mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( - "Not found", request=Mock(), response=mock_response - ) - mock_http_client.get = AsyncMock(return_value=mock_response) - - # Act & Assert - with pytest.raises(httpx.HTTPStatusError): - await business_client.get_user_context("user-123", "tenant-456") - - async def test_get_user_context_request_error(self, business_client, mock_http_client): - """Test request error handling.""" - # Arrange - mock_http_client.get = AsyncMock( - side_effect=httpx.RequestError("Connection failed", request=Mock()) - ) - - # Act & Assert - with pytest.raises(httpx.RequestError): - await business_client.get_user_context("user-123", "tenant-456") - - -@pytest.mark.unit -class TestBusinessApiClientOrganizationalContext: - """Test get_organizational_context method.""" - - @pytest.fixture - def business_client(self): - """Create BusinessApiClient with mocked HTTP client.""" - client = BusinessApiClient(base_url="https://api.test.com", jwt_token="test-token") - mock_client = AsyncMock() - mock_response = Mock() - mock_response.status_code = 200 - mock_response.json.return_value = { - "data": { - "tenantId": "tenant-456", - "companyName": "Test Corp", - "industry": "Technology", - "values": ["Innovation", "Excellence"], - } - } - mock_client.get = AsyncMock(return_value=mock_response) - client.client = mock_client - return client - - async def test_get_organizational_context_success(self, business_client): - """Test successful organizational context retrieval.""" - # Arrange - tenant_id = "tenant-456" - - # Act - result = await business_client.get_organizational_context(tenant_id) - - # Assert - assert result["tenantId"] == "tenant-456" - assert result["companyName"] == "Test Corp" - assert "Innovation" in result["values"] - - async def test_get_organizational_context_with_headers(self, business_client): - """Test that proper headers are sent.""" - # Arrange - tenant_id = "tenant-456" - - # Act - await business_client.get_organizational_context(tenant_id) - - # Assert - call_args = business_client.client.get.call_args - headers = call_args.kwargs.get("headers", {}) - assert "Authorization" in headers - assert headers["Authorization"] == "Bearer test-token" - - -@pytest.mark.unit -class TestBusinessApiClientUserGoals: - """Test get_user_goals method.""" - - @pytest.fixture - def business_client(self): - """Create BusinessApiClient with mocked HTTP client.""" - client = BusinessApiClient(base_url="https://api.test.com", jwt_token="test-token") - mock_client = AsyncMock() - client.client = mock_client - return client - - async def test_get_user_goals_success(self, business_client): - """Test successful goals retrieval.""" - # Arrange - mock_response = Mock() - mock_response.status_code = 200 - mock_response.json.return_value = { - "data": [ - {"goalId": "goal-1", "title": "Increase revenue"}, - {"goalId": "goal-2", "title": "Improve efficiency"}, - ] - } - business_client.client.get = AsyncMock(return_value=mock_response) - - # Act - result = await business_client.get_user_goals("user-123", "tenant-456") - - # Assert - assert len(result) == 2 - assert result[0]["goalId"] == "goal-1" - assert result[1]["title"] == "Improve efficiency" - - async def test_get_user_goals_empty_list(self, business_client): - """Test handling of empty goals list.""" - # Arrange - mock_response = Mock() - mock_response.status_code = 200 - mock_response.json.return_value = {"data": []} - business_client.client.get = AsyncMock(return_value=mock_response) - - # Act - result = await business_client.get_user_goals("user-123", "tenant-456") - - # Assert - assert result == [] - assert isinstance(result, list) - - async def test_get_user_goals_non_list_response(self, business_client): - """Test handling of non-list response (returns empty list).""" - # Arrange - mock_response = Mock() - mock_response.status_code = 200 - mock_response.json.return_value = {"error": "Invalid format"} - business_client.client.get = AsyncMock(return_value=mock_response) - - # Act - result = await business_client.get_user_goals("user-123", "tenant-456") - - # Assert - assert result == [] - - -@pytest.mark.unit -@pytest.mark.skip(reason="get_metrics() removed - not in MVP scope (post-MVP feature)") -class TestBusinessApiClientMetrics: - """Test get_metrics method.""" - - @pytest.fixture - def business_client(self): - """Create BusinessApiClient with mocked HTTP client.""" - client = BusinessApiClient(base_url="https://api.test.com", jwt_token="test-token") - mock_client = AsyncMock() - client.client = mock_client - return client - - @pytest.mark.skip(reason="get_metrics() removed - not in MVP scope") - async def test_get_metrics_success(self, business_client): - """Test successful metrics retrieval.""" - # Arrange - mock_response = Mock() - mock_response.status_code = 200 - mock_response.json.return_value = { - "entityId": "user-123", - "metrics": {"revenue": 100000, "growth": 15.5}, - } - business_client.client.get = AsyncMock(return_value=mock_response) - - # Act - result = await business_client.get_metrics("user-123", "user", "tenant-456") - - # Assert - assert result["entityId"] == "user-123" - assert result["metrics"]["revenue"] == 100000 - - @pytest.mark.skip(reason="get_metrics() removed - not in MVP scope") - async def test_get_metrics_with_entity_types(self, business_client): - """Test metrics retrieval for different entity types.""" - # Arrange - mock_response = Mock() - mock_response.status_code = 200 - mock_response.json.return_value = {"data": "test"} - business_client.client.get = AsyncMock(return_value=mock_response) - - # Act - Test different entity types - await business_client.get_metrics("user-123", "user", "tenant-456") - await business_client.get_metrics("team-456", "team", "tenant-456") - await business_client.get_metrics("org-789", "org", "tenant-456") - - # Assert - Verify correct endpoints called - assert business_client.client.get.call_count == 3 - calls = [str(call) for call in business_client.client.get.call_args_list] - assert any("user-123" in call for call in calls) - assert any("team-456" in call for call in calls) - assert any("org-789" in call for call in calls) - - -@pytest.mark.unit -class TestBusinessApiClientClose: - """Test client cleanup.""" - - async def test_close_client(self): - """Test proper client cleanup.""" - # Arrange - client = BusinessApiClient(base_url="https://api.test.com", jwt_token="test-token") - client.client = AsyncMock() - - # Act - await client.close() - - # Assert - client.client.aclose.assert_called_once() - - -@pytest.mark.unit -class TestBusinessApiClientEdgeCases: - """Test edge cases and error scenarios.""" - - def test_empty_base_url(self): - """Test handling of empty base URL.""" - # Act & Assert - client = BusinessApiClient(base_url="") - assert client.base_url == "" - - def test_none_jwt_token(self): - """Test handling of None JWT token.""" - # Arrange & Act - client = BusinessApiClient(base_url="https://api.test.com", jwt_token=None) - - # Assert - headers = client._get_headers() - assert "Authorization" not in headers - - async def test_multiple_concurrent_requests(self): - """Test handling of concurrent requests.""" - # Arrange - client = BusinessApiClient(base_url="https://api.test.com", jwt_token="test-token") - mock_client = AsyncMock() - mock_response = Mock() - mock_response.status_code = 200 - mock_response.json.return_value = { - "data": { - "user_id": "user-1", - "email": "test@example.com", - "first_name": "Test", - "last_name": "User", - } - } - mock_client.get = AsyncMock(return_value=mock_response) - client.client = mock_client - - # Act - Multiple concurrent calls - import asyncio - - results = await asyncio.gather( - client.get_user_context("user-1", "tenant-1"), - client.get_user_context("user-2", "tenant-1"), - client.get_user_context("user-3", "tenant-1"), - ) - - # Assert - assert len(results) == 3 - assert all(r["user_id"] is not None for r in results) - assert all(r["role"] == "Business Owner" for r in results) - assert mock_client.get.call_count == 3 +"""Unit tests for BusinessApiClient (Issue #48, refactored for MVP in #52).""" + +from unittest.mock import AsyncMock, Mock + +import httpx +import pytest + +from coaching.src.infrastructure.external.business_api_client import BusinessApiClient + + +@pytest.mark.unit +class TestBusinessApiClientInitialization: + """Test BusinessApiClient initialization.""" + + def test_init_with_default_values(self): + """Test initialization with default values.""" + # Arrange & Act + client = BusinessApiClient(base_url="https://api.test.com", jwt_token="test-token") + + # Assert + assert client.base_url == "https://api.test.com" + assert client.jwt_token == "test-token" + assert client.timeout == 30 + assert client.max_retries == 3 + + def test_init_strips_trailing_slash(self): + """Test that trailing slash is removed from base_url.""" + # Arrange & Act + client = BusinessApiClient( + base_url="https://api.test.com/", + ) + + # Assert + assert client.base_url == "https://api.test.com" + + def test_init_with_custom_values(self): + """Test initialization with custom timeout and retries.""" + # Arrange & Act + client = BusinessApiClient( + base_url="https://api.test.com", + jwt_token="test-token", + timeout=60, + max_retries=5, + ) + + # Assert + assert client.timeout == 60 + assert client.max_retries == 5 + + +@pytest.mark.unit +class TestBusinessApiClientHeaders: + """Test HTTP header generation.""" + + def test_get_headers_without_token(self): + """Test headers without JWT token.""" + # Arrange + client = BusinessApiClient(base_url="https://api.test.com") + + # Act + headers = client._get_headers() + + # Assert + assert headers["Content-Type"] == "application/json" + assert headers["Accept"] == "application/json" + assert "Authorization" not in headers + + def test_get_headers_with_token(self): + """Test headers with JWT token.""" + # Arrange + client = BusinessApiClient(base_url="https://api.test.com", jwt_token="test-jwt-token") + + # Act + headers = client._get_headers() + + # Assert + assert headers["Content-Type"] == "application/json" + assert headers["Accept"] == "application/json" + assert headers["Authorization"] == "Bearer test-jwt-token" + + +@pytest.mark.unit +class TestBusinessApiClientUserContext: + """Test get_user_context method.""" + + @pytest.fixture + def mock_http_client(self): + """Create mock HTTP client.""" + mock_client = AsyncMock() + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "data": { + "user_id": "user-123", + "email": "test@example.com", + "first_name": "Test", + "last_name": "User", + } + } + mock_client.get = AsyncMock(return_value=mock_response) + return mock_client + + @pytest.fixture + def business_client(self, mock_http_client): + """Create BusinessApiClient with mocked HTTP client.""" + client = BusinessApiClient(base_url="https://api.test.com", jwt_token="test-token") + client.client = mock_http_client + return client + + async def test_get_user_context_success(self, business_client, mock_http_client): + """Test successful user context retrieval.""" + # Arrange + user_id = "user-123" + tenant_id = "tenant-456" + + # Act + result = await business_client.get_user_context(user_id, tenant_id) + + # Assert + assert result["user_id"] == "user-123" + assert result["email"] == "test@example.com" + assert result["role"] == "Business Owner" # MVP fallback + mock_http_client.get.assert_called_once() + call_args = mock_http_client.get.call_args + assert "/user/profile" in str(call_args) + + async def test_get_user_context_http_error(self, business_client, mock_http_client): + """Test HTTP error handling.""" + # Arrange + mock_response = Mock() + mock_response.status_code = 404 + mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( + "Not found", request=Mock(), response=mock_response + ) + mock_http_client.get = AsyncMock(return_value=mock_response) + + # Act & Assert + with pytest.raises(httpx.HTTPStatusError): + await business_client.get_user_context("user-123", "tenant-456") + + async def test_get_user_context_request_error(self, business_client, mock_http_client): + """Test request error handling.""" + # Arrange + mock_http_client.get = AsyncMock( + side_effect=httpx.RequestError("Connection failed", request=Mock()) + ) + + # Act & Assert + with pytest.raises(httpx.RequestError): + await business_client.get_user_context("user-123", "tenant-456") + + +@pytest.mark.unit +class TestBusinessApiClientOrganizationalContext: + """Test get_organizational_context method.""" + + @pytest.fixture + def business_client(self): + """Create BusinessApiClient with mocked HTTP client.""" + client = BusinessApiClient(base_url="https://api.test.com", jwt_token="test-token") + mock_client = AsyncMock() + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "data": { + "tenantId": "tenant-456", + "companyName": "Test Corp", + "industry": "Technology", + "values": ["Innovation", "Excellence"], + } + } + mock_client.get = AsyncMock(return_value=mock_response) + client.client = mock_client + return client + + async def test_get_organizational_context_success(self, business_client): + """Test successful organizational context retrieval.""" + # Arrange + tenant_id = "tenant-456" + + # Act + result = await business_client.get_organizational_context(tenant_id) + + # Assert + assert result["tenantId"] == "tenant-456" + assert result["companyName"] == "Test Corp" + assert "Innovation" in result["values"] + + async def test_get_organizational_context_with_headers(self, business_client): + """Test that proper headers are sent.""" + # Arrange + tenant_id = "tenant-456" + + # Act + await business_client.get_organizational_context(tenant_id) + + # Assert + call_args = business_client.client.get.call_args + headers = call_args.kwargs.get("headers", {}) + assert "Authorization" in headers + assert headers["Authorization"] == "Bearer test-token" + + +@pytest.mark.unit +class TestBusinessApiClientUserGoals: + """Test get_user_goals method.""" + + @pytest.fixture + def business_client(self): + """Create BusinessApiClient with mocked HTTP client.""" + client = BusinessApiClient(base_url="https://api.test.com", jwt_token="test-token") + mock_client = AsyncMock() + client.client = mock_client + return client + + async def test_get_user_goals_success(self, business_client): + """Test successful goals retrieval.""" + # Arrange + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "data": [ + {"goalId": "goal-1", "title": "Increase revenue"}, + {"goalId": "goal-2", "title": "Improve efficiency"}, + ] + } + business_client.client.get = AsyncMock(return_value=mock_response) + + # Act + result = await business_client.get_user_goals("user-123", "tenant-456") + + # Assert + assert len(result) == 2 + assert result[0]["goalId"] == "goal-1" + assert result[1]["title"] == "Improve efficiency" + + async def test_get_user_goals_empty_list(self, business_client): + """Test handling of empty goals list.""" + # Arrange + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = {"data": []} + business_client.client.get = AsyncMock(return_value=mock_response) + + # Act + result = await business_client.get_user_goals("user-123", "tenant-456") + + # Assert + assert result == [] + assert isinstance(result, list) + + async def test_get_user_goals_non_list_response(self, business_client): + """Test handling of non-list response (returns empty list).""" + # Arrange + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = {"error": "Invalid format"} + business_client.client.get = AsyncMock(return_value=mock_response) + + # Act + result = await business_client.get_user_goals("user-123", "tenant-456") + + # Assert + assert result == [] + + +@pytest.mark.unit +@pytest.mark.skip(reason="get_metrics() removed - not in MVP scope (post-MVP feature)") +class TestBusinessApiClientMetrics: + """Test get_metrics method.""" + + @pytest.fixture + def business_client(self): + """Create BusinessApiClient with mocked HTTP client.""" + client = BusinessApiClient(base_url="https://api.test.com", jwt_token="test-token") + mock_client = AsyncMock() + client.client = mock_client + return client + + @pytest.mark.skip(reason="get_metrics() removed - not in MVP scope") + async def test_get_metrics_success(self, business_client): + """Test successful metrics retrieval.""" + # Arrange + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "entityId": "user-123", + "metrics": {"revenue": 100000, "growth": 15.5}, + } + business_client.client.get = AsyncMock(return_value=mock_response) + + # Act + result = await business_client.get_metrics("user-123", "user", "tenant-456") + + # Assert + assert result["entityId"] == "user-123" + assert result["metrics"]["revenue"] == 100000 + + @pytest.mark.skip(reason="get_metrics() removed - not in MVP scope") + async def test_get_metrics_with_entity_types(self, business_client): + """Test metrics retrieval for different entity types.""" + # Arrange + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = {"data": "test"} + business_client.client.get = AsyncMock(return_value=mock_response) + + # Act - Test different entity types + await business_client.get_metrics("user-123", "user", "tenant-456") + await business_client.get_metrics("team-456", "team", "tenant-456") + await business_client.get_metrics("org-789", "org", "tenant-456") + + # Assert - Verify correct endpoints called + assert business_client.client.get.call_count == 3 + calls = [str(call) for call in business_client.client.get.call_args_list] + assert any("user-123" in call for call in calls) + assert any("team-456" in call for call in calls) + assert any("org-789" in call for call in calls) + + +@pytest.mark.unit +class TestBusinessApiClientClose: + """Test client cleanup.""" + + async def test_close_client(self): + """Test proper client cleanup.""" + # Arrange + client = BusinessApiClient(base_url="https://api.test.com", jwt_token="test-token") + client.client = AsyncMock() + + # Act + await client.close() + + # Assert + client.client.aclose.assert_called_once() + + +@pytest.mark.unit +class TestBusinessApiClientEdgeCases: + """Test edge cases and error scenarios.""" + + def test_empty_base_url(self): + """Test handling of empty base URL.""" + # Act & Assert + client = BusinessApiClient(base_url="") + assert client.base_url == "" + + def test_none_jwt_token(self): + """Test handling of None JWT token.""" + # Arrange & Act + client = BusinessApiClient(base_url="https://api.test.com", jwt_token=None) + + # Assert + headers = client._get_headers() + assert "Authorization" not in headers + + async def test_multiple_concurrent_requests(self): + """Test handling of concurrent requests.""" + # Arrange + client = BusinessApiClient(base_url="https://api.test.com", jwt_token="test-token") + mock_client = AsyncMock() + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "data": { + "user_id": "user-1", + "email": "test@example.com", + "first_name": "Test", + "last_name": "User", + } + } + mock_client.get = AsyncMock(return_value=mock_response) + client.client = mock_client + + # Act - Multiple concurrent calls + import asyncio + + results = await asyncio.gather( + client.get_user_context("user-1", "tenant-1"), + client.get_user_context("user-2", "tenant-1"), + client.get_user_context("user-3", "tenant-1"), + ) + + # Assert + assert len(results) == 3 + assert all(r["user_id"] is not None for r in results) + assert all(r["role"] == "Business Owner" for r in results) + assert mock_client.get.call_count == 3 diff --git a/coaching/tests/unit/test_constants.py b/coaching/tests/unit/test_constants.py index 71fdaf05..03077e11 100644 --- a/coaching/tests/unit/test_constants.py +++ b/coaching/tests/unit/test_constants.py @@ -1,6 +1,7 @@ """Unit tests for core constants and enums.""" import pytest + from coaching.src.core.constants import ( AnalysisType, CoachingTopic, diff --git a/coaching/tests/unit/test_exceptions.py b/coaching/tests/unit/test_exceptions.py index edfe711c..72cf4fdd 100644 --- a/coaching/tests/unit/test_exceptions.py +++ b/coaching/tests/unit/test_exceptions.py @@ -1,116 +1,117 @@ -"""Unit tests for custom exceptions.""" - -import pytest -from coaching.src.core.exceptions import ( - ConversationNotFoundCompatError, - ConversationNotFoundError, - InvalidTopicError, -) - - -@pytest.mark.unit -class TestConversationNotFoundError: - """Test ConversationNotFoundError exception.""" - - def test_exception_message(self) -> None: - """Test exception message formatting.""" - # Arrange - conversation_id = "conv-123" - - # Act - error = ConversationNotFoundError(conversation_id) - - # Assert - assert conversation_id in str(error) - assert "not found" in str(error).lower() - - def test_exception_can_be_raised(self) -> None: - """Test that exception can be raised and caught.""" - # Arrange - conversation_id = "conv-456" - - # Act & Assert - with pytest.raises(ConversationNotFoundError) as exc_info: - raise ConversationNotFoundError(conversation_id) - - assert conversation_id in str(exc_info.value) - - def test_exception_inherits_from_exception(self) -> None: - """Test that custom exception inherits from Exception.""" - # Arrange & Act - error = ConversationNotFoundError("test") - - # Assert - assert isinstance(error, Exception) - - -@pytest.mark.unit -class TestConversationNotFoundCompatError: - """Test ConversationNotFoundCompatError exception.""" - - def test_compat_exception_message(self) -> None: - """Test compat exception message formatting.""" - # Arrange - conversation_id = "conv-789" - - # Act - error = ConversationNotFoundCompatError(conversation_id) - - # Assert - assert conversation_id in str(error) - - def test_compat_exception_can_be_raised(self) -> None: - """Test that compat exception can be raised.""" - # Arrange - conversation_id = "conv-compat" - - # Act & Assert - with pytest.raises(ConversationNotFoundCompatError): - raise ConversationNotFoundCompatError(conversation_id) - - -@pytest.mark.unit -class TestInvalidTopicError: - """Test InvalidTopicError exception.""" - - def test_invalid_topic_error_message(self) -> None: - """Test invalid topic error message.""" - # Arrange - topic = "invalid_topic" - - # Act - error = InvalidTopicError(topic) - - # Assert - assert topic in str(error) - assert "invalid" in str(error).lower() or "topic" in str(error).lower() - - def test_invalid_topic_error_can_be_raised(self) -> None: - """Test that invalid topic error can be raised.""" - # Arrange - topic = "bad_topic" - - # Act & Assert - with pytest.raises(InvalidTopicError): - raise InvalidTopicError(topic) - - -@pytest.mark.unit -class TestExceptionHierarchy: - """Test exception hierarchy and relationships.""" - - def test_all_exceptions_inherit_from_exception(self) -> None: - """Test that all custom exceptions inherit from Exception.""" - # Act & Assert - assert issubclass(ConversationNotFoundError, Exception) - assert issubclass(ConversationNotFoundCompatError, Exception) - assert issubclass(InvalidTopicError, Exception) - - def test_exceptions_can_be_caught_as_exception(self) -> None: - """Test that custom exceptions can be caught as generic Exception.""" - # Arrange & Act & Assert - with pytest.raises(Exception): - raise ConversationNotFoundError("test") - - with pytest.raises(Exception): - raise InvalidTopicError("test") +"""Unit tests for custom exceptions.""" + +import pytest + +from coaching.src.core.exceptions import ( + ConversationNotFoundCompatError, + ConversationNotFoundError, + InvalidTopicError, +) + + +@pytest.mark.unit +class TestConversationNotFoundError: + """Test ConversationNotFoundError exception.""" + + def test_exception_message(self) -> None: + """Test exception message formatting.""" + # Arrange + conversation_id = "conv-123" + + # Act + error = ConversationNotFoundError(conversation_id) + + # Assert + assert conversation_id in str(error) + assert "not found" in str(error).lower() + + def test_exception_can_be_raised(self) -> None: + """Test that exception can be raised and caught.""" + # Arrange + conversation_id = "conv-456" + + # Act & Assert + with pytest.raises(ConversationNotFoundError) as exc_info: + raise ConversationNotFoundError(conversation_id) + + assert conversation_id in str(exc_info.value) + + def test_exception_inherits_from_exception(self) -> None: + """Test that custom exception inherits from Exception.""" + # Arrange & Act + error = ConversationNotFoundError("test") + + # Assert + assert isinstance(error, Exception) + + +@pytest.mark.unit +class TestConversationNotFoundCompatError: + """Test ConversationNotFoundCompatError exception.""" + + def test_compat_exception_message(self) -> None: + """Test compat exception message formatting.""" + # Arrange + conversation_id = "conv-789" + + # Act + error = ConversationNotFoundCompatError(conversation_id) + + # Assert + assert conversation_id in str(error) + + def test_compat_exception_can_be_raised(self) -> None: + """Test that compat exception can be raised.""" + # Arrange + conversation_id = "conv-compat" + + # Act & Assert + with pytest.raises(ConversationNotFoundCompatError): + raise ConversationNotFoundCompatError(conversation_id) + + +@pytest.mark.unit +class TestInvalidTopicError: + """Test InvalidTopicError exception.""" + + def test_invalid_topic_error_message(self) -> None: + """Test invalid topic error message.""" + # Arrange + topic = "invalid_topic" + + # Act + error = InvalidTopicError(topic) + + # Assert + assert topic in str(error) + assert "invalid" in str(error).lower() or "topic" in str(error).lower() + + def test_invalid_topic_error_can_be_raised(self) -> None: + """Test that invalid topic error can be raised.""" + # Arrange + topic = "bad_topic" + + # Act & Assert + with pytest.raises(InvalidTopicError): + raise InvalidTopicError(topic) + + +@pytest.mark.unit +class TestExceptionHierarchy: + """Test exception hierarchy and relationships.""" + + def test_all_exceptions_inherit_from_exception(self) -> None: + """Test that all custom exceptions inherit from Exception.""" + # Act & Assert + assert issubclass(ConversationNotFoundError, Exception) + assert issubclass(ConversationNotFoundCompatError, Exception) + assert issubclass(InvalidTopicError, Exception) + + def test_exceptions_can_be_caught_as_exception(self) -> None: + """Test that custom exceptions can be caught as generic Exception.""" + # Arrange & Act & Assert + with pytest.raises(Exception): + raise ConversationNotFoundError("test") + + with pytest.raises(Exception): + raise InvalidTopicError("test") diff --git a/coaching/tests/unit/test_insights_service.py b/coaching/tests/unit/test_insights_service.py index 372b0235..b14a7890 100644 --- a/coaching/tests/unit/test_insights_service.py +++ b/coaching/tests/unit/test_insights_service.py @@ -2,6 +2,7 @@ from unittest.mock import AsyncMock, MagicMock import pytest + from coaching.src.infrastructure.external.business_api_client import BusinessApiClient from coaching.src.infrastructure.repositories.dynamodb_conversation_repository import ( DynamoDBConversationRepository, diff --git a/coaching/tests/unit/test_model_pricing.py b/coaching/tests/unit/test_model_pricing.py index 3d4593d1..5795a6a9 100644 --- a/coaching/tests/unit/test_model_pricing.py +++ b/coaching/tests/unit/test_model_pricing.py @@ -1,6 +1,7 @@ """Unit tests for model pricing calculations.""" import pytest + from coaching.src.infrastructure.llm.model_pricing import ( MODEL_PRICING, calculate_cost, diff --git a/coaching/tests/unit/test_models.py b/coaching/tests/unit/test_models.py index b6120fa7..3d34c395 100644 --- a/coaching/tests/unit/test_models.py +++ b/coaching/tests/unit/test_models.py @@ -1,184 +1,185 @@ -"""Unit tests for data models.""" - -from datetime import datetime - -import pytest -from coaching.src.core.constants import ( - CoachingTopic, - ConversationPhase, - ConversationStatus, - MessageRole, -) -from coaching.src.models.conversation import Conversation, ConversationContext, Message -from coaching.src.models.requests import InitiateConversationRequest, MessageRequest -from coaching.src.models.responses import ConversationResponse, MessageResponse - - -class TestConversationModels: - """Test conversation model classes.""" - - def test_message_creation(self) -> None: - """Test Message model creation.""" - message = Message(role=MessageRole.USER, content="Test message", metadata={"test": "value"}) - - assert message.role == MessageRole.USER - assert message.content == "Test message" - assert message.metadata == {"test": "value"} - assert isinstance(message.timestamp, datetime) - - def test_conversation_context_creation(self) -> None: - """Test ConversationContext model creation.""" - context = ConversationContext( - identified_values=["growth", "autonomy"], - key_insights=["Values-driven", "Seeks independence"], - ) - - assert context.identified_values == ["growth", "autonomy"] - assert context.key_insights == ["Values-driven", "Seeks independence"] - assert context.response_count == 0 - - def test_coaching_session_creation(self) -> None: - """Test Conversation model creation.""" - conversation = Conversation( - conversation_id="test-123", user_id="user-456", topic="core_values" - ) - - assert conversation.conversation_id == "test-123" - assert conversation.user_id == "user-456" - assert conversation.topic == "core_values" - assert conversation.status == ConversationStatus.ACTIVE - assert len(conversation.messages) == 0 - assert isinstance(conversation.created_at, datetime) - - def test_conversation_add_message(self) -> None: - """Test adding messages to conversation.""" - conversation = Conversation( - conversation_id="test-123", user_id="user-456", topic="core_values" - ) - - # Add user message - conversation.add_message(MessageRole.USER, "Hello", {"source": "test"}) - - assert len(conversation.messages) == 1 - assert conversation.messages[0].role == MessageRole.USER - assert conversation.messages[0].content == "Hello" - assert conversation.messages[0].metadata == {"source": "test"} - assert conversation.context.response_count == 1 - - def test_conversation_progress_calculation(self) -> None: - """Test conversation progress calculation.""" - conversation = Conversation( - conversation_id="test-123", user_id="user-456", topic="core_values" - ) - - # Initial progress - assert conversation.calculate_progress() == 0.0 - - # Add some messages - for _ in range(6): - conversation.add_message(MessageRole.USER, "test") - conversation.add_message(MessageRole.ASSISTANT, "response") - - # 12 messages total -> 1.0 progress - assert conversation.calculate_progress() == 1.0 - - def test_conversation_status_changes(self) -> None: - """Test conversation status management.""" - conversation = Conversation( - conversation_id="test-123", user_id="user-456", topic="core_values" - ) - - # Initial state - assert conversation.is_active() - assert conversation.completed_at is None - - # Mark completed - conversation.mark_completed() - assert conversation.status == ConversationStatus.COMPLETED - assert not conversation.is_active() - assert conversation.completed_at is not None - - # Test pause/resume - conversation.status = ConversationStatus.ACTIVE # Reset - conversation.mark_paused() - assert conversation.status == ConversationStatus.PAUSED - assert conversation.paused_at is not None - - conversation.resume() - assert conversation.status == ConversationStatus.ACTIVE - - -class TestRequestModels: - """Test request model classes.""" - - def test_initiate_conversation_request(self) -> None: - """Test InitiateConversationRequest validation.""" - request = InitiateConversationRequest( - user_id="test-user", - topic=CoachingTopic.CORE_VALUES, - context={"preference": "detailed"}, - language="en", - ) - - assert request.user_id == "test-user" - assert request.topic == CoachingTopic.CORE_VALUES - assert request.context == {"preference": "detailed"} - assert request.language == "en" - - def test_initiate_conversation_request_validation(self) -> None: - """Test request validation.""" - # Empty user_id should be rejected - with pytest.raises(ValueError): - InitiateConversationRequest(user_id=" ", topic=CoachingTopic.CORE_VALUES) - - def test_message_request(self) -> None: - """Test MessageRequest validation.""" - request = MessageRequest( - user_message="I value growth and learning", metadata={"session": "1"} - ) - - assert request.user_message == "I value growth and learning" - assert request.metadata == {"session": "1"} - - def test_message_request_validation(self) -> None: - """Test message request validation.""" - # Empty message should be rejected - with pytest.raises(ValueError): - MessageRequest(user_message=" ") - - -class TestResponseModels: - """Test response model classes.""" - - def test_conversation_response(self) -> None: - """Test ConversationResponse model.""" - response = ConversationResponse( - conversation_id="test-123", - status=ConversationStatus.ACTIVE, - current_question="What energizes you?", - progress=0.3, - phase=ConversationPhase.EXPLORATION, - ) - - assert response.conversation_id == "test-123" - assert response.status == ConversationStatus.ACTIVE - assert response.progress == 0.3 - assert response.phase == ConversationPhase.EXPLORATION - - def test_message_response(self) -> None: - """Test MessageResponse model.""" - response = MessageResponse( - ai_response="That's interesting! Tell me more.", - follow_up_question="Can you give an example?", - insights=["Shows growth orientation"], - progress=0.4, - is_complete=False, - phase=ConversationPhase.EXPLORATION, - ) - - assert response.ai_response == "That's interesting! Tell me more." - assert response.follow_up_question == "Can you give an example?" - assert response.insights == ["Shows growth orientation"] - assert response.progress == 0.4 - assert not response.is_complete - assert response.phase == ConversationPhase.EXPLORATION +"""Unit tests for data models.""" + +from datetime import datetime + +import pytest + +from coaching.src.core.constants import ( + CoachingTopic, + ConversationPhase, + ConversationStatus, + MessageRole, +) +from coaching.src.models.conversation import Conversation, ConversationContext, Message +from coaching.src.models.requests import InitiateConversationRequest, MessageRequest +from coaching.src.models.responses import ConversationResponse, MessageResponse + + +class TestConversationModels: + """Test conversation model classes.""" + + def test_message_creation(self) -> None: + """Test Message model creation.""" + message = Message(role=MessageRole.USER, content="Test message", metadata={"test": "value"}) + + assert message.role == MessageRole.USER + assert message.content == "Test message" + assert message.metadata == {"test": "value"} + assert isinstance(message.timestamp, datetime) + + def test_conversation_context_creation(self) -> None: + """Test ConversationContext model creation.""" + context = ConversationContext( + identified_values=["growth", "autonomy"], + key_insights=["Values-driven", "Seeks independence"], + ) + + assert context.identified_values == ["growth", "autonomy"] + assert context.key_insights == ["Values-driven", "Seeks independence"] + assert context.response_count == 0 + + def test_coaching_session_creation(self) -> None: + """Test Conversation model creation.""" + conversation = Conversation( + conversation_id="test-123", user_id="user-456", topic="core_values" + ) + + assert conversation.conversation_id == "test-123" + assert conversation.user_id == "user-456" + assert conversation.topic == "core_values" + assert conversation.status == ConversationStatus.ACTIVE + assert len(conversation.messages) == 0 + assert isinstance(conversation.created_at, datetime) + + def test_conversation_add_message(self) -> None: + """Test adding messages to conversation.""" + conversation = Conversation( + conversation_id="test-123", user_id="user-456", topic="core_values" + ) + + # Add user message + conversation.add_message(MessageRole.USER, "Hello", {"source": "test"}) + + assert len(conversation.messages) == 1 + assert conversation.messages[0].role == MessageRole.USER + assert conversation.messages[0].content == "Hello" + assert conversation.messages[0].metadata == {"source": "test"} + assert conversation.context.response_count == 1 + + def test_conversation_progress_calculation(self) -> None: + """Test conversation progress calculation.""" + conversation = Conversation( + conversation_id="test-123", user_id="user-456", topic="core_values" + ) + + # Initial progress + assert conversation.calculate_progress() == 0.0 + + # Add some messages + for _ in range(6): + conversation.add_message(MessageRole.USER, "test") + conversation.add_message(MessageRole.ASSISTANT, "response") + + # 12 messages total -> 1.0 progress + assert conversation.calculate_progress() == 1.0 + + def test_conversation_status_changes(self) -> None: + """Test conversation status management.""" + conversation = Conversation( + conversation_id="test-123", user_id="user-456", topic="core_values" + ) + + # Initial state + assert conversation.is_active() + assert conversation.completed_at is None + + # Mark completed + conversation.mark_completed() + assert conversation.status == ConversationStatus.COMPLETED + assert not conversation.is_active() + assert conversation.completed_at is not None + + # Test pause/resume + conversation.status = ConversationStatus.ACTIVE # Reset + conversation.mark_paused() + assert conversation.status == ConversationStatus.PAUSED + assert conversation.paused_at is not None + + conversation.resume() + assert conversation.status == ConversationStatus.ACTIVE + + +class TestRequestModels: + """Test request model classes.""" + + def test_initiate_conversation_request(self) -> None: + """Test InitiateConversationRequest validation.""" + request = InitiateConversationRequest( + user_id="test-user", + topic=CoachingTopic.CORE_VALUES, + context={"preference": "detailed"}, + language="en", + ) + + assert request.user_id == "test-user" + assert request.topic == CoachingTopic.CORE_VALUES + assert request.context == {"preference": "detailed"} + assert request.language == "en" + + def test_initiate_conversation_request_validation(self) -> None: + """Test request validation.""" + # Empty user_id should be rejected + with pytest.raises(ValueError): + InitiateConversationRequest(user_id=" ", topic=CoachingTopic.CORE_VALUES) + + def test_message_request(self) -> None: + """Test MessageRequest validation.""" + request = MessageRequest( + user_message="I value growth and learning", metadata={"session": "1"} + ) + + assert request.user_message == "I value growth and learning" + assert request.metadata == {"session": "1"} + + def test_message_request_validation(self) -> None: + """Test message request validation.""" + # Empty message should be rejected + with pytest.raises(ValueError): + MessageRequest(user_message=" ") + + +class TestResponseModels: + """Test response model classes.""" + + def test_conversation_response(self) -> None: + """Test ConversationResponse model.""" + response = ConversationResponse( + conversation_id="test-123", + status=ConversationStatus.ACTIVE, + current_question="What energizes you?", + progress=0.3, + phase=ConversationPhase.EXPLORATION, + ) + + assert response.conversation_id == "test-123" + assert response.status == ConversationStatus.ACTIVE + assert response.progress == 0.3 + assert response.phase == ConversationPhase.EXPLORATION + + def test_message_response(self) -> None: + """Test MessageResponse model.""" + response = MessageResponse( + ai_response="That's interesting! Tell me more.", + follow_up_question="Can you give an example?", + insights=["Shows growth orientation"], + progress=0.4, + is_complete=False, + phase=ConversationPhase.EXPLORATION, + ) + + assert response.ai_response == "That's interesting! Tell me more." + assert response.follow_up_question == "Can you give an example?" + assert response.insights == ["Shows growth orientation"] + assert response.progress == 0.4 + assert not response.is_complete + assert response.phase == ConversationPhase.EXPLORATION diff --git a/coaching/tests/unit/test_onboarding_models.py b/coaching/tests/unit/test_onboarding_models.py index 63b38462..e9a5e991 100644 --- a/coaching/tests/unit/test_onboarding_models.py +++ b/coaching/tests/unit/test_onboarding_models.py @@ -1,6 +1,8 @@ """Unit tests for Onboarding Pydantic models (Issue #48).""" import pytest +from pydantic import ValidationError + from coaching.src.api.models.onboarding import ( OnboardingCoachingRequest, OnboardingCoachingResponse, @@ -14,7 +16,6 @@ WebsiteScanTargetMarket, WebsiteScanValueProposition, ) -from pydantic import ValidationError @pytest.mark.unit diff --git a/coaching/tests/unit/test_onboarding_service.py b/coaching/tests/unit/test_onboarding_service.py index 65b7f6f0..2144ab0c 100644 --- a/coaching/tests/unit/test_onboarding_service.py +++ b/coaching/tests/unit/test_onboarding_service.py @@ -1,205 +1,206 @@ -"""Unit tests for OnboardingService (Issue #37 - Sample Test).""" - -from unittest.mock import AsyncMock - -import pytest -from coaching.src.services.onboarding_service import OnboardingService - - -@pytest.mark.unit -class TestOnboardingService: - """Test onboarding service business logic.""" - - @pytest.fixture - def mock_llm_service(self): - """Create mock LLM service.""" - service = AsyncMock() - service.generate_single_shot_analysis = AsyncMock( - return_value={"response": "1. Suggestion one\n2. Suggestion two\n3. Suggestion three"} - ) - return service - - @pytest.fixture - def onboarding_service(self, mock_llm_service): - """Create onboarding service with mocked dependencies.""" - return OnboardingService(llm_service=mock_llm_service) - - async def test_get_suggestions_niche_success(self, onboarding_service, mock_llm_service): - """Test successful niche suggestion generation.""" - # Arrange - kind = "niche" - context = { - "businessName": "TechCorp", - "industry": "Software", - "products": ["CRM", "Analytics"], - } - - # Act - result = await onboarding_service.get_suggestions( - kind=kind, - context=context, - ) - - # Assert - assert "suggestions" in result - assert "reasoning" in result - assert isinstance(result["suggestions"], list) - assert len(result["suggestions"]) > 0 - mock_llm_service.generate_single_shot_analysis.assert_called_once() - - async def test_get_suggestions_with_current_draft(self, onboarding_service): - """Test suggestions generation with existing draft.""" - # Arrange - kind = "valueProposition" - current = "We help businesses grow" - context = {"businessName": "GrowthCo"} - - # Act - result = await onboarding_service.get_suggestions( - kind=kind, - current=current, - context=context, - ) - - # Assert - assert result["suggestions"] - assert result["reasoning"] - - async def test_get_coaching_core_values(self, onboarding_service, mock_llm_service): - """Test coaching for core values topic.""" - # Arrange - mock_llm_service.generate_single_shot_analysis.return_value = { - "response": 'Consider "Integrity" and "Innovation" as core values for your business.' - } - topic = "coreValues" - message = "How do I define core values?" - context = {"businessName": "ValueCo"} - - # Act - result = await onboarding_service.get_coaching( - topic=topic, - message=message, - context=context, - ) - - # Assert - assert "response" in result - assert "suggestions" in result - assert result["response"] - assert isinstance(result["suggestions"], list) - - async def test_scan_website_success(self, onboarding_service, mock_llm_service): - """Test successful website scanning.""" - # Arrange - url = "https://example.com" - mock_analysis_result = { - "products": [ - {"id": "product-1", "name": "Test Product", "problem": "Solves test problem"} - ], - "niche": "Test niche description", - "ica": "Test ideal customer", - "value_proposition": "Test value proposition", - } - - # Mock the website_analysis_service.analyze_website method - onboarding_service.website_analysis_service.analyze_website = AsyncMock( - return_value=mock_analysis_result - ) - - # Act - result = await onboarding_service.scan_website(url) - - # Assert - assert "businessName" in result - assert "industry" in result - assert "description" in result - assert "products" in result - assert "targetMarket" in result - assert "suggestedNiche" in result - assert result["businessName"] == "Example" - assert result["products"] == ["Test Product"] - assert result["description"] == "Test value proposition" - assert result["targetMarket"] == "Test ideal customer" - assert result["suggestedNiche"] == "Test niche description" - - async def test_parse_suggestions_from_response(self, onboarding_service): - """Test internal suggestion parsing logic.""" - # Arrange - response = """ - 1. First suggestion text here - 2. Second suggestion with more details - 3. Third comprehensive suggestion - """ - - # Act - suggestions = onboarding_service._parse_suggestions(response) - - # Assert - assert len(suggestions) > 0 - assert all(isinstance(s, str) for s in suggestions) - assert all(len(s) > 20 for s in suggestions) # Minimum length check - - async def test_extract_suggestions_from_coaching_with_quotes(self, onboarding_service): - """Test extracting suggestions from coaching response with quoted text.""" - # Arrange - response = 'Consider "Excellence" and "Customer Focus" as values.' - topic = "coreValues" - - # Act - suggestions = onboarding_service._extract_suggestions_from_coaching(response, topic) - - # Assert - assert "Excellence" in suggestions - assert "Customer Focus" in suggestions - - async def test_get_suggestions_empty_context(self, onboarding_service): - """Test suggestions generation with minimal context.""" - # Arrange - kind = "ica" - - # Act - result = await onboarding_service.get_suggestions(kind=kind, context={}) - - # Assert - assert result["suggestions"] - assert result["reasoning"] - # Should handle empty context gracefully - - -@pytest.mark.unit -class TestOnboardingServiceEdgeCases: - """Test edge cases and error scenarios.""" - - @pytest.fixture - def onboarding_service(self): - """Create service with mocked LLM.""" - mock_llm = AsyncMock() - mock_llm.generate_single_shot_analysis = AsyncMock(return_value={"response": ""}) - return OnboardingService(llm_service=mock_llm) - - async def test_empty_llm_response_has_fallback(self, onboarding_service): - """Test that empty LLM response provides fallback.""" - # Arrange - kind = "niche" - - # Act - result = await onboarding_service.get_suggestions(kind=kind) - - # Assert - assert result["suggestions"] - assert len(result["suggestions"]) > 0 - # Should provide fallback message - - async def test_coaching_with_empty_message(self, onboarding_service): - """Test coaching handles edge cases gracefully.""" - # This test documents expected behavior - # In production, validation should catch this at API layer - topic = "purpose" - message = "" - - # Act - result = await onboarding_service.get_coaching(topic=topic, message=message) - - # Assert - assert "response" in result - # Service should handle gracefully even with empty message +"""Unit tests for OnboardingService (Issue #37 - Sample Test).""" + +from unittest.mock import AsyncMock + +import pytest + +from coaching.src.services.onboarding_service import OnboardingService + + +@pytest.mark.unit +class TestOnboardingService: + """Test onboarding service business logic.""" + + @pytest.fixture + def mock_llm_service(self): + """Create mock LLM service.""" + service = AsyncMock() + service.generate_single_shot_analysis = AsyncMock( + return_value={"response": "1. Suggestion one\n2. Suggestion two\n3. Suggestion three"} + ) + return service + + @pytest.fixture + def onboarding_service(self, mock_llm_service): + """Create onboarding service with mocked dependencies.""" + return OnboardingService(llm_service=mock_llm_service) + + async def test_get_suggestions_niche_success(self, onboarding_service, mock_llm_service): + """Test successful niche suggestion generation.""" + # Arrange + kind = "niche" + context = { + "businessName": "TechCorp", + "industry": "Software", + "products": ["CRM", "Analytics"], + } + + # Act + result = await onboarding_service.get_suggestions( + kind=kind, + context=context, + ) + + # Assert + assert "suggestions" in result + assert "reasoning" in result + assert isinstance(result["suggestions"], list) + assert len(result["suggestions"]) > 0 + mock_llm_service.generate_single_shot_analysis.assert_called_once() + + async def test_get_suggestions_with_current_draft(self, onboarding_service): + """Test suggestions generation with existing draft.""" + # Arrange + kind = "valueProposition" + current = "We help businesses grow" + context = {"businessName": "GrowthCo"} + + # Act + result = await onboarding_service.get_suggestions( + kind=kind, + current=current, + context=context, + ) + + # Assert + assert result["suggestions"] + assert result["reasoning"] + + async def test_get_coaching_core_values(self, onboarding_service, mock_llm_service): + """Test coaching for core values topic.""" + # Arrange + mock_llm_service.generate_single_shot_analysis.return_value = { + "response": 'Consider "Integrity" and "Innovation" as core values for your business.' + } + topic = "coreValues" + message = "How do I define core values?" + context = {"businessName": "ValueCo"} + + # Act + result = await onboarding_service.get_coaching( + topic=topic, + message=message, + context=context, + ) + + # Assert + assert "response" in result + assert "suggestions" in result + assert result["response"] + assert isinstance(result["suggestions"], list) + + async def test_scan_website_success(self, onboarding_service, mock_llm_service): + """Test successful website scanning.""" + # Arrange + url = "https://example.com" + mock_analysis_result = { + "products": [ + {"id": "product-1", "name": "Test Product", "problem": "Solves test problem"} + ], + "niche": "Test niche description", + "ica": "Test ideal customer", + "value_proposition": "Test value proposition", + } + + # Mock the website_analysis_service.analyze_website method + onboarding_service.website_analysis_service.analyze_website = AsyncMock( + return_value=mock_analysis_result + ) + + # Act + result = await onboarding_service.scan_website(url) + + # Assert + assert "businessName" in result + assert "industry" in result + assert "description" in result + assert "products" in result + assert "targetMarket" in result + assert "suggestedNiche" in result + assert result["businessName"] == "Example" + assert result["products"] == ["Test Product"] + assert result["description"] == "Test value proposition" + assert result["targetMarket"] == "Test ideal customer" + assert result["suggestedNiche"] == "Test niche description" + + async def test_parse_suggestions_from_response(self, onboarding_service): + """Test internal suggestion parsing logic.""" + # Arrange + response = """ + 1. First suggestion text here + 2. Second suggestion with more details + 3. Third comprehensive suggestion + """ + + # Act + suggestions = onboarding_service._parse_suggestions(response) + + # Assert + assert len(suggestions) > 0 + assert all(isinstance(s, str) for s in suggestions) + assert all(len(s) > 20 for s in suggestions) # Minimum length check + + async def test_extract_suggestions_from_coaching_with_quotes(self, onboarding_service): + """Test extracting suggestions from coaching response with quoted text.""" + # Arrange + response = 'Consider "Excellence" and "Customer Focus" as values.' + topic = "coreValues" + + # Act + suggestions = onboarding_service._extract_suggestions_from_coaching(response, topic) + + # Assert + assert "Excellence" in suggestions + assert "Customer Focus" in suggestions + + async def test_get_suggestions_empty_context(self, onboarding_service): + """Test suggestions generation with minimal context.""" + # Arrange + kind = "ica" + + # Act + result = await onboarding_service.get_suggestions(kind=kind, context={}) + + # Assert + assert result["suggestions"] + assert result["reasoning"] + # Should handle empty context gracefully + + +@pytest.mark.unit +class TestOnboardingServiceEdgeCases: + """Test edge cases and error scenarios.""" + + @pytest.fixture + def onboarding_service(self): + """Create service with mocked LLM.""" + mock_llm = AsyncMock() + mock_llm.generate_single_shot_analysis = AsyncMock(return_value={"response": ""}) + return OnboardingService(llm_service=mock_llm) + + async def test_empty_llm_response_has_fallback(self, onboarding_service): + """Test that empty LLM response provides fallback.""" + # Arrange + kind = "niche" + + # Act + result = await onboarding_service.get_suggestions(kind=kind) + + # Assert + assert result["suggestions"] + assert len(result["suggestions"]) > 0 + # Should provide fallback message + + async def test_coaching_with_empty_message(self, onboarding_service): + """Test coaching handles edge cases gracefully.""" + # This test documents expected behavior + # In production, validation should catch this at API layer + topic = "purpose" + message = "" + + # Act + result = await onboarding_service.get_coaching(topic=topic, message=message) + + # Assert + assert "response" in result + # Service should handle gracefully even with empty message diff --git a/coaching/tests/unit/test_response_models.py b/coaching/tests/unit/test_response_models.py index ce499b00..a0851932 100644 --- a/coaching/tests/unit/test_response_models.py +++ b/coaching/tests/unit/test_response_models.py @@ -1,224 +1,225 @@ -"""Unit tests for response models.""" - -from datetime import datetime - -import pytest -from coaching.src.core.constants import ConversationPhase, ConversationStatus -from coaching.src.models.responses import ( - ConversationListResponse, - ConversationResponse, - ConversationSummary, - MessageResponse, -) - - -@pytest.mark.unit -class TestConversationResponse: - """Test ConversationResponse model.""" - - def test_valid_conversation_response(self): - """Test creating valid conversation response.""" - # Arrange & Act - response = ConversationResponse( - conversation_id="conv-123", - status=ConversationStatus.ACTIVE, - current_question="How can I help you?", - progress=0.25, - phase=ConversationPhase.INTRODUCTION, - ) - - # Assert - assert response.conversation_id == "conv-123" - assert response.status == ConversationStatus.ACTIVE - assert response.current_question == "How can I help you?" - assert response.progress == 0.25 - assert response.phase == ConversationPhase.INTRODUCTION - - def test_conversation_response_with_metadata(self): - """Test conversation response with optional metadata.""" - # Arrange & Act - response = ConversationResponse( - conversation_id="conv-456", - status=ConversationStatus.PAUSED, - current_question="Let's continue...", - progress=0.50, - phase=ConversationPhase.DEEPENING, - ) - - # Assert - assert response.progress == 0.50 - assert response.status == ConversationStatus.PAUSED - - -@pytest.mark.unit -class TestMessageResponse: - """Test MessageResponse model.""" - - def test_valid_message_response(self): - """Test creating valid message response.""" - # Arrange & Act - response = MessageResponse( - ai_response="Here's my coaching advice...", - follow_up_question="What would you like to explore next?", - insights=["Insight 1", "Insight 2"], - progress=0.60, - is_complete=False, - phase=ConversationPhase.SYNTHESIS, - ) - - # Assert - assert response.ai_response == "Here's my coaching advice..." - assert response.follow_up_question == "What would you like to explore next?" - assert len(response.insights) == 2 - assert response.progress == 0.60 - assert response.is_complete is False - assert response.phase == ConversationPhase.SYNTHESIS - - def test_message_response_minimal(self): - """Test message response with minimal fields.""" - # Arrange & Act - response = MessageResponse( - ai_response="Short response", - progress=0.10, - phase=ConversationPhase.INTRODUCTION, - ) - - # Assert - assert response.ai_response == "Short response" - assert response.follow_up_question is None - assert response.insights is None - assert response.is_complete is False - - def test_message_response_completed(self): - """Test message response for completed conversation.""" - # Arrange & Act - response = MessageResponse( - ai_response="Great work! We're done.", - next_steps=["Review your values", "Create action plan"], - identified_values=["Integrity", "Growth", "Innovation"], - progress=1.0, - is_complete=True, - phase=ConversationPhase.COMPLETION, - ) - - # Assert - assert response.is_complete is True - assert response.progress == 1.0 - assert len(response.next_steps) == 2 - assert len(response.identified_values) == 3 - - -@pytest.mark.unit -class TestConversationSummary: - """Test ConversationSummary model.""" - - def test_valid_conversation_summary(self): - """Test creating valid conversation summary.""" - # Arrange - now = datetime.now() - - # Act - summary = ConversationSummary( - conversation_id="conv-789", - topic="strategy", - status=ConversationStatus.ACTIVE, - progress=0.40, - created_at=now, - updated_at=now, - message_count=5, - ) - - # Assert - assert summary.conversation_id == "conv-789" - assert summary.topic == "strategy" - assert summary.status == ConversationStatus.ACTIVE - assert summary.progress == 0.40 - assert summary.message_count == 5 - assert summary.created_at == now - - def test_conversation_summary_with_different_statuses(self): - """Test summaries with different statuses.""" - # Arrange - now = datetime.now() - - # Act & Assert - for status in ConversationStatus: - summary = ConversationSummary( - conversation_id=f"conv-{status.value}", - topic="test", - status=status, - progress=0, - created_at=now, - updated_at=now, - message_count=0, - ) - assert summary.status == status - - -@pytest.mark.unit -class TestConversationListResponse: - """Test ConversationListResponse model.""" - - def test_valid_list_response(self): - """Test creating valid list response.""" - # Arrange - now = datetime.now() - summaries = [ - ConversationSummary( - conversation_id="conv-1", - topic="strategy", - status=ConversationStatus.ACTIVE, - progress=50, - created_at=now, - updated_at=now, - message_count=3, - ), - ConversationSummary( - conversation_id="conv-2", - topic="leadership", - status=ConversationStatus.COMPLETED, - progress=100, - created_at=now, - updated_at=now, - message_count=10, - ), - ] - - # Act - list_response = ConversationListResponse( - conversations=summaries, - total=2, - page=1, - page_size=20, - ) - - # Assert - assert len(list_response.conversations) == 2 - assert list_response.total == 2 - assert list_response.page == 1 - - def test_empty_list_response(self): - """Test list response with no conversations.""" - # Arrange & Act - list_response = ConversationListResponse( - conversations=[], - total=0, - page=1, - ) - - # Assert - assert len(list_response.conversations) == 0 - assert list_response.total == 0 - - def test_list_response_pagination(self): - """Test list response with different pagination.""" - # Arrange & Act - list_response = ConversationListResponse( - conversations=[], - total=100, - page=5, - ) - - # Assert - assert list_response.page == 5 - assert list_response.total == 100 +"""Unit tests for response models.""" + +from datetime import datetime + +import pytest + +from coaching.src.core.constants import ConversationPhase, ConversationStatus +from coaching.src.models.responses import ( + ConversationListResponse, + ConversationResponse, + ConversationSummary, + MessageResponse, +) + + +@pytest.mark.unit +class TestConversationResponse: + """Test ConversationResponse model.""" + + def test_valid_conversation_response(self): + """Test creating valid conversation response.""" + # Arrange & Act + response = ConversationResponse( + conversation_id="conv-123", + status=ConversationStatus.ACTIVE, + current_question="How can I help you?", + progress=0.25, + phase=ConversationPhase.INTRODUCTION, + ) + + # Assert + assert response.conversation_id == "conv-123" + assert response.status == ConversationStatus.ACTIVE + assert response.current_question == "How can I help you?" + assert response.progress == 0.25 + assert response.phase == ConversationPhase.INTRODUCTION + + def test_conversation_response_with_metadata(self): + """Test conversation response with optional metadata.""" + # Arrange & Act + response = ConversationResponse( + conversation_id="conv-456", + status=ConversationStatus.PAUSED, + current_question="Let's continue...", + progress=0.50, + phase=ConversationPhase.DEEPENING, + ) + + # Assert + assert response.progress == 0.50 + assert response.status == ConversationStatus.PAUSED + + +@pytest.mark.unit +class TestMessageResponse: + """Test MessageResponse model.""" + + def test_valid_message_response(self): + """Test creating valid message response.""" + # Arrange & Act + response = MessageResponse( + ai_response="Here's my coaching advice...", + follow_up_question="What would you like to explore next?", + insights=["Insight 1", "Insight 2"], + progress=0.60, + is_complete=False, + phase=ConversationPhase.SYNTHESIS, + ) + + # Assert + assert response.ai_response == "Here's my coaching advice..." + assert response.follow_up_question == "What would you like to explore next?" + assert len(response.insights) == 2 + assert response.progress == 0.60 + assert response.is_complete is False + assert response.phase == ConversationPhase.SYNTHESIS + + def test_message_response_minimal(self): + """Test message response with minimal fields.""" + # Arrange & Act + response = MessageResponse( + ai_response="Short response", + progress=0.10, + phase=ConversationPhase.INTRODUCTION, + ) + + # Assert + assert response.ai_response == "Short response" + assert response.follow_up_question is None + assert response.insights is None + assert response.is_complete is False + + def test_message_response_completed(self): + """Test message response for completed conversation.""" + # Arrange & Act + response = MessageResponse( + ai_response="Great work! We're done.", + next_steps=["Review your values", "Create action plan"], + identified_values=["Integrity", "Growth", "Innovation"], + progress=1.0, + is_complete=True, + phase=ConversationPhase.COMPLETION, + ) + + # Assert + assert response.is_complete is True + assert response.progress == 1.0 + assert len(response.next_steps) == 2 + assert len(response.identified_values) == 3 + + +@pytest.mark.unit +class TestConversationSummary: + """Test ConversationSummary model.""" + + def test_valid_conversation_summary(self): + """Test creating valid conversation summary.""" + # Arrange + now = datetime.now() + + # Act + summary = ConversationSummary( + conversation_id="conv-789", + topic="strategy", + status=ConversationStatus.ACTIVE, + progress=0.40, + created_at=now, + updated_at=now, + message_count=5, + ) + + # Assert + assert summary.conversation_id == "conv-789" + assert summary.topic == "strategy" + assert summary.status == ConversationStatus.ACTIVE + assert summary.progress == 0.40 + assert summary.message_count == 5 + assert summary.created_at == now + + def test_conversation_summary_with_different_statuses(self): + """Test summaries with different statuses.""" + # Arrange + now = datetime.now() + + # Act & Assert + for status in ConversationStatus: + summary = ConversationSummary( + conversation_id=f"conv-{status.value}", + topic="test", + status=status, + progress=0, + created_at=now, + updated_at=now, + message_count=0, + ) + assert summary.status == status + + +@pytest.mark.unit +class TestConversationListResponse: + """Test ConversationListResponse model.""" + + def test_valid_list_response(self): + """Test creating valid list response.""" + # Arrange + now = datetime.now() + summaries = [ + ConversationSummary( + conversation_id="conv-1", + topic="strategy", + status=ConversationStatus.ACTIVE, + progress=50, + created_at=now, + updated_at=now, + message_count=3, + ), + ConversationSummary( + conversation_id="conv-2", + topic="leadership", + status=ConversationStatus.COMPLETED, + progress=100, + created_at=now, + updated_at=now, + message_count=10, + ), + ] + + # Act + list_response = ConversationListResponse( + conversations=summaries, + total=2, + page=1, + page_size=20, + ) + + # Assert + assert len(list_response.conversations) == 2 + assert list_response.total == 2 + assert list_response.page == 1 + + def test_empty_list_response(self): + """Test list response with no conversations.""" + # Arrange & Act + list_response = ConversationListResponse( + conversations=[], + total=0, + page=1, + ) + + # Assert + assert len(list_response.conversations) == 0 + assert list_response.total == 0 + + def test_list_response_pagination(self): + """Test list response with different pagination.""" + # Arrange & Act + list_response = ConversationListResponse( + conversations=[], + total=100, + page=5, + ) + + # Assert + assert list_response.page == 5 + assert list_response.total == 100 diff --git a/coaching/tests/unit/workflows/test_analysis_workflow_template.py b/coaching/tests/unit/workflows/test_analysis_workflow_template.py index 535a708d..308cf99e 100644 --- a/coaching/tests/unit/workflows/test_analysis_workflow_template.py +++ b/coaching/tests/unit/workflows/test_analysis_workflow_template.py @@ -1,137 +1,138 @@ -from unittest.mock import AsyncMock, Mock - -import pytest -from coaching.src.workflows.analysis_workflow_template import AnalysisWorkflowTemplate -from coaching.src.workflows.base import WorkflowConfig, WorkflowStatus - - -class TestAnalysisWorkflowTemplate: - @pytest.fixture - def mock_provider(self): - provider = Mock() - # Mock invoke() which returns a string directly - provider.invoke = AsyncMock(return_value='{"analysis": "result"}') - return provider - - @pytest.fixture - def mock_provider_manager(self, mock_provider): - manager = Mock() - manager.get_provider.return_value = mock_provider - return manager - - @pytest.fixture - def analysis_workflow(self, mock_provider_manager): - config = WorkflowConfig(workflow_type="single_shot_analysis", provider_id="openai") - return AnalysisWorkflowTemplate(config=config, provider_manager=mock_provider_manager) - - @pytest.fixture - def base_state(self): - return { - "workflow_id": "wf_123", - "user_id": "user_123", - "workflow_context": {"provider_id": "openai", "analysis_type": "general"}, - "step_data": {}, - "metadata": {}, - "status": WorkflowStatus.RUNNING.value, - "messages": [ - { - "role": "user", - "content": "This is a test input for analysis that is long enough.", - } - ], - } - - @pytest.mark.asyncio - async def test_create_initial_state(self, analysis_workflow): - user_input = { - "workflow_id": "wf_123", - "user_id": "user_123", - "messages": [{"role": "user", "content": "Test content"}], - "analysis_type": "values", - } - - state = await analysis_workflow.create_initial_state(user_input) - - assert state.workflow_id == "wf_123" - assert state.current_step == "input_validation" - assert state.workflow_context["analysis_type"] == "values" - - @pytest.mark.asyncio - async def test_input_validation_node_success(self, analysis_workflow, base_state): - result = await analysis_workflow.input_validation_node(base_state) - - assert result["current_step"] == "input_validation" - assert result["step_data"]["validation"]["is_valid"] is True - assert "analysis_focus" in result - - @pytest.mark.asyncio - async def test_input_validation_node_too_short(self, analysis_workflow, base_state): - base_state["messages"][0]["content"] = "Short" - - result = await analysis_workflow.input_validation_node(base_state) - - assert result["step_data"]["validation"]["is_valid"] is False - assert result["status"] == "failed" - - @pytest.mark.asyncio - async def test_analysis_execution_node_success( - self, analysis_workflow, base_state, mock_provider_manager - ): - # Setup state as if validation passed - base_state["analysis_focus"] = ["themes"] - - result = await analysis_workflow.analysis_execution_node(base_state) - - assert result["current_step"] == "analysis_execution" - assert "analysis" in result["step_data"] - mock_provider_manager.get_provider.assert_called_with("openai") - - @pytest.mark.asyncio - async def test_analysis_execution_node_failed_previous(self, analysis_workflow, base_state): - base_state["status"] = "failed" - result = await analysis_workflow.analysis_execution_node(base_state) - assert result == base_state - - @pytest.mark.asyncio - async def test_insight_extraction_node_success(self, analysis_workflow, base_state): - # Setup state with analysis results - base_state["step_data"]["analysis"] = { - "analysis_result": {"key_points": ["Point 1", "Point 2"], "sentiment": "positive"}, - "analysis_type": "general", - } - - result = await analysis_workflow.insight_extraction_node(base_state) - - assert result["current_step"] == "insight_extraction" - assert "insights" in result["results"] - assert "insight_summary" in result["results"] - - @pytest.mark.asyncio - async def test_response_formatting_node_success(self, analysis_workflow, base_state): - # Setup state with insights - base_state["results"] = { - "insights": [{"content": "Insight 1"}], - "insight_summary": {"total_insights": 1}, - } - - result = await analysis_workflow.response_formatting_node(base_state) - - assert "messages" in result - # Check if the last message is from assistant - assert result["messages"][-1]["role"] == "assistant" - - @pytest.mark.asyncio - async def test_response_formatting_node_failed(self, analysis_workflow, base_state): - base_state["status"] = "failed" - - result = await analysis_workflow.response_formatting_node(base_state) - - assert "messages" in result - assert "I'm sorry" in result["messages"][-1]["content"] - - @pytest.mark.asyncio - async def test_completion_node(self, analysis_workflow, base_state): - result = await analysis_workflow.completion_node(base_state) - - assert result["status"] == WorkflowStatus.COMPLETED.value - assert "completed_at" in result +from unittest.mock import AsyncMock, Mock + +import pytest + +from coaching.src.workflows.analysis_workflow_template import AnalysisWorkflowTemplate +from coaching.src.workflows.base import WorkflowConfig, WorkflowStatus + + +class TestAnalysisWorkflowTemplate: + @pytest.fixture + def mock_provider(self): + provider = Mock() + # Mock invoke() which returns a string directly + provider.invoke = AsyncMock(return_value='{"analysis": "result"}') + return provider + + @pytest.fixture + def mock_provider_manager(self, mock_provider): + manager = Mock() + manager.get_provider.return_value = mock_provider + return manager + + @pytest.fixture + def analysis_workflow(self, mock_provider_manager): + config = WorkflowConfig(workflow_type="single_shot_analysis", provider_id="openai") + return AnalysisWorkflowTemplate(config=config, provider_manager=mock_provider_manager) + + @pytest.fixture + def base_state(self): + return { + "workflow_id": "wf_123", + "user_id": "user_123", + "workflow_context": {"provider_id": "openai", "analysis_type": "general"}, + "step_data": {}, + "metadata": {}, + "status": WorkflowStatus.RUNNING.value, + "messages": [ + { + "role": "user", + "content": "This is a test input for analysis that is long enough.", + } + ], + } + + @pytest.mark.asyncio + async def test_create_initial_state(self, analysis_workflow): + user_input = { + "workflow_id": "wf_123", + "user_id": "user_123", + "messages": [{"role": "user", "content": "Test content"}], + "analysis_type": "values", + } + + state = await analysis_workflow.create_initial_state(user_input) + + assert state.workflow_id == "wf_123" + assert state.current_step == "input_validation" + assert state.workflow_context["analysis_type"] == "values" + + @pytest.mark.asyncio + async def test_input_validation_node_success(self, analysis_workflow, base_state): + result = await analysis_workflow.input_validation_node(base_state) + + assert result["current_step"] == "input_validation" + assert result["step_data"]["validation"]["is_valid"] is True + assert "analysis_focus" in result + + @pytest.mark.asyncio + async def test_input_validation_node_too_short(self, analysis_workflow, base_state): + base_state["messages"][0]["content"] = "Short" + + result = await analysis_workflow.input_validation_node(base_state) + + assert result["step_data"]["validation"]["is_valid"] is False + assert result["status"] == "failed" + + @pytest.mark.asyncio + async def test_analysis_execution_node_success( + self, analysis_workflow, base_state, mock_provider_manager + ): + # Setup state as if validation passed + base_state["analysis_focus"] = ["themes"] + + result = await analysis_workflow.analysis_execution_node(base_state) + + assert result["current_step"] == "analysis_execution" + assert "analysis" in result["step_data"] + mock_provider_manager.get_provider.assert_called_with("openai") + + @pytest.mark.asyncio + async def test_analysis_execution_node_failed_previous(self, analysis_workflow, base_state): + base_state["status"] = "failed" + result = await analysis_workflow.analysis_execution_node(base_state) + assert result == base_state + + @pytest.mark.asyncio + async def test_insight_extraction_node_success(self, analysis_workflow, base_state): + # Setup state with analysis results + base_state["step_data"]["analysis"] = { + "analysis_result": {"key_points": ["Point 1", "Point 2"], "sentiment": "positive"}, + "analysis_type": "general", + } + + result = await analysis_workflow.insight_extraction_node(base_state) + + assert result["current_step"] == "insight_extraction" + assert "insights" in result["results"] + assert "insight_summary" in result["results"] + + @pytest.mark.asyncio + async def test_response_formatting_node_success(self, analysis_workflow, base_state): + # Setup state with insights + base_state["results"] = { + "insights": [{"content": "Insight 1"}], + "insight_summary": {"total_insights": 1}, + } + + result = await analysis_workflow.response_formatting_node(base_state) + + assert "messages" in result + # Check if the last message is from assistant + assert result["messages"][-1]["role"] == "assistant" + + @pytest.mark.asyncio + async def test_response_formatting_node_failed(self, analysis_workflow, base_state): + base_state["status"] = "failed" + + result = await analysis_workflow.response_formatting_node(base_state) + + assert "messages" in result + assert "I'm sorry" in result["messages"][-1]["content"] + + @pytest.mark.asyncio + async def test_completion_node(self, analysis_workflow, base_state): + result = await analysis_workflow.completion_node(base_state) + + assert result["status"] == WorkflowStatus.COMPLETED.value + assert "completed_at" in result diff --git a/coaching/tests/unit/workflows/test_coaching_workflow_nodes.py b/coaching/tests/unit/workflows/test_coaching_workflow_nodes.py index bf93cb5b..79e55017 100644 --- a/coaching/tests/unit/workflows/test_coaching_workflow_nodes.py +++ b/coaching/tests/unit/workflows/test_coaching_workflow_nodes.py @@ -1,155 +1,156 @@ -from unittest.mock import AsyncMock, Mock - -import pytest -from coaching.src.application.conversation.conversation_service import ( - ConversationApplicationService, -) -from coaching.src.application.llm.llm_service import LLMApplicationService -from coaching.src.core.constants import CoachingTopic, MessageRole -from coaching.src.core.types import ConversationId, TenantId, UserId -from coaching.src.domain.entities.conversation import Conversation -from coaching.src.domain.value_objects.message import Message -from coaching.src.workflows.base import WorkflowConfig, WorkflowStatus -from coaching.src.workflows.coaching_workflow import CoachingWorkflow, CoachingWorkflowConfig - - -class TestCoachingWorkflowNodes: - @pytest.fixture - def mock_conversation_service(self): - service = Mock(spec=ConversationApplicationService) - mock_conversation = Mock(spec=Conversation) - mock_conversation.conversation_id = ConversationId("conv_123") - mock_conversation.user_id = UserId("user_123") - mock_conversation.tenant_id = TenantId("tenant_123") - mock_conversation.topic = CoachingTopic.CORE_VALUES - mock_conversation.messages = [] - - service.get_conversation = AsyncMock(return_value=mock_conversation) - service.add_message = AsyncMock(return_value=mock_conversation) - service.complete_conversation = AsyncMock(return_value=mock_conversation) - return service - - @pytest.fixture - def mock_llm_service(self): - service = Mock(spec=LLMApplicationService) - mock_response = Mock() - mock_response.content = "AI Response" - service.generate_coaching_response = AsyncMock(return_value=mock_response) - return service - - @pytest.fixture - def coaching_workflow(self, mock_conversation_service, mock_llm_service): - workflow_config = CoachingWorkflowConfig( - conversation_service=mock_conversation_service, - llm_service=mock_llm_service, - temperature=0.7, - ) - base_config = WorkflowConfig(workflow_type="conversational_coaching", provider_id="openai") - return CoachingWorkflow(config=base_config, workflow_config=workflow_config) - - @pytest.fixture - def base_state(self): - return { - "workflow_id": "wf_123", - "user_id": "user_123", - "tenant_id": "tenant_123", - "workflow_context": {"conversation_id": "conv_123"}, - "step_data": {}, - "metadata": {}, - "status": WorkflowStatus.RUNNING.value, - } - - @pytest.mark.asyncio - async def test_start_node(self, coaching_workflow, base_state): - result = await coaching_workflow._start_node(base_state) - assert result["current_step"] == "initial_assessment" - assert result["status"] == WorkflowStatus.WAITING_INPUT.value - assert "updated_at" in result - - @pytest.mark.asyncio - async def test_initial_assessment_node_no_user_messages( - self, coaching_workflow, base_state, mock_conversation_service - ): - # Setup conversation with no user messages - mock_conv = mock_conversation_service.get_conversation.return_value - mock_conv.messages = [Message(role=MessageRole.ASSISTANT, content="Welcome")] - - result = await coaching_workflow._initial_assessment_node(base_state) - - # Should return state unchanged (waiting for input) - assert result == base_state - mock_conversation_service.add_message.assert_not_called() - - @pytest.mark.asyncio - async def test_initial_assessment_node_with_user_message( - self, coaching_workflow, base_state, mock_conversation_service, mock_llm_service - ): - # Setup conversation with user message - mock_conv = mock_conversation_service.get_conversation.return_value - mock_conv.messages = [ - Message(role=MessageRole.ASSISTANT, content="Welcome"), - Message(role=MessageRole.USER, content="I need help"), - ] - - result = await coaching_workflow._initial_assessment_node(base_state) - - assert result["current_step"] == "goal_exploration" - assert result["step_data"]["initial_focus"] == "I need help" - mock_llm_service.generate_coaching_response.assert_called_once() - mock_conversation_service.add_message.assert_called_once() - - @pytest.mark.asyncio - async def test_initial_assessment_node_error( - self, coaching_workflow, base_state, mock_conversation_service - ): - mock_conversation_service.get_conversation.side_effect = Exception("DB Error") - - result = await coaching_workflow._initial_assessment_node(base_state) - - assert result["status"] == WorkflowStatus.FAILED.value - assert result["metadata"]["error"] == "DB Error" - - @pytest.mark.asyncio - async def test_goal_exploration_node(self, coaching_workflow, base_state): - result = await coaching_workflow._goal_exploration_node(base_state) - assert result["current_step"] == "action_planning" - - @pytest.mark.asyncio - async def test_action_planning_node(self, coaching_workflow, base_state): - result = await coaching_workflow._action_planning_node(base_state) - assert result["current_step"] == "reflection" - - @pytest.mark.asyncio - async def test_reflection_node(self, coaching_workflow, base_state): - result = await coaching_workflow._reflection_node(base_state) - assert result["current_step"] == "next_steps" - - @pytest.mark.asyncio - async def test_next_steps_node(self, coaching_workflow, base_state): - result = await coaching_workflow._next_steps_node(base_state) - assert result["current_step"] == "completion" - - @pytest.mark.asyncio - async def test_completion_node_success( - self, coaching_workflow, base_state, mock_conversation_service - ): - result = await coaching_workflow._completion_node(base_state) - - assert result["status"] == WorkflowStatus.COMPLETED.value - assert "completed_at" in result - mock_conversation_service.add_message.assert_called_once() - mock_conversation_service.complete_conversation.assert_called_once() - - @pytest.mark.asyncio - async def test_completion_node_error( - self, coaching_workflow, base_state, mock_conversation_service - ): - mock_conversation_service.complete_conversation.side_effect = Exception("Completion Error") - - # Should log error but still mark as completed (based on implementation) - # Wait, let's check implementation. - # It catches exception, logs it, then proceeds to set status=COMPLETED. - - result = await coaching_workflow._completion_node(base_state) - - assert result["status"] == WorkflowStatus.COMPLETED.value +from unittest.mock import AsyncMock, Mock + +import pytest + +from coaching.src.application.conversation.conversation_service import ( + ConversationApplicationService, +) +from coaching.src.application.llm.llm_service import LLMApplicationService +from coaching.src.core.constants import CoachingTopic, MessageRole +from coaching.src.core.types import ConversationId, TenantId, UserId +from coaching.src.domain.entities.conversation import Conversation +from coaching.src.domain.value_objects.message import Message +from coaching.src.workflows.base import WorkflowConfig, WorkflowStatus +from coaching.src.workflows.coaching_workflow import CoachingWorkflow, CoachingWorkflowConfig + + +class TestCoachingWorkflowNodes: + @pytest.fixture + def mock_conversation_service(self): + service = Mock(spec=ConversationApplicationService) + mock_conversation = Mock(spec=Conversation) + mock_conversation.conversation_id = ConversationId("conv_123") + mock_conversation.user_id = UserId("user_123") + mock_conversation.tenant_id = TenantId("tenant_123") + mock_conversation.topic = CoachingTopic.CORE_VALUES + mock_conversation.messages = [] + + service.get_conversation = AsyncMock(return_value=mock_conversation) + service.add_message = AsyncMock(return_value=mock_conversation) + service.complete_conversation = AsyncMock(return_value=mock_conversation) + return service + + @pytest.fixture + def mock_llm_service(self): + service = Mock(spec=LLMApplicationService) + mock_response = Mock() + mock_response.content = "AI Response" + service.generate_coaching_response = AsyncMock(return_value=mock_response) + return service + + @pytest.fixture + def coaching_workflow(self, mock_conversation_service, mock_llm_service): + workflow_config = CoachingWorkflowConfig( + conversation_service=mock_conversation_service, + llm_service=mock_llm_service, + temperature=0.7, + ) + base_config = WorkflowConfig(workflow_type="conversational_coaching", provider_id="openai") + return CoachingWorkflow(config=base_config, workflow_config=workflow_config) + + @pytest.fixture + def base_state(self): + return { + "workflow_id": "wf_123", + "user_id": "user_123", + "tenant_id": "tenant_123", + "workflow_context": {"conversation_id": "conv_123"}, + "step_data": {}, + "metadata": {}, + "status": WorkflowStatus.RUNNING.value, + } + + @pytest.mark.asyncio + async def test_start_node(self, coaching_workflow, base_state): + result = await coaching_workflow._start_node(base_state) + assert result["current_step"] == "initial_assessment" + assert result["status"] == WorkflowStatus.WAITING_INPUT.value + assert "updated_at" in result + + @pytest.mark.asyncio + async def test_initial_assessment_node_no_user_messages( + self, coaching_workflow, base_state, mock_conversation_service + ): + # Setup conversation with no user messages + mock_conv = mock_conversation_service.get_conversation.return_value + mock_conv.messages = [Message(role=MessageRole.ASSISTANT, content="Welcome")] + + result = await coaching_workflow._initial_assessment_node(base_state) + + # Should return state unchanged (waiting for input) + assert result == base_state + mock_conversation_service.add_message.assert_not_called() + + @pytest.mark.asyncio + async def test_initial_assessment_node_with_user_message( + self, coaching_workflow, base_state, mock_conversation_service, mock_llm_service + ): + # Setup conversation with user message + mock_conv = mock_conversation_service.get_conversation.return_value + mock_conv.messages = [ + Message(role=MessageRole.ASSISTANT, content="Welcome"), + Message(role=MessageRole.USER, content="I need help"), + ] + + result = await coaching_workflow._initial_assessment_node(base_state) + + assert result["current_step"] == "goal_exploration" + assert result["step_data"]["initial_focus"] == "I need help" + mock_llm_service.generate_coaching_response.assert_called_once() + mock_conversation_service.add_message.assert_called_once() + + @pytest.mark.asyncio + async def test_initial_assessment_node_error( + self, coaching_workflow, base_state, mock_conversation_service + ): + mock_conversation_service.get_conversation.side_effect = Exception("DB Error") + + result = await coaching_workflow._initial_assessment_node(base_state) + + assert result["status"] == WorkflowStatus.FAILED.value + assert result["metadata"]["error"] == "DB Error" + + @pytest.mark.asyncio + async def test_goal_exploration_node(self, coaching_workflow, base_state): + result = await coaching_workflow._goal_exploration_node(base_state) + assert result["current_step"] == "action_planning" + + @pytest.mark.asyncio + async def test_action_planning_node(self, coaching_workflow, base_state): + result = await coaching_workflow._action_planning_node(base_state) + assert result["current_step"] == "reflection" + + @pytest.mark.asyncio + async def test_reflection_node(self, coaching_workflow, base_state): + result = await coaching_workflow._reflection_node(base_state) + assert result["current_step"] == "next_steps" + + @pytest.mark.asyncio + async def test_next_steps_node(self, coaching_workflow, base_state): + result = await coaching_workflow._next_steps_node(base_state) + assert result["current_step"] == "completion" + + @pytest.mark.asyncio + async def test_completion_node_success( + self, coaching_workflow, base_state, mock_conversation_service + ): + result = await coaching_workflow._completion_node(base_state) + + assert result["status"] == WorkflowStatus.COMPLETED.value + assert "completed_at" in result + mock_conversation_service.add_message.assert_called_once() + mock_conversation_service.complete_conversation.assert_called_once() + + @pytest.mark.asyncio + async def test_completion_node_error( + self, coaching_workflow, base_state, mock_conversation_service + ): + mock_conversation_service.complete_conversation.side_effect = Exception("Completion Error") + + # Should log error but still mark as completed (based on implementation) + # Wait, let's check implementation. + # It catches exception, logs it, then proceeds to set status=COMPLETED. + + result = await coaching_workflow._completion_node(base_state) + + assert result["status"] == WorkflowStatus.COMPLETED.value diff --git a/coaching/tests/unit/workflows/test_conversation_workflow_template.py b/coaching/tests/unit/workflows/test_conversation_workflow_template.py index 9bc99098..2a1bef2f 100644 --- a/coaching/tests/unit/workflows/test_conversation_workflow_template.py +++ b/coaching/tests/unit/workflows/test_conversation_workflow_template.py @@ -1,6 +1,7 @@ from unittest.mock import AsyncMock, Mock import pytest + from coaching.src.workflows.base import WorkflowConfig, WorkflowStatus from coaching.src.workflows.conversation_workflow_template import ConversationWorkflowTemplate diff --git a/coaching/tests/unit/workflows/test_refactored_workflows.py b/coaching/tests/unit/workflows/test_refactored_workflows.py index bf39ee6b..7877075a 100644 --- a/coaching/tests/unit/workflows/test_refactored_workflows.py +++ b/coaching/tests/unit/workflows/test_refactored_workflows.py @@ -1,384 +1,385 @@ -"""Tests for refactored workflows using new architecture. - -This test suite verifies that the refactored coaching_workflow.py and -analysis_workflow.py properly integrate with domain entities and services. -""" - -from unittest.mock import AsyncMock, Mock - -import pytest -from coaching.src.application.analysis.base_analysis_service import BaseAnalysisService -from coaching.src.application.conversation.conversation_service import ( - ConversationApplicationService, -) -from coaching.src.application.llm.llm_service import LLMApplicationService -from coaching.src.core.constants import AnalysisType, CoachingTopic, ConversationStatus, MessageRole -from coaching.src.core.types import ConversationId, TenantId, UserId -from coaching.src.domain.entities.conversation import Conversation -from coaching.src.domain.ports.llm_provider_port import LLMResponse -from coaching.src.domain.value_objects.message import Message -from coaching.src.workflows.analysis_workflow import ( - AnalysisWorkflow, - AnalysisWorkflowConfig, - AnalysisWorkflowInput, -) -from coaching.src.workflows.base import WorkflowConfig, WorkflowStatus -from coaching.src.workflows.coaching_workflow import ( - CoachingWorkflow, - CoachingWorkflowConfig, - CoachingWorkflowInput, -) - - -class MockAnalysisService(BaseAnalysisService): - """Mock analysis service for testing.""" - - def get_analysis_type(self) -> AnalysisType: - return AnalysisType.ALIGNMENT - - def build_prompt(self, context: dict) -> str: - return "Mock prompt" - - def parse_response(self, llm_response: str) -> dict: - return { - "alignment_score": 85, - "overall_assessment": "Good alignment", - "strengths": ["Value alignment"], - "misalignments": [], - "recommendations": [], - } - - -class TestCoachingWorkflow: - """Test refactored coaching workflow.""" - - @pytest.fixture - def mock_conversation_service(self): - """Create mock conversation service.""" - service = Mock(spec=ConversationApplicationService) - - # Mock start_conversation - mock_conversation = Mock(spec=Conversation) - mock_conversation.conversation_id = ConversationId("conv_123") - mock_conversation.user_id = UserId("user_123") - mock_conversation.tenant_id = TenantId("tenant_123") - mock_conversation.topic = CoachingTopic.CORE_VALUES - mock_conversation.status = ConversationStatus.ACTIVE - mock_conversation.messages = [ - Message( - role=MessageRole.ASSISTANT, - content="Welcome to your coaching session!", - ) - ] - - service.start_conversation = AsyncMock(return_value=mock_conversation) - service.add_message = AsyncMock(return_value=mock_conversation) - service.get_conversation = AsyncMock(return_value=mock_conversation) - service.complete_conversation = AsyncMock(return_value=mock_conversation) - - return service - - @pytest.fixture - def mock_llm_service(self): - """Create mock LLM service.""" - service = Mock(spec=LLMApplicationService) - - # Mock response - mock_response = Mock(spec=LLMResponse) - mock_response.content = "That's great! Can you tell me more about those values?" - mock_response.model = "gpt-4" - mock_response.usage = {"total_tokens": 100} - mock_response.provider = "openai" - mock_response.finish_reason = "stop" - - service.generate_coaching_response = AsyncMock(return_value=mock_response) - - return service - - @pytest.fixture - def workflow_config(self, mock_conversation_service, mock_llm_service): - """Create workflow configuration.""" - return CoachingWorkflowConfig( - conversation_service=mock_conversation_service, - llm_service=mock_llm_service, - temperature=0.7, - ) - - @pytest.fixture - def coaching_workflow(self, workflow_config): - """Create coaching workflow instance.""" - base_config = WorkflowConfig( - workflow_type="conversational_coaching", - provider_id="openai", - ) - return CoachingWorkflow(config=base_config, workflow_config=workflow_config) - - @pytest.mark.asyncio - async def test_coaching_workflow_input_validation(self): - """Test coaching workflow input model validation.""" - # Valid input - valid_input = CoachingWorkflowInput( - workflow_id="wf_123", - user_id="user_123", - tenant_id="tenant_123", - topic=CoachingTopic.CORE_VALUES, - ) - assert valid_input.workflow_id == "wf_123" - assert valid_input.topic == CoachingTopic.CORE_VALUES - - # Invalid input - missing required field - with pytest.raises(Exception): # Pydantic validation error - CoachingWorkflowInput( - workflow_id="wf_123", - user_id="user_123", - # Missing tenant_id and topic - ) - - @pytest.mark.asyncio - async def test_create_initial_state(self, coaching_workflow, mock_conversation_service): - """Test creating initial workflow state.""" - user_input = { - "workflow_id": "wf_123", - "user_id": "user_123", - "tenant_id": "tenant_123", - "topic": "core_values", - "initial_message": "I want to explore my core values", - } - - state = await coaching_workflow.create_initial_state(user_input) - - # Verify state structure - assert state.workflow_id == "wf_123" - assert state.user_id == "user_123" - assert state.status == WorkflowStatus.RUNNING - assert "conversation_id" in state.workflow_context - - # Verify conversation service was called - mock_conversation_service.start_conversation.assert_called_once() - mock_conversation_service.add_message.assert_called_once() - - @pytest.mark.asyncio - async def test_validate_state(self, coaching_workflow): - """Test workflow state validation.""" - from coaching.src.workflows.base import WorkflowState - - # Valid state - valid_state = WorkflowState( - workflow_id="wf_123", - workflow_type="conversational_coaching", - user_id="user_123", - current_step="start", - workflow_context={"conversation_id": "conv_123"}, - ) - assert await coaching_workflow.validate_state(valid_state) - - # Invalid state - missing conversation_id - invalid_state = WorkflowState( - workflow_id="wf_123", - workflow_type="conversational_coaching", - user_id="user_123", - current_step="start", - workflow_context={}, - ) - assert not await coaching_workflow.validate_state(invalid_state) - - @pytest.mark.asyncio - async def test_initial_assessment_node( - self, coaching_workflow, mock_llm_service, mock_conversation_service - ): - """Test initial assessment node uses LLM service.""" - # Add user message to trigger LLM call - from coaching.src.core.constants import MessageRole - from coaching.src.domain.value_objects.message import Message - - mock_conv = mock_conversation_service.get_conversation.return_value - mock_conv.messages = [ - Message(role=MessageRole.ASSISTANT, content="Welcome!"), - Message(role=MessageRole.USER, content="I want to explore my values"), - ] - - state = { - "workflow_id": "wf_123", - "user_id": "user_123", - "tenant_id": "tenant_123", - "workflow_context": {"conversation_id": "conv_123"}, - "step_data": {}, - "metadata": {}, - } - - result = await coaching_workflow._initial_assessment_node(state) - - # Verify LLM service was called - mock_llm_service.generate_coaching_response.assert_called_once() - - # Verify state was returned (implementation may not update state directly) - assert result is not None - - -class TestAnalysisWorkflow: - """Test refactored analysis workflow.""" - - @pytest.fixture - def mock_analysis_service(self): - """Create mock analysis service.""" - service = MockAnalysisService(llm_service=Mock()) - service.analyze = AsyncMock( - return_value={ - "alignment_score": 85, - "overall_assessment": "Good alignment", - "strengths": ["Clear values"], - "misalignments": [], - "recommendations": [{"action": "Continue", "priority": "medium"}], - } - ) - return service - - @pytest.fixture - def workflow_config(self, mock_analysis_service): - """Create workflow configuration.""" - return AnalysisWorkflowConfig(analysis_service=mock_analysis_service) - - @pytest.fixture - def analysis_workflow(self, workflow_config): - """Create analysis workflow instance.""" - base_config = WorkflowConfig( - workflow_type="single_shot_analysis", - provider_id="openai", - ) - return AnalysisWorkflow(config=base_config, workflow_config=workflow_config) - - @pytest.mark.asyncio - async def test_analysis_workflow_input_validation(self): - """Test analysis workflow input model validation.""" - # Valid input - valid_input = AnalysisWorkflowInput( - workflow_id="wf_456", - user_id="user_456", - tenant_id="tenant_456", - analysis_type=AnalysisType.ALIGNMENT, - text_to_analyze="This is my plan for the quarter", - ) - assert valid_input.workflow_id == "wf_456" - assert valid_input.analysis_type == AnalysisType.ALIGNMENT - - # Invalid input - missing required field - with pytest.raises(Exception): # Pydantic validation error - AnalysisWorkflowInput( - workflow_id="wf_456", - user_id="user_456", - # Missing tenant_id, analysis_type, text_to_analyze - ) - - @pytest.mark.asyncio - async def test_create_initial_state(self, analysis_workflow): - """Test creating initial workflow state.""" - user_input = { - "workflow_id": "wf_456", - "user_id": "user_456", - "tenant_id": "tenant_456", - "analysis_type": "alignment", - "text_to_analyze": "My quarterly plan focuses on growth", - "context": {"purpose": "Build sustainable business"}, - } - - state = await analysis_workflow.create_initial_state(user_input) - - # Verify state structure - assert state.workflow_id == "wf_456" - assert state.user_id == "user_456" - assert state.status == WorkflowStatus.RUNNING - assert "analysis_type" in state.workflow_context - assert "text_to_analyze" in state.workflow_context - assert "context" in state.workflow_context - - @pytest.mark.asyncio - async def test_validate_state(self, analysis_workflow): - """Test workflow state validation.""" - from coaching.src.workflows.base import WorkflowState - - # Valid state - valid_state = WorkflowState( - workflow_id="wf_456", - workflow_type="single_shot_analysis", - user_id="user_456", - current_step="start", - workflow_context={ - "analysis_type": "alignment", - "text_to_analyze": "Test content", - }, - ) - assert await analysis_workflow.validate_state(valid_state) - - # Invalid state - missing text_to_analyze - invalid_state = WorkflowState( - workflow_id="wf_456", - workflow_type="single_shot_analysis", - user_id="user_456", - current_step="start", - workflow_context={"analysis_type": "alignment"}, - ) - assert not await analysis_workflow.validate_state(invalid_state) - - @pytest.mark.asyncio - async def test_analysis_node_uses_service(self, analysis_workflow, mock_analysis_service): - """Test analysis node uses analysis service.""" - state = { - "workflow_id": "wf_456", - "workflow_context": { - "analysis_type": "alignment", - "text_to_analyze": "My business plan", - "context": {"purpose": "Growth"}, - }, - "results": {}, - "metadata": {}, - } - - result = await analysis_workflow._analysis_node(state) - - # Verify analysis service was called - mock_analysis_service.analyze.assert_called_once() - - # Verify state was updated - assert result["current_step"] == "completion" - assert "analysis" in result["results"] - assert result["results"]["analysis_type"] == "alignment" - - -class TestWorkflowArchitectureCompliance: - """Test that workflows comply with new architecture principles.""" - - @pytest.mark.asyncio - async def test_coaching_workflow_uses_pydantic_types(self): - """Verify coaching workflow uses Pydantic models, not dicts.""" - # Input model exists and is Pydantic - from pydantic import BaseModel - - assert issubclass(CoachingWorkflowInput, BaseModel) - assert issubclass(CoachingWorkflowConfig, BaseModel) - - @pytest.mark.asyncio - async def test_analysis_workflow_uses_pydantic_types(self): - """Verify analysis workflow uses Pydantic models, not dicts.""" - from pydantic import BaseModel - - assert issubclass(AnalysisWorkflowInput, BaseModel) - assert issubclass(AnalysisWorkflowConfig, BaseModel) - - @pytest.mark.asyncio - async def test_workflows_use_domain_services(self): - """Verify workflows depend on services, not direct implementations.""" - # Coaching workflow should accept services - mock_conv_service = Mock(spec=ConversationApplicationService) - mock_llm_service = Mock(spec=LLMApplicationService) - - config = CoachingWorkflowConfig( - conversation_service=mock_conv_service, - llm_service=mock_llm_service, - ) - assert config.conversation_service == mock_conv_service - assert config.llm_service == mock_llm_service - - # Analysis workflow should accept service - mock_analysis_service = Mock(spec=BaseAnalysisService) - analysis_config = AnalysisWorkflowConfig(analysis_service=mock_analysis_service) - assert analysis_config.analysis_service == mock_analysis_service +"""Tests for refactored workflows using new architecture. + +This test suite verifies that the refactored coaching_workflow.py and +analysis_workflow.py properly integrate with domain entities and services. +""" + +from unittest.mock import AsyncMock, Mock + +import pytest + +from coaching.src.application.analysis.base_analysis_service import BaseAnalysisService +from coaching.src.application.conversation.conversation_service import ( + ConversationApplicationService, +) +from coaching.src.application.llm.llm_service import LLMApplicationService +from coaching.src.core.constants import AnalysisType, CoachingTopic, ConversationStatus, MessageRole +from coaching.src.core.types import ConversationId, TenantId, UserId +from coaching.src.domain.entities.conversation import Conversation +from coaching.src.domain.ports.llm_provider_port import LLMResponse +from coaching.src.domain.value_objects.message import Message +from coaching.src.workflows.analysis_workflow import ( + AnalysisWorkflow, + AnalysisWorkflowConfig, + AnalysisWorkflowInput, +) +from coaching.src.workflows.base import WorkflowConfig, WorkflowStatus +from coaching.src.workflows.coaching_workflow import ( + CoachingWorkflow, + CoachingWorkflowConfig, + CoachingWorkflowInput, +) + + +class MockAnalysisService(BaseAnalysisService): + """Mock analysis service for testing.""" + + def get_analysis_type(self) -> AnalysisType: + return AnalysisType.ALIGNMENT + + def build_prompt(self, context: dict) -> str: + return "Mock prompt" + + def parse_response(self, llm_response: str) -> dict: + return { + "alignment_score": 85, + "overall_assessment": "Good alignment", + "strengths": ["Value alignment"], + "misalignments": [], + "recommendations": [], + } + + +class TestCoachingWorkflow: + """Test refactored coaching workflow.""" + + @pytest.fixture + def mock_conversation_service(self): + """Create mock conversation service.""" + service = Mock(spec=ConversationApplicationService) + + # Mock start_conversation + mock_conversation = Mock(spec=Conversation) + mock_conversation.conversation_id = ConversationId("conv_123") + mock_conversation.user_id = UserId("user_123") + mock_conversation.tenant_id = TenantId("tenant_123") + mock_conversation.topic = CoachingTopic.CORE_VALUES + mock_conversation.status = ConversationStatus.ACTIVE + mock_conversation.messages = [ + Message( + role=MessageRole.ASSISTANT, + content="Welcome to your coaching session!", + ) + ] + + service.start_conversation = AsyncMock(return_value=mock_conversation) + service.add_message = AsyncMock(return_value=mock_conversation) + service.get_conversation = AsyncMock(return_value=mock_conversation) + service.complete_conversation = AsyncMock(return_value=mock_conversation) + + return service + + @pytest.fixture + def mock_llm_service(self): + """Create mock LLM service.""" + service = Mock(spec=LLMApplicationService) + + # Mock response + mock_response = Mock(spec=LLMResponse) + mock_response.content = "That's great! Can you tell me more about those values?" + mock_response.model = "gpt-4" + mock_response.usage = {"total_tokens": 100} + mock_response.provider = "openai" + mock_response.finish_reason = "stop" + + service.generate_coaching_response = AsyncMock(return_value=mock_response) + + return service + + @pytest.fixture + def workflow_config(self, mock_conversation_service, mock_llm_service): + """Create workflow configuration.""" + return CoachingWorkflowConfig( + conversation_service=mock_conversation_service, + llm_service=mock_llm_service, + temperature=0.7, + ) + + @pytest.fixture + def coaching_workflow(self, workflow_config): + """Create coaching workflow instance.""" + base_config = WorkflowConfig( + workflow_type="conversational_coaching", + provider_id="openai", + ) + return CoachingWorkflow(config=base_config, workflow_config=workflow_config) + + @pytest.mark.asyncio + async def test_coaching_workflow_input_validation(self): + """Test coaching workflow input model validation.""" + # Valid input + valid_input = CoachingWorkflowInput( + workflow_id="wf_123", + user_id="user_123", + tenant_id="tenant_123", + topic=CoachingTopic.CORE_VALUES, + ) + assert valid_input.workflow_id == "wf_123" + assert valid_input.topic == CoachingTopic.CORE_VALUES + + # Invalid input - missing required field + with pytest.raises(Exception): # Pydantic validation error + CoachingWorkflowInput( + workflow_id="wf_123", + user_id="user_123", + # Missing tenant_id and topic + ) + + @pytest.mark.asyncio + async def test_create_initial_state(self, coaching_workflow, mock_conversation_service): + """Test creating initial workflow state.""" + user_input = { + "workflow_id": "wf_123", + "user_id": "user_123", + "tenant_id": "tenant_123", + "topic": "core_values", + "initial_message": "I want to explore my core values", + } + + state = await coaching_workflow.create_initial_state(user_input) + + # Verify state structure + assert state.workflow_id == "wf_123" + assert state.user_id == "user_123" + assert state.status == WorkflowStatus.RUNNING + assert "conversation_id" in state.workflow_context + + # Verify conversation service was called + mock_conversation_service.start_conversation.assert_called_once() + mock_conversation_service.add_message.assert_called_once() + + @pytest.mark.asyncio + async def test_validate_state(self, coaching_workflow): + """Test workflow state validation.""" + from coaching.src.workflows.base import WorkflowState + + # Valid state + valid_state = WorkflowState( + workflow_id="wf_123", + workflow_type="conversational_coaching", + user_id="user_123", + current_step="start", + workflow_context={"conversation_id": "conv_123"}, + ) + assert await coaching_workflow.validate_state(valid_state) + + # Invalid state - missing conversation_id + invalid_state = WorkflowState( + workflow_id="wf_123", + workflow_type="conversational_coaching", + user_id="user_123", + current_step="start", + workflow_context={}, + ) + assert not await coaching_workflow.validate_state(invalid_state) + + @pytest.mark.asyncio + async def test_initial_assessment_node( + self, coaching_workflow, mock_llm_service, mock_conversation_service + ): + """Test initial assessment node uses LLM service.""" + # Add user message to trigger LLM call + from coaching.src.core.constants import MessageRole + from coaching.src.domain.value_objects.message import Message + + mock_conv = mock_conversation_service.get_conversation.return_value + mock_conv.messages = [ + Message(role=MessageRole.ASSISTANT, content="Welcome!"), + Message(role=MessageRole.USER, content="I want to explore my values"), + ] + + state = { + "workflow_id": "wf_123", + "user_id": "user_123", + "tenant_id": "tenant_123", + "workflow_context": {"conversation_id": "conv_123"}, + "step_data": {}, + "metadata": {}, + } + + result = await coaching_workflow._initial_assessment_node(state) + + # Verify LLM service was called + mock_llm_service.generate_coaching_response.assert_called_once() + + # Verify state was returned (implementation may not update state directly) + assert result is not None + + +class TestAnalysisWorkflow: + """Test refactored analysis workflow.""" + + @pytest.fixture + def mock_analysis_service(self): + """Create mock analysis service.""" + service = MockAnalysisService(llm_service=Mock()) + service.analyze = AsyncMock( + return_value={ + "alignment_score": 85, + "overall_assessment": "Good alignment", + "strengths": ["Clear values"], + "misalignments": [], + "recommendations": [{"action": "Continue", "priority": "medium"}], + } + ) + return service + + @pytest.fixture + def workflow_config(self, mock_analysis_service): + """Create workflow configuration.""" + return AnalysisWorkflowConfig(analysis_service=mock_analysis_service) + + @pytest.fixture + def analysis_workflow(self, workflow_config): + """Create analysis workflow instance.""" + base_config = WorkflowConfig( + workflow_type="single_shot_analysis", + provider_id="openai", + ) + return AnalysisWorkflow(config=base_config, workflow_config=workflow_config) + + @pytest.mark.asyncio + async def test_analysis_workflow_input_validation(self): + """Test analysis workflow input model validation.""" + # Valid input + valid_input = AnalysisWorkflowInput( + workflow_id="wf_456", + user_id="user_456", + tenant_id="tenant_456", + analysis_type=AnalysisType.ALIGNMENT, + text_to_analyze="This is my plan for the quarter", + ) + assert valid_input.workflow_id == "wf_456" + assert valid_input.analysis_type == AnalysisType.ALIGNMENT + + # Invalid input - missing required field + with pytest.raises(Exception): # Pydantic validation error + AnalysisWorkflowInput( + workflow_id="wf_456", + user_id="user_456", + # Missing tenant_id, analysis_type, text_to_analyze + ) + + @pytest.mark.asyncio + async def test_create_initial_state(self, analysis_workflow): + """Test creating initial workflow state.""" + user_input = { + "workflow_id": "wf_456", + "user_id": "user_456", + "tenant_id": "tenant_456", + "analysis_type": "alignment", + "text_to_analyze": "My quarterly plan focuses on growth", + "context": {"purpose": "Build sustainable business"}, + } + + state = await analysis_workflow.create_initial_state(user_input) + + # Verify state structure + assert state.workflow_id == "wf_456" + assert state.user_id == "user_456" + assert state.status == WorkflowStatus.RUNNING + assert "analysis_type" in state.workflow_context + assert "text_to_analyze" in state.workflow_context + assert "context" in state.workflow_context + + @pytest.mark.asyncio + async def test_validate_state(self, analysis_workflow): + """Test workflow state validation.""" + from coaching.src.workflows.base import WorkflowState + + # Valid state + valid_state = WorkflowState( + workflow_id="wf_456", + workflow_type="single_shot_analysis", + user_id="user_456", + current_step="start", + workflow_context={ + "analysis_type": "alignment", + "text_to_analyze": "Test content", + }, + ) + assert await analysis_workflow.validate_state(valid_state) + + # Invalid state - missing text_to_analyze + invalid_state = WorkflowState( + workflow_id="wf_456", + workflow_type="single_shot_analysis", + user_id="user_456", + current_step="start", + workflow_context={"analysis_type": "alignment"}, + ) + assert not await analysis_workflow.validate_state(invalid_state) + + @pytest.mark.asyncio + async def test_analysis_node_uses_service(self, analysis_workflow, mock_analysis_service): + """Test analysis node uses analysis service.""" + state = { + "workflow_id": "wf_456", + "workflow_context": { + "analysis_type": "alignment", + "text_to_analyze": "My business plan", + "context": {"purpose": "Growth"}, + }, + "results": {}, + "metadata": {}, + } + + result = await analysis_workflow._analysis_node(state) + + # Verify analysis service was called + mock_analysis_service.analyze.assert_called_once() + + # Verify state was updated + assert result["current_step"] == "completion" + assert "analysis" in result["results"] + assert result["results"]["analysis_type"] == "alignment" + + +class TestWorkflowArchitectureCompliance: + """Test that workflows comply with new architecture principles.""" + + @pytest.mark.asyncio + async def test_coaching_workflow_uses_pydantic_types(self): + """Verify coaching workflow uses Pydantic models, not dicts.""" + # Input model exists and is Pydantic + from pydantic import BaseModel + + assert issubclass(CoachingWorkflowInput, BaseModel) + assert issubclass(CoachingWorkflowConfig, BaseModel) + + @pytest.mark.asyncio + async def test_analysis_workflow_uses_pydantic_types(self): + """Verify analysis workflow uses Pydantic models, not dicts.""" + from pydantic import BaseModel + + assert issubclass(AnalysisWorkflowInput, BaseModel) + assert issubclass(AnalysisWorkflowConfig, BaseModel) + + @pytest.mark.asyncio + async def test_workflows_use_domain_services(self): + """Verify workflows depend on services, not direct implementations.""" + # Coaching workflow should accept services + mock_conv_service = Mock(spec=ConversationApplicationService) + mock_llm_service = Mock(spec=LLMApplicationService) + + config = CoachingWorkflowConfig( + conversation_service=mock_conv_service, + llm_service=mock_llm_service, + ) + assert config.conversation_service == mock_conv_service + assert config.llm_service == mock_llm_service + + # Analysis workflow should accept service + mock_analysis_service = Mock(spec=BaseAnalysisService) + analysis_config = AnalysisWorkflowConfig(analysis_service=mock_analysis_service) + assert analysis_config.analysis_service == mock_analysis_service