diff --git a/src/config/app_config.py b/src/config/app_config.py deleted file mode 100644 index ffd6a2b7266a94afd6090e1ffeff2d3eaef476fa..0000000000000000000000000000000000000000 --- a/src/config/app_config.py +++ /dev/null @@ -1,32 +0,0 @@ -""" -Configuration for HuggingFace Spaces deployment -""" - -# HuggingFace Spaces metadata -SPACE_CONFIG = { - "title": "🏥 Lifestyle Journey MVP", - "emoji": "🏥", - "colorFrom": "blue", - "colorTo": "green", - "sdk": "gradio", - "sdk_version": "4.0.0", - "app_file": "app.py", - "pinned": False, - "license": "mit" -} - -# Gradio configuration -GRADIO_CONFIG = { - "theme": "soft", - "show_api": False, - "show_error": True, - "height": 600, - "title": "Lifestyle Journey MVP" -} - -# API configuration -API_CONFIG = { - "gemini_model": "gemini-2.5-flash", - "temperature": 0.3, - "max_tokens": 2048 -} \ No newline at end of file diff --git a/src/config/dynamic.py b/src/config/dynamic.py deleted file mode 100644 index 178a600afabeefd7786874d424d6fe10c5d6e9e9..0000000000000000000000000000000000000000 --- a/src/config/dynamic.py +++ /dev/null @@ -1,396 +0,0 @@ -# dynamic_config.py - Environment Configuration Management -""" -Strategic Configuration Management: Environment-driven feature control - -Design Philosophy: "Configuration-driven deployment enables risk-free rollout" -- Environment variables control feature activation -- Safe defaults prevent accidental activation in production -- Granular control over performance and safety parameters -- Clear separation between development and production configurations -""" - -import os -from typing import Dict, Any, Optional -from dataclasses import dataclass -from enum import Enum - -class DeploymentEnvironment(Enum): - """Environment types for configuration optimization""" - DEVELOPMENT = "development" - TESTING = "testing" - STAGING = "staging" - PRODUCTION = "production" - -class FeatureFlag(Enum): - """Feature flags for gradual rollout control""" - DYNAMIC_PROMPTS = "dynamic_prompts" - ADVANCED_CACHING = "advanced_caching" - PERFORMANCE_MONITORING = "performance_monitoring" - DEBUG_LOGGING = "debug_logging" - MEDICAL_REVIEW_INTEGRATION = "medical_review_integration" - -@dataclass -class DynamicPromptConfiguration: - """ - Comprehensive configuration for dynamic prompt composition system - - Strategic Design: Centralized configuration with environment-specific optimization - - Safe defaults for production deployment - - Performance tuning parameters for different environments - - Medical safety thresholds with conservative defaults - - Feature flags for gradual rollout control - """ - - # === CORE FEATURE CONTROL === - enabled: bool = False # Master switch - disabled by default - fallback_enabled: bool = True # Always allow fallback to static prompts - environment: DeploymentEnvironment = DeploymentEnvironment.PRODUCTION - - # === PERFORMANCE PARAMETERS === - classification_timeout_ms: int = 5000 # LLM classification timeout - assembly_timeout_ms: int = 2000 # Prompt assembly timeout - cache_enabled: bool = True # Enable intelligent caching - cache_ttl_hours: int = 24 # Cache time-to-live - max_cache_size: int = 1000 # Maximum cache entries - - # === MEDICAL SAFETY CONFIGURATION === - require_safety_validation: bool = True # Mandatory safety validation - min_safety_components: int = 1 # Minimum safety components required - safety_validation_timeout_ms: int = 1000 # Safety check timeout - medical_review_required_threshold: str = "enhanced" # When medical review required - - # === QUALITY ASSURANCE === - debug_mode: bool = False # Detailed logging for development - performance_monitoring: bool = True # Track composition performance - error_reporting: bool = True # Report errors for analysis - audit_logging: bool = True # Comprehensive audit trail - - # === ROLLOUT CONTROL === - rollout_percentage: int = 0 # Percentage of users with dynamic prompts - feature_flags: Dict[str, bool] = None # Granular feature control - - # === API LIMITS AND THRESHOLDS === - max_daily_classifications: int = 10000 # Daily classification limit - rate_limit_per_minute: int = 100 # Classifications per minute - concurrent_classifications: int = 10 # Concurrent LLM calls - - def __post_init__(self): - """Initialize default feature flags and validate configuration""" - if self.feature_flags is None: - self.feature_flags = { - FeatureFlag.DYNAMIC_PROMPTS.value: self.enabled, - FeatureFlag.ADVANCED_CACHING.value: self.cache_enabled, - FeatureFlag.PERFORMANCE_MONITORING.value: self.performance_monitoring, - FeatureFlag.DEBUG_LOGGING.value: self.debug_mode, - FeatureFlag.MEDICAL_REVIEW_INTEGRATION.value: True - } - - # Validate configuration consistency - self._validate_configuration() - - def _validate_configuration(self): - """Validate configuration parameters for consistency and safety""" - - # Safety validations - if self.enabled and not self.require_safety_validation: - raise ValueError("Dynamic prompts cannot be enabled without safety validation") - - if self.min_safety_components < 1: - raise ValueError("At least one safety component must be required") - - # Performance validations - if self.classification_timeout_ms < 1000: - raise ValueError("Classification timeout must be at least 1000ms for safety") - - if self.rollout_percentage < 0 or self.rollout_percentage > 100: - raise ValueError("Rollout percentage must be between 0 and 100") - - # Cache validations - if self.cache_enabled and self.max_cache_size < 100: - raise ValueError("Cache size must be at least 100 entries if enabled") - -# Backward-compatibility class-level flags used in some tests -# These provide simple attribute targets for monkeypatching -DynamicPromptConfiguration.ENABLED = False -DynamicPromptConfiguration.DEBUG_MODE = False -DynamicPromptConfiguration.CACHE_ENABLED = True -DynamicPromptConfiguration.REQUIRE_SAFETY_VALIDATION = True - -class EnvironmentConfigurationManager: - """ - Strategic environment configuration management - - Design Philosophy: "Environment-aware configuration with safe defaults" - - Automatic environment detection and configuration - - Safe production defaults with development optimizations - - Clear configuration hierarchy and override mechanisms - - Comprehensive validation and error reporting - """ - - def __init__(self): - self.environment = self._detect_environment() - self.config = self._load_environment_configuration() - - def _detect_environment(self) -> DeploymentEnvironment: - """Detect deployment environment from various indicators""" - - # Check explicit environment variable - env_name = os.getenv('DEPLOYMENT_ENVIRONMENT', '').lower() - if env_name: - for env in DeploymentEnvironment: - if env.value == env_name: - return env - - # Detect from common environment indicators - if os.getenv('DEBUG', '').lower() == 'true': - return DeploymentEnvironment.DEVELOPMENT - - if os.getenv('PYTEST_CURRENT_TEST') is not None: - return DeploymentEnvironment.TESTING - - if 'pytest' in os.environ.get('_', ''): - return DeploymentEnvironment.TESTING - - if os.getenv('STAGING', '').lower() == 'true': - return DeploymentEnvironment.STAGING - - # Default to production for safety - return DeploymentEnvironment.PRODUCTION - - def _load_environment_configuration(self) -> DynamicPromptConfiguration: - """Load configuration optimized for detected environment""" - - # Base configuration with safe defaults - base_config = DynamicPromptConfiguration() - - # Environment-specific optimizations - if self.environment == DeploymentEnvironment.DEVELOPMENT: - return self._apply_development_config(base_config) - elif self.environment == DeploymentEnvironment.TESTING: - return self._apply_testing_config(base_config) - elif self.environment == DeploymentEnvironment.STAGING: - return self._apply_staging_config(base_config) - else: # PRODUCTION - return self._apply_production_config(base_config) - - def _apply_development_config(self, config: DynamicPromptConfiguration) -> DynamicPromptConfiguration: - """Apply development-optimized configuration""" - - # Development optimizations - config.enabled = self._get_bool_env('ENABLE_DYNAMIC_PROMPTS', True) - config.debug_mode = True - config.performance_monitoring = True - config.classification_timeout_ms = 10000 # Longer timeout for debugging - config.cache_ttl_hours = 1 # Shorter cache for rapid development - config.rollout_percentage = 100 # Full rollout in development - - # Development feature flags - config.feature_flags.update({ - FeatureFlag.DEBUG_LOGGING.value: True, - FeatureFlag.PERFORMANCE_MONITORING.value: True - }) - - return config - - def _apply_testing_config(self, config: DynamicPromptConfiguration) -> DynamicPromptConfiguration: - """Apply testing-optimized configuration""" - - # Testing optimizations - config.enabled = True # Always enable for testing - config.debug_mode = True - config.cache_enabled = False # Disable cache for deterministic tests - config.classification_timeout_ms = 2000 # Faster timeout for tests - config.max_daily_classifications = 1000 # Lower limit for tests - config.rollout_percentage = 100 # Full rollout for testing - - return config - - def _apply_staging_config(self, config: DynamicPromptConfiguration) -> DynamicPromptConfiguration: - """Apply staging-optimized configuration""" - - # Staging optimizations (production-like with monitoring) - config.enabled = self._get_bool_env('ENABLE_DYNAMIC_PROMPTS', False) - config.debug_mode = self._get_bool_env('DEBUG_DYNAMIC_PROMPTS', True) - config.performance_monitoring = True - config.rollout_percentage = self._get_int_env('DYNAMIC_ROLLOUT_PERCENTAGE', 25) - - return config - - def _apply_production_config(self, config: DynamicPromptConfiguration) -> DynamicPromptConfiguration: - """Apply production-optimized configuration""" - - # Production optimizations (conservative and safe) - config.enabled = self._get_bool_env('ENABLE_DYNAMIC_PROMPTS', False) - config.debug_mode = self._get_bool_env('DEBUG_DYNAMIC_PROMPTS', False) - config.performance_monitoring = self._get_bool_env('PERFORMANCE_MONITORING', True) - config.rollout_percentage = self._get_int_env('DYNAMIC_ROLLOUT_PERCENTAGE', 0) - - # Production-specific timeouts (conservative) - config.classification_timeout_ms = self._get_int_env('CLASSIFICATION_TIMEOUT_MS', 3000) - config.cache_ttl_hours = self._get_int_env('CACHE_TTL_HOURS', 24) - - return config - - def _get_bool_env(self, key: str, default: bool) -> bool: - """Get boolean environment variable with safe parsing""" - value = os.getenv(key, str(default)).lower() - return value in ('true', '1', 'yes', 'on', 'enabled') - - def _get_int_env(self, key: str, default: int) -> int: - """Get integer environment variable with safe parsing""" - try: - return int(os.getenv(key, str(default))) - except ValueError: - return default - - def get_configuration(self) -> DynamicPromptConfiguration: - """Get current configuration""" - return self.config - - def update_rollout_percentage(self, percentage: int) -> bool: - """Update rollout percentage with validation""" - if 0 <= percentage <= 100: - self.config.rollout_percentage = percentage - return True - return False - - def enable_feature(self, feature: FeatureFlag) -> bool: - """Enable specific feature flag""" - if feature.value in self.config.feature_flags: - self.config.feature_flags[feature.value] = True - return True - return False - - def disable_feature(self, feature: FeatureFlag) -> bool: - """Disable specific feature flag""" - if feature.value in self.config.feature_flags: - self.config.feature_flags[feature.value] = False - return True - return False - - def get_configuration_summary(self) -> Dict[str, Any]: - """Get comprehensive configuration summary for monitoring""" - return { - 'environment': self.environment.value, - 'dynamic_prompts_enabled': self.config.enabled, - 'rollout_percentage': self.config.rollout_percentage, - 'debug_mode': self.config.debug_mode, - 'safety_validation_required': self.config.require_safety_validation, - 'cache_enabled': self.config.cache_enabled, - 'performance_monitoring': self.config.performance_monitoring, - 'feature_flags': self.config.feature_flags.copy(), - 'timeouts': { - 'classification_ms': self.config.classification_timeout_ms, - 'assembly_ms': self.config.assembly_timeout_ms, - 'safety_validation_ms': self.config.safety_validation_timeout_ms - }, - 'limits': { - 'daily_classifications': self.config.max_daily_classifications, - 'rate_limit_per_minute': self.config.rate_limit_per_minute, - 'concurrent_classifications': self.config.concurrent_classifications - } - } - -# === GLOBAL CONFIGURATION INSTANCE === - -# Initialize global configuration manager -_config_manager = EnvironmentConfigurationManager() - -def get_dynamic_prompt_config() -> DynamicPromptConfiguration: - """Get current dynamic prompt configuration""" - return _config_manager.get_configuration() - -def get_config_manager() -> EnvironmentConfigurationManager: - """Get configuration manager for advanced operations""" - return _config_manager - -def is_dynamic_prompts_enabled() -> bool: - """Quick check if dynamic prompts are enabled""" - return _config_manager.config.enabled - -def get_rollout_percentage() -> int: - """Get current rollout percentage""" - return _config_manager.config.rollout_percentage - -def should_use_dynamic_prompts(user_id: Optional[str] = None) -> bool: - """ - Determine if dynamic prompts should be used for a specific user - - Strategy: Gradual rollout based on rollout percentage - - Uses deterministic hash of user_id for consistent experience - - Falls back to random selection if no user_id provided - - Always respects global enable/disable setting - """ - - if not _config_manager.config.enabled: - return False - - rollout_percentage = _config_manager.config.rollout_percentage - - if rollout_percentage == 0: - return False - elif rollout_percentage == 100: - return True - else: - # Deterministic rollout based on user_id hash - if user_id: - import hashlib - hash_value = int(hashlib.md5(user_id.encode()).hexdigest()[:8], 16) - return (hash_value % 100) < rollout_percentage - else: - # Random fallback for anonymous users - import random - return random.randint(1, 100) <= rollout_percentage - -# === ENVIRONMENT VARIABLE REFERENCE === - -ENVIRONMENT_VARIABLES_REFERENCE = """ -=== DYNAMIC PROMPT COMPOSITION ENVIRONMENT VARIABLES === - -Core Configuration: - ENABLE_DYNAMIC_PROMPTS=false # Master switch for dynamic composition - DEPLOYMENT_ENVIRONMENT=production # Environment type (development/testing/staging/production) - DYNAMIC_ROLLOUT_PERCENTAGE=0 # Percentage of users with dynamic prompts (0-100) - -Performance Tuning: - CLASSIFICATION_TIMEOUT_MS=5000 # LLM classification timeout - ASSEMBLY_TIMEOUT_MS=2000 # Prompt assembly timeout - CACHE_TTL_HOURS=24 # Cache time-to-live - MAX_CACHE_SIZE=1000 # Maximum cache entries - -Safety and Quality: - DEBUG_DYNAMIC_PROMPTS=false # Enable detailed debug logging - PERFORMANCE_MONITORING=true # Track composition performance - REQUIRE_SAFETY_VALIDATION=true # Mandatory safety validation - -API Limits: - MAX_DAILY_CLASSIFICATIONS=10000 # Daily classification limit - RATE_LIMIT_PER_MINUTE=100 # Classifications per minute - CONCURRENT_CLASSIFICATIONS=10 # Concurrent LLM calls - -Example Production Configuration: - ENABLE_DYNAMIC_PROMPTS=true - DEPLOYMENT_ENVIRONMENT=production - DYNAMIC_ROLLOUT_PERCENTAGE=25 - CLASSIFICATION_TIMEOUT_MS=3000 - DEBUG_DYNAMIC_PROMPTS=false - PERFORMANCE_MONITORING=true - -Example Development Configuration: - ENABLE_DYNAMIC_PROMPTS=true - DEPLOYMENT_ENVIRONMENT=development - DYNAMIC_ROLLOUT_PERCENTAGE=100 - CLASSIFICATION_TIMEOUT_MS=10000 - DEBUG_DYNAMIC_PROMPTS=true - CACHE_TTL_HOURS=1 -""" - -# Backward-compatibility alias used in some tests/docs -DynamicPromptConfig = DynamicPromptConfiguration - -if __name__ == "__main__": - # Print current configuration for debugging - config_summary = _config_manager.get_configuration_summary() - print("=== CURRENT DYNAMIC PROMPT CONFIGURATION ===") - import json - print(json.dumps(config_summary, indent=2)) diff --git a/src/config/prompt_composer.py b/src/config/prompt_composer.py deleted file mode 100644 index 1bc8301361089d8eb8d8ab5a00c43de5f78f7fb8..0000000000000000000000000000000000000000 --- a/src/config/prompt_composer.py +++ /dev/null @@ -1,435 +0,0 @@ -# prompt_composer.py - NEW FILE -""" -Dynamic Medical Prompt Composition System - -Strategic Design Philosophy: -- Modular medical components for personalized patient care -- Context-aware adaptation based on patient profiles -- Minimal invasive integration with existing architecture -- Extensible framework for future medical conditions -""" - -from typing import Dict, List, Optional, Any, TYPE_CHECKING -from dataclasses import dataclass -from datetime import datetime - -from src.prompts.types import PromptComponent -if TYPE_CHECKING: - from src.core.core_classes import LifestyleProfile, ClinicalBackground - -@dataclass -class ProfileAnalysis: - """Comprehensive analysis of patient profile for prompt composition""" - conditions: Dict[str, List[str]] - risk_factors: List[str] - preferences: Dict[str, bool] - communication_style: str - progress_stage: str - motivation_level: str - complexity_score: int - -# Use PromptComponent from prompt_types to avoid circular imports - -class DynamicPromptComposer: - """ - Core orchestrator for dynamic medical prompt composition - - Strategic Architecture: - - Analyzes patient profile characteristics - - Selects appropriate modular components - - Composes personalized medical prompts - - Maintains safety and effectiveness standards - """ - - def __init__(self): - # Lazy import to avoid potential circular dependencies - from src.prompts.components import PromptComponentLibrary - self.component_library = PromptComponentLibrary() - self.profile_analyzer = PatientProfileAnalyzer() - self.composition_logs = [] - - def compose_lifestyle_prompt(self, - lifestyle_profile: "LifestyleProfile", - session_context: Optional[Dict] = None) -> str: - """ - Main orchestration method for prompt composition - - Args: - lifestyle_profile: Patient's lifestyle and medical profile - session_context: Additional context (session length, clinical background) - - Returns: - Fully composed, personalized medical prompt - """ - - # Strategic Phase 1: Comprehensive Profile Analysis - profile_analysis = self.profile_analyzer.analyze_profile(lifestyle_profile) - - # Strategic Phase 2: Component Selection and Prioritization - selected_components = self._select_components(profile_analysis, session_context) - - # Strategic Phase 3: Intelligent Prompt Assembly - composed_prompt = self._assemble_prompt(selected_components, profile_analysis) - - # Strategic Phase 4: Quality Validation and Logging - self._log_composition(lifestyle_profile, composed_prompt, selected_components) - - return composed_prompt - - def _select_components(self, - analysis: ProfileAnalysis, - context: Optional[Dict] = None) -> List[PromptComponent]: - """Select appropriate components based on patient analysis""" - - components = [] - - # Foundation component (always included) - components.append(self.component_library.get_base_foundation()) - - # Condition-specific modules - for category, conditions in analysis.conditions.items(): - if conditions: # If patient has conditions in this category - component = self.component_library.get_condition_component(category) - if component: - components.append(component) - - # Personalization modules - personalization = self.component_library.get_personalization_component( - analysis.preferences, analysis.communication_style - ) - if personalization: - components.append(personalization) - - # Safety protocols (always included, but adapted) - safety = self.component_library.get_safety_component(analysis.risk_factors) - components.append(safety) - - # Progress-specific guidance - progress = self.component_library.get_progress_component(analysis.progress_stage) - if progress: - components.append(progress) - - # Sort by priority - components.sort(key=lambda x: x.priority, reverse=True) - - return components - - def _assemble_prompt(self, - components: List[PromptComponent], - analysis: ProfileAnalysis) -> str: - """Intelligently assemble components into cohesive prompt""" - - # Strategic assembly with contextual flow - assembled_sections = [] - - # Base foundation - base_components = [c for c in components if c.name == "base_foundation"] - if base_components: - assembled_sections.append(base_components[0].content) - - # Medical condition modules - condition_components = [c for c in components if "condition" in c.name.lower()] - if condition_components: - condition_text = "\n\n".join([c.content for c in condition_components]) - assembled_sections.append(condition_text) - - # Personalization and communication style - personal_components = [c for c in components if "personal" in c.name.lower()] - if personal_components: - personal_text = "\n\n".join([c.content for c in personal_components]) - assembled_sections.append(personal_text) - - # Safety protocols - safety_components = [c for c in components if "safety" in c.name.lower()] - if safety_components: - safety_text = "\n\n".join([c.content for c in safety_components]) - assembled_sections.append(safety_text) - - # Progress and motivation - progress_components = [c for c in components if "progress" in c.name.lower()] - if progress_components: - progress_text = "\n\n".join([c.content for c in progress_components]) - assembled_sections.append(progress_text) - - # Final assembly with strategic formatting - final_prompt = "\n\n".join(assembled_sections) - - # Add dynamic patient context - patient_context = self._generate_patient_context(analysis) - final_prompt += f"\n\n{patient_context}" - - return final_prompt - - def _generate_patient_context(self, analysis: ProfileAnalysis) -> str: - """Generate dynamic patient context section""" - - context_parts = [] - - context_parts.append("CURRENT PATIENT CONTEXT:") - - if analysis.conditions: - active_conditions = [] - for category, conditions in analysis.conditions.items(): - active_conditions.extend(conditions) - if active_conditions: - context_parts.append(f"• Active conditions requiring consideration: {', '.join(active_conditions[:3])}") - - if analysis.preferences.get("data_driven"): - context_parts.append("• Patient prefers data-driven, evidence-based explanations") - - if analysis.preferences.get("gradual_approach"): - context_parts.append("• Patient responds well to gradual, step-by-step approaches") - - if analysis.progress_stage: - context_parts.append(f"• Current progress stage: {analysis.progress_stage}") - - return "\n".join(context_parts) - - def _log_composition(self, - profile: "LifestyleProfile", - prompt: str, - components: List[PromptComponent]): - """Log composition details for analysis and optimization""" - - log_entry = { - "timestamp": datetime.now().isoformat(), - "patient_name": profile.patient_name, - "conditions": profile.conditions, - "components_used": [c.name for c in components], - "prompt_length": len(prompt), - "component_count": len(components) - } - - self.composition_logs.append(log_entry) - - # Keep only last 100 entries - if len(self.composition_logs) > 100: - self.composition_logs = self.composition_logs[-100:] - -class PatientProfileAnalyzer: - """ - Strategic analyzer for patient profiles and medical characteristics - - Core responsibility: Transform raw patient data into actionable insights - for prompt composition and personalization - """ - - def analyze_profile(self, lifestyle_profile: "LifestyleProfile") -> ProfileAnalysis: - """ - Comprehensive analysis of patient profile for prompt optimization - - Strategic approach: - 1. Medical condition categorization - 2. Risk factor assessment - 3. Communication preference extraction - 4. Progress stage evaluation - """ - - analysis = ProfileAnalysis( - conditions=self._categorize_conditions(lifestyle_profile.conditions), - risk_factors=self._assess_risk_factors(lifestyle_profile), - preferences=self._extract_preferences(lifestyle_profile.personal_preferences), - communication_style=self._determine_communication_style(lifestyle_profile), - progress_stage=self._assess_progress_stage(lifestyle_profile), - motivation_level=self._evaluate_motivation(lifestyle_profile), - complexity_score=self._calculate_complexity_score(lifestyle_profile) - ) - - return analysis - - def _categorize_conditions(self, conditions: List[str]) -> Dict[str, List[str]]: - """Categorize medical conditions for appropriate module selection""" - - categories = { - "cardiovascular": [], - "metabolic": [], - "mobility": [], - "anticoagulation": [], - "obesity": [], - "mental_health": [] - } - - # Strategic condition mapping for module selection - condition_mapping = { - # Cardiovascular conditions - "hypertension": "cardiovascular", - "atrial fibrillation": "cardiovascular", - "heart": "cardiovascular", - "cardiac": "cardiovascular", - "blood pressure": "cardiovascular", - - # Metabolic conditions - "diabetes": "metabolic", - "glucose": "metabolic", - "insulin": "metabolic", - "metabolic": "metabolic", - - # Mobility and physical limitations - "arthritis": "mobility", - "joint": "mobility", - "mobility": "mobility", - "amputation": "mobility", - "acl": "mobility", - "reconstruction": "mobility", - "knee": "mobility", - - # Anticoagulation therapy - "dvt": "anticoagulation", - "deep vein thrombosis": "anticoagulation", - "thrombosis": "anticoagulation", - "blood clot": "anticoagulation", - "anticoagulation": "anticoagulation", - "warfarin": "anticoagulation", - "rivaroxaban": "anticoagulation", - - # Obesity and weight management - "obesity": "obesity", - "overweight": "obesity", - "weight": "obesity", - "bmi": "obesity" - } - - for condition in conditions: - condition_lower = condition.lower() - for keyword, category in condition_mapping.items(): - if keyword in condition_lower: - categories[category].append(condition) - break - - return categories - - def _assess_risk_factors(self, profile: "LifestyleProfile") -> List[str]: - """Identify key risk factors requiring special attention""" - - risk_factors = [] - - # Medical risk factors - high_risk_conditions = [ - "anticoagulation", "bleeding risk", "fall risk", - "uncontrolled diabetes", "severe hypertension" - ] - - for condition in profile.conditions: - condition_lower = condition.lower() - for risk in high_risk_conditions: - if risk in condition_lower: - risk_factors.append(risk) - - # Exercise limitation risks - for limitation in profile.exercise_limitations: - limitation_lower = limitation.lower() - if any(word in limitation_lower for word in ["avoid", "risk", "bleeding", "fall"]): - risk_factors.append("exercise_restriction") - if "anticoagulation" in limitation_lower or "blood thinner" in limitation_lower: - risk_factors.append("anticoagulation") - if "bleeding" in limitation_lower: - risk_factors.append("bleeding risk") - - return list(set(risk_factors)) # Remove duplicates - - def _extract_preferences(self, preferences: List[str]) -> Dict[str, bool]: - """Extract communication and approach preferences""" - - extracted = { - "data_driven": False, - "detailed_explanations": False, - "gradual_approach": False, - "intellectual_curiosity": False, - "visual_learner": False, - "technology_comfortable": False - } - - if not preferences: - return extracted - - preferences_text = " ".join(preferences).lower() - - # Strategic preference detection - preference_keywords = { - "data_driven": ["data", "tracking", "numbers", "metrics", "evidence"], - "detailed_explanations": ["understand", "explain", "detail", "thorough"], - "gradual_approach": ["gradual", "slow", "step", "progressive", "gentle"], - "intellectual_curiosity": ["intellectual", "research", "study", "learn"], - "visual_learner": ["visual", "charts", "graphs", "pictures"], - "technology_comfortable": ["app", "digital", "online", "technology"] - } - - for preference, keywords in preference_keywords.items(): - if any(keyword in preferences_text for keyword in keywords): - extracted[preference] = True - - return extracted - - def _determine_communication_style(self, profile: "LifestyleProfile") -> str: - """Determine optimal communication style for patient""" - - # Analyze various indicators - if profile.personal_preferences: - prefs_text = " ".join(profile.personal_preferences).lower() - - if "intellectual" in prefs_text or "professor" in prefs_text: - return "analytical_detailed" - elif "gradual" in prefs_text or "careful" in prefs_text: - return "supportive_gentle" - elif "data" in prefs_text or "tracking" in prefs_text: - return "data_focused" - - # Default to supportive approach for medical context - return "supportive_encouraging" - - def _assess_progress_stage(self, profile: "LifestyleProfile") -> str: - """Assess patient's current progress stage""" - - # Analyze journey summary and last session - if profile.journey_summary: - journey_lower = profile.journey_summary.lower() - - if "maintenance" in journey_lower: - return "maintenance" - elif "established" in journey_lower or "consistent" in journey_lower: - return "established_routine" - elif "progress" in journey_lower or "improving" in journey_lower: - return "active_progress" - - if profile.last_session_summary: - if "first" in profile.last_session_summary.lower(): - return "initial_assessment" - - return "active_coaching" - - def _evaluate_motivation(self, profile: "LifestyleProfile") -> str: - """Evaluate patient motivation level""" - - # Analyze progress metrics and journey summary - motivation_indicators = { - "high": ["motivated", "committed", "dedicated", "consistent"], - "moderate": ["trying", "working", "attempting"], - "low": ["struggling", "difficult", "challenges"] - } - - text_to_analyze = f"{profile.journey_summary} {profile.last_session_summary}".lower() - - for level, indicators in motivation_indicators.items(): - if any(indicator in text_to_analyze for indicator in indicators): - return level - - return "moderate" # Default assumption - - def _calculate_complexity_score(self, profile: "LifestyleProfile") -> int: - """Calculate patient complexity score for prompt adaptation""" - - complexity = 0 - - # Medical complexity - complexity += len(profile.conditions) * 2 - complexity += len(profile.exercise_limitations) - - # Risk factors - if any("anticoagulation" in str(limitation).lower() for limitation in profile.exercise_limitations): - complexity += 3 - - # Preference complexity - if profile.personal_preferences: - complexity += len(profile.personal_preferences) - - return min(complexity, 20) # Cap at 20 \ No newline at end of file diff --git a/src/config/rollout_controller.py b/src/config/rollout_controller.py deleted file mode 100644 index 9b95955cf2ef492824413288da74684d4f9887b5..0000000000000000000000000000000000000000 --- a/src/config/rollout_controller.py +++ /dev/null @@ -1,95 +0,0 @@ -# rollout_controller.py -import os -import time -from datetime import datetime, timedelta -from src.config.dynamic import get_config_manager, get_rollout_percentage - -class ProductionRolloutController: - """Automated rollout controller with safety monitoring""" - - def __init__(self): - self.config_manager = get_config_manager() - self.safety_thresholds = { - 'max_error_rate': 0.01, # 1% maximum error rate - 'min_safety_validation_rate': 0.995, # 99.5% safety validation - 'max_fallback_rate': 0.10 # 10% maximum fallback rate - } - self.rollout_schedule = [5, 15, 35, 75, 100] - self.current_stage = 0 - - def check_safety_metrics(self): - """Check current safety metrics against thresholds""" - # In real implementation, this would query monitoring systems - # Simplified for demonstration - - metrics = { - 'error_rate': 0.005, # 0.5% error rate - 'safety_validation_rate': 0.998, # 99.8% safety validation - 'fallback_rate': 0.05 # 5% fallback rate - } - - safety_ok = ( - metrics['error_rate'] <= self.safety_thresholds['max_error_rate'] and - metrics['safety_validation_rate'] >= self.safety_thresholds['min_safety_validation_rate'] and - metrics['fallback_rate'] <= self.safety_thresholds['max_fallback_rate'] - ) - - return safety_ok, metrics - - def advance_rollout_stage(self): - """Advance to next rollout stage if safety metrics are acceptable""" - - from src.config.dynamic import get_rollout_percentage - - print(f"=== ROLLOUT STAGE {self.current_stage + 1} EVALUATION ===") - print(f"Current rollout: {get_rollout_percentage()}%") - - # Check safety metrics - safety_ok, metrics = self.check_safety_metrics() - - print(f"Safety Metrics:") - print(f" Error rate: {metrics['error_rate']:.3f} (threshold: {self.safety_thresholds['max_error_rate']:.3f})") - print(f" Safety validation: {metrics['safety_validation_rate']:.3f} (threshold: {self.safety_thresholds['min_safety_validation_rate']:.3f})") - print(f" Fallback rate: {metrics['fallback_rate']:.3f} (threshold: {self.safety_thresholds['max_fallback_rate']:.3f})") - - if not safety_ok: - print("❌ Safety metrics do not meet thresholds - rollout advancement blocked") - return False - - # Advance to next stage - if self.current_stage < len(self.rollout_schedule) - 1: - self.current_stage += 1 - new_percentage = self.rollout_schedule[self.current_stage] - - # Update the rollout percentage directly through the config attribute - if 0 <= new_percentage <= 100: - self.config_manager.config.rollout_percentage = new_percentage - print(f"✅ Rollout advanced to {new_percentage}%") - return True - else: - print(f"❌ Invalid rollout percentage: {new_percentage}") - return False - else: - print("✅ Rollout complete at 100%") - return True - - def emergency_rollback(self): - """Emergency rollback to 0% if critical issues detected""" - print("🚨 EMERGENCY ROLLBACK INITIATED") - - # Set rollout percentage to 0 through the config attribute - self.config_manager.config.rollout_percentage = 0 - print("✅ Emergency rollback to 0% completed") - return True - -# Usage example -if __name__ == "__main__": - controller = ProductionRolloutController() - - # Check if advancement is possible - advancement_success = controller.advance_rollout_stage() - - if advancement_success: - print("Rollout advancement successful") - else: - print("Rollout advancement blocked or failed") \ No newline at end of file diff --git a/src/core/combined_assistant.py b/src/core/combined_assistant.py deleted file mode 100644 index 40a16a57c403bdade7a4a980b47feeca9c893b9f..0000000000000000000000000000000000000000 --- a/src/core/combined_assistant.py +++ /dev/null @@ -1,391 +0,0 @@ -# combined_assistant.py -""" -Combined Assistant - Coordinates Lifestyle and Spiritual Assistants - -Manages parallel execution of both assistants and combines their results -with intelligent prioritization based on detected indicators. - -Requirements: 2.1, 2.2, 2.3, 2.4, 2.5, 6.1, 6.2, 6.3, 6.4, 6.5 -""" - -import logging -from typing import Dict, Any, List, Optional -from datetime import datetime - -from src.core.spiritual_assistant import SpiritualAssistant - -# Configure logging -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - - -class CombinedAssistant: - """ - Coordinates Lifestyle and Spiritual assistants in combined mode. - - Invokes both assistants, combines their results, and determines - response priority based on detected indicators (especially red flags). - - Requirements: 2.1, 2.2, 2.3, 2.4, 2.5, 6.1, 6.2, 6.3, 6.4, 6.5 - """ - - def __init__( - self, - lifestyle_assistant, # MainLifestyleAssistant - spiritual_assistant: SpiritualAssistant - ): - """ - Initialize Combined Assistant. - - Args: - lifestyle_assistant: MainLifestyleAssistant instance - spiritual_assistant: SpiritualAssistant instance - """ - self.lifestyle = lifestyle_assistant - self.spiritual = spiritual_assistant - logger.info("🌟 CombinedAssistant initialized") - - def process_message( - self, - message: str, - chat_history: List, - clinical_background, - lifestyle_profile, - session_length: int - ) -> Dict[str, Any]: - """ - Process message with both assistants and combine results. - - Invokes both Lifestyle and Spiritual assistants in parallel, - analyzes their results, determines priority, and combines - responses appropriately. - - Args: - message: User's message - chat_history: Chat history - clinical_background: Patient's clinical context - lifestyle_profile: Patient's lifestyle profile - session_length: Current session length - - Returns: - { - "message": str, # Combined response - "lifestyle_result": Dict, - "spiritual_result": Dict, - "priority": str, # "lifestyle", "spiritual", "balanced" - "action": str, # "continue", "escalate_spiritual", "close" - "reasoning": str - } - - Requirements: 2.1, 2.2, 6.1, 6.2 - """ - logger.info(f"Processing combined message: {message[:50]}...") - - # Invoke both assistants (Requirement 6.1) - lifestyle_result = self._invoke_lifestyle( - message, chat_history, clinical_background, lifestyle_profile, session_length - ) - - spiritual_result = self._invoke_spiritual( - message, chat_history, clinical_background - ) - - # Determine priority (Requirements 6.2, 6.3) - priority = self._determine_priority(lifestyle_result, spiritual_result) - - # Determine action (Requirements 2.3, 6.3) - action = self._determine_action(lifestyle_result, spiritual_result, priority) - - # Combine responses (Requirements 2.2, 2.5, 6.4) - combined_message = self._combine_responses( - lifestyle_result, spiritual_result, priority - ) - - # Create reasoning - reasoning = self._create_reasoning(lifestyle_result, spiritual_result, priority) - - return { - "message": combined_message, - "lifestyle_result": lifestyle_result, - "spiritual_result": spiritual_result, - "priority": priority, - "action": action, - "reasoning": reasoning - } - - def _invoke_lifestyle( - self, - message: str, - chat_history: List, - clinical_background, - lifestyle_profile, - session_length: int - ) -> Dict[str, Any]: - """ - Invoke Lifestyle Assistant. - - Handles errors gracefully and returns error result if needed. - - Requirement: 6.5 - """ - try: - logger.info("Invoking Lifestyle Assistant...") - result = self.lifestyle.process_message( - message, - chat_history, - clinical_background, - lifestyle_profile, - session_length - ) - logger.info(f"Lifestyle result: action={result.get('action', 'unknown')}") - return result - except Exception as e: - logger.error(f"Lifestyle Assistant error: {e}", exc_info=True) - return { - "message": "Lifestyle support temporarily unavailable.", - "action": "error", - "reasoning": f"Error: {str(e)}", - "error": True - } - - def _invoke_spiritual( - self, - message: str, - chat_history: List, - clinical_background - ) -> Dict[str, Any]: - """ - Invoke Spiritual Assistant. - - Handles errors gracefully and returns error result if needed. - - Requirement: 6.5 - """ - try: - logger.info("Invoking Spiritual Assistant...") - result = self.spiritual.process_message( - message, - chat_history, - clinical_background - ) - logger.info(f"Spiritual result: action={result.get('action', 'unknown')}") - return result - except Exception as e: - logger.error(f"Spiritual Assistant error: {e}", exc_info=True) - return { - "message": "Spiritual support temporarily unavailable.", - "action": "error", - "reasoning": f"Error: {str(e)}", - "classification": None, - "error": True - } - - def _determine_priority( - self, - lifestyle_result: Dict[str, Any], - spiritual_result: Dict[str, Any] - ) -> str: - """ - Determine response priority based on results. - - Priority rules: - 1. Spiritual red flag → "spiritual" (highest priority) - 2. Lifestyle close action → "lifestyle" - 3. Both normal → "balanced" - 4. One error → use successful one - - Requirements: 2.3, 2.4, 6.2, 6.3 - """ - # Check for errors - lifestyle_error = lifestyle_result.get("error", False) - spiritual_error = spiritual_result.get("error", False) - - if lifestyle_error and spiritual_error: - return "balanced" # Both failed, show both errors - elif lifestyle_error: - return "spiritual" # Only spiritual worked - elif spiritual_error: - return "lifestyle" # Only lifestyle worked - - # Check for spiritual red flag (Requirement 2.3, 6.3) - spiritual_classification = spiritual_result.get("classification") - if spiritual_classification and spiritual_classification.flag_level == "red": - logger.info("Priority: SPIRITUAL (red flag detected)") - return "spiritual" - - # Check for spiritual escalation - if spiritual_result.get("action") == "escalate": - logger.info("Priority: SPIRITUAL (escalation needed)") - return "spiritual" - - # Check for lifestyle close action (Requirement 6.3) - if lifestyle_result.get("action") == "close": - logger.info("Priority: LIFESTYLE (session closing)") - return "lifestyle" - - # Default to balanced (Requirement 6.4) - logger.info("Priority: BALANCED (both normal)") - return "balanced" - - def _determine_action( - self, - lifestyle_result: Dict[str, Any], - spiritual_result: Dict[str, Any], - priority: str - ) -> str: - """ - Determine overall action based on priority. - - Actions: - - "escalate_spiritual": Spiritual red flag needs immediate attention - - "close": Lifestyle wants to close session - - "continue": Normal continuation - - Requirement: 2.3 - """ - if priority == "spiritual": - if spiritual_result.get("action") == "escalate": - return "escalate_spiritual" - - if priority == "lifestyle": - if lifestyle_result.get("action") == "close": - return "close" - - return "continue" - - def _combine_responses( - self, - lifestyle_result: Dict[str, Any], - spiritual_result: Dict[str, Any], - priority: str - ) -> str: - """ - Combine responses based on priority. - - Formats: - - spiritual priority: Spiritual first (prominent), then lifestyle - - lifestyle priority: Lifestyle first (prominent), then spiritual - - balanced: Both equally prominent - - Handles partial failures with user notifications. - - Requirements: 2.2, 2.5, 6.4, 11.1 - """ - lifestyle_msg = lifestyle_result.get("message", "") - spiritual_msg = spiritual_result.get("message", "") - - # Check for errors (Requirement: 11.1) - lifestyle_error = lifestyle_result.get("error", False) - spiritual_error = spiritual_result.get("error", False) - - # Both failed - if lifestyle_error and spiritual_error: - return f"""⚠️ **Combined Support Temporarily Unavailable** - -Both lifestyle and spiritual assistants are experiencing issues. - -💚 **Lifestyle:** {lifestyle_msg} - -🕊️ **Spiritual:** {spiritual_msg} - -Please try again or switch to Medical mode for immediate assistance.""" - - # Only lifestyle failed - if lifestyle_error: - return f"""🕊️ **Spiritual Assessment (Lifestyle Temporarily Unavailable)** - -{spiritual_msg} - ---- - -⚠️ **Lifestyle Support:** {lifestyle_msg} - -Spiritual assessment is available. Lifestyle support will return shortly.""" - - # Only spiritual failed - if spiritual_error: - return f"""💚 **Lifestyle Coaching (Spiritual Temporarily Unavailable)** - -{lifestyle_msg} - ---- - -⚠️ **Spiritual Support:** {spiritual_msg} - -Lifestyle coaching is available. Spiritual assessment will return shortly.""" - - if priority == "spiritual": - # Spiritual takes precedence (Requirement 2.4) - return f"""{spiritual_msg} - ---- - -💚 **Lifestyle Support** - -{lifestyle_msg}""" - - elif priority == "lifestyle": - # Lifestyle takes precedence - return f"""{lifestyle_msg} - ---- - -🕊️ **Spiritual Wellness Check** - -{spiritual_msg}""" - - else: # balanced - # Both equally prominent (Requirement 2.5) - return f"""🌟 **Comprehensive Support** - -💚 **Lifestyle Coaching:** - -{lifestyle_msg} - ---- - -🕊️ **Spiritual Wellness:** - -{spiritual_msg}""" - - def _create_reasoning( - self, - lifestyle_result: Dict[str, Any], - spiritual_result: Dict[str, Any], - priority: str - ) -> str: - """Create reasoning explanation for the combined result""" - reasons = [] - - # Add lifestyle reasoning - lifestyle_reasoning = lifestyle_result.get("reasoning", "") - if lifestyle_reasoning: - reasons.append(f"Lifestyle: {lifestyle_reasoning}") - - # Add spiritual reasoning - spiritual_reasoning = spiritual_result.get("reasoning", "") - if spiritual_reasoning: - reasons.append(f"Spiritual: {spiritual_reasoning}") - - # Add priority reasoning - reasons.append(f"Priority: {priority}") - - return " | ".join(reasons) - - -# Convenience function -def create_combined_assistant( - lifestyle_assistant, - spiritual_assistant: SpiritualAssistant -) -> CombinedAssistant: - """ - Create and return a CombinedAssistant instance. - - Args: - lifestyle_assistant: MainLifestyleAssistant instance - spiritual_assistant: SpiritualAssistant instance - - Returns: - Initialized CombinedAssistant - """ - return CombinedAssistant(lifestyle_assistant, spiritual_assistant) diff --git a/src/core/core_classes.py b/src/core/core_classes.py deleted file mode 100644 index bb59dbea060b61fb19bb6d918b6b29ad033c2d4f..0000000000000000000000000000000000000000 --- a/src/core/core_classes.py +++ /dev/null @@ -1,1861 +0,0 @@ -# core_classes.py - Enhanced with Strategic Dynamic Prompt Composition -""" -Enterprise Medical AI Architecture: Enhanced Core Classes - -Strategic Evolution: Minimal invasive integration of intelligent prompt personalization - -Architectural Philosophy: "Preserve operational stability while enabling transformational capability" -- Zero breaking changes: All existing interfaces and behaviors preserved -- Graceful enhancement: Dynamic composition as optional layer with fallback mechanisms -- Medical safety first: Uncompromising safety protocols embedded at every integration point -- Professional transparency: Clear audit trail for medical professional oversight -""" - -import os -import json -import time -import asyncio -import traceback -from datetime import datetime -from dataclasses import dataclass, asdict -from typing import Dict, List, Optional, Any, Union, Tuple, Callable, TypeVar, Type, TYPE_CHECKING -from enum import Enum - -# Import AIClientManager for type hints -if TYPE_CHECKING: - from src.core.ai_client import AIClientManager - from src.core.spiritual_classes import DistressClassification, ReferralMessage - -import re - -# === NEW DYNAMIC COMPOSITION IMPORTS === -# These imports are conditional to avoid breaking existing deployments -try: - from src.prompts.types import ( - ClassificationContext, PromptCompositionSpec, - DynamicPromptConfig, SafetyLevel - ) - from src.prompts.classifier import LLMPromptClassifier - from src.prompts.assembler import DynamicTemplateAssembler - DYNAMIC_COMPONENTS_AVAILABLE = True -except ImportError as e: - # Graceful degradation when dynamic components are not available - DYNAMIC_COMPONENTS_AVAILABLE = False - # Define a dummy config for when components are missing - class DynamicPromptConfig: - DEBUG_MODE = False - ENABLED = False - CACHE_ENABLED = False - REQUIRE_SAFETY_VALIDATION = True - print(f"ℹ️ Dynamic prompt composition not available: {e}") - -# For global status monitoring, aliasing new name to old name -DYNAMIC_PROMPTS_AVAILABLE = DYNAMIC_COMPONENTS_AVAILABLE - -# AI Client Management - Multi-Provider Architecture -from src.core.ai_client import UniversalAIClient, create_ai_client - -# Core Medical Data Structures - Preserved Legacy Architecture -from src.config.prompts import ( - # Active classifiers - SYSTEM_PROMPT_ENTRY_CLASSIFIER, - PROMPT_ENTRY_CLASSIFIER, - SYSTEM_PROMPT_TRIAGE_EXIT_CLASSIFIER, - PROMPT_TRIAGE_EXIT_CLASSIFIER, - # Lifestyle Profile Update - SYSTEM_PROMPT_LIFESTYLE_PROFILE_UPDATER, - PROMPT_LIFESTYLE_PROFILE_UPDATE, - # Main Lifestyle Assistant - Static Fallback - SYSTEM_PROMPT_MAIN_LIFESTYLE, - PROMPT_MAIN_LIFESTYLE, - # Medical assistants - SYSTEM_PROMPT_SOFT_MEDICAL_TRIAGE, - PROMPT_SOFT_MEDICAL_TRIAGE, - SYSTEM_PROMPT_MEDICAL_ASSISTANT, - PROMPT_MEDICAL_ASSISTANT -) - -try: - from app_config import API_CONFIG -except ImportError: - API_CONFIG = {"gemini_model": "gemini-2.5-flash", "temperature": 0.3} - -# ===== ENHANCED DATA STRUCTURES ===== - -# ===== ASSISTANT MODE ENUM ===== - -class AssistantMode(Enum): - """ - Режими роботи асистента. - - Визначає доступні режими для обробки повідомлень користувача: - - NONE: Режим не визначено - - MEDICAL: Медичний режим для обробки медичних питань - - LIFESTYLE: Режим lifestyle рекомендацій - - SPIRITUAL: Режим оцінки духовного дистресу - - COMBINED: Комбінований режим (Lifestyle + Spiritual) - """ - NONE = "none" - MEDICAL = "medical" - LIFESTYLE = "lifestyle" - SPIRITUAL = "spiritual" - COMBINED = "combined" - -@dataclass -class ClinicalBackground: - """Enhanced clinical background with composition context tracking""" - patient_id: str - patient_name: str = "" - patient_age: str = "" - active_problems: List[str] = None - past_medical_history: List[str] = None - current_medications: List[str] = None - allergies: str = "" - vital_signs_and_measurements: List[str] = None - laboratory_results: List[str] = None - assessment_and_plan: str = "" - critical_alerts: List[str] = None - social_history: Dict = None - recent_clinical_events: List[str] = None - - # NEW: Composition context for enhanced prompt generation - prompt_composition_history: List[Dict] = None - - def __post_init__(self): - if self.active_problems is None: - self.active_problems = [] - if self.past_medical_history is None: - self.past_medical_history = [] - if self.current_medications is None: - self.current_medications = [] - if self.vital_signs_and_measurements is None: - self.vital_signs_and_measurements = [] - if self.laboratory_results is None: - self.laboratory_results = [] - if self.critical_alerts is None: - self.critical_alerts = [] - if self.recent_clinical_events is None: - self.recent_clinical_events = [] - if self.social_history is None: - self.social_history = {} - if self.prompt_composition_history is None: - self.prompt_composition_history = [] - -@dataclass -class LifestyleProfile: - """Enhanced lifestyle profile with composition optimization tracking""" - patient_name: str - patient_age: str - conditions: List[str] - primary_goal: str - exercise_preferences: Optional[List[str]] = None - exercise_limitations: Optional[List[str]] = None - dietary_notes: Optional[List[str]] = None - personal_preferences: Optional[List[str]] = None - journey_summary: str = "" - last_session_summary: str = "" - next_check_in: str = "not set" - progress_metrics: Dict[str, str] = None - - # NEW: Prompt optimization tracking - prompt_effectiveness_scores: Dict[str, float] = None - communication_style_preferences: Dict[str, bool] = None - - def __post_init__(self): - if self.conditions is None: - self.conditions = [] - if self.progress_metrics is None: - self.progress_metrics = {} - if self.prompt_effectiveness_scores is None: - self.prompt_effectiveness_scores = {} - if self.communication_style_preferences is None: - self.communication_style_preferences = {} - if self.exercise_preferences is None: - self.exercise_preferences = [] - if self.exercise_limitations is None: - self.exercise_limitations = [] - if self.dietary_notes is None: - self.dietary_notes = [] - if self.personal_preferences is None: - self.personal_preferences = [] - -@dataclass -class ChatMessage: - """Enhanced chat message with composition context""" - timestamp: str - role: str - message: str - mode: str - metadata: Dict = None - - # NEW: Prompt composition tracking - prompt_composition_id: Optional[str] = None - composition_effectiveness_score: Optional[float] = None - -@dataclass -class SessionState: - """Enhanced session state with dynamic prompt context and multi-mode support""" - current_mode: 'AssistantMode' # Changed from str to AssistantMode enum - is_active_session: bool - session_start_time: Optional[str] - last_controller_decision: Dict - - # Lifecycle management - lifestyle_session_length: int = 0 - last_triage_summary: str = "" - entry_classification: Dict = None - - # Spiritual state (NEW) - spiritual_assessment: Optional['DistressClassification'] = None - spiritual_referral: Optional['ReferralMessage'] = None - spiritual_questions: List[str] = None - - # Combined mode state (NEW) - combined_results: Dict[str, Any] = None - active_assistants: List[str] = None - - # Dynamic prompt composition state - current_prompt_composition_id: Optional[str] = None - composition_analytics: Dict = None - - def __post_init__(self): - if self.entry_classification is None: - self.entry_classification = {} - if self.composition_analytics is None: - self.composition_analytics = {} - if self.spiritual_questions is None: - self.spiritual_questions = [] - if self.combined_results is None: - self.combined_results = {} - if self.active_assistants is None: - self.active_assistants = [] - - def reset(self): - """Скидає стан сесії, очищуючи всі поля""" - self.is_active_session = False - self.session_start_time = None - self.last_controller_decision = {} - self.lifestyle_session_length = 0 - self.last_triage_summary = "" - self.entry_classification = {} - self.spiritual_assessment = None - self.spiritual_referral = None - self.spiritual_questions = [] - self.combined_results = {} - self.active_assistants = [] - self.current_prompt_composition_id = None - self.composition_analytics = {} - - def update_mode(self, new_mode: 'AssistantMode'): - """Оновлює поточний режим роботи""" - self.current_mode = new_mode - # Оновлюємо список активних асистентів - if new_mode == AssistantMode.COMBINED: - self.active_assistants = ["lifestyle", "spiritual"] - elif new_mode == AssistantMode.LIFESTYLE: - self.active_assistants = ["lifestyle"] - elif new_mode == AssistantMode.SPIRITUAL: - self.active_assistants = ["spiritual"] - elif new_mode == AssistantMode.MEDICAL: - self.active_assistants = ["medical"] - else: - self.active_assistants = [] - - def get_active_assistants(self) -> List[str]: - """Повертає список активних асистентів""" - return self.active_assistants.copy() - -# ===== ENHANCED LIFESTYLE ASSISTANT WITH DYNAMIC PROMPTS ===== - -class EnhancedMainLifestyleAssistant: - """ - Strategic Enhancement: Intelligent Lifestyle Assistant with Optional Dynamic Prompt Composition - - Core Enhancement Philosophy: - - Preserve 100% backward compatibility with existing system - - Add intelligent composition as optional enhancement layer - - Maintain multiple fallback mechanisms for maximum reliability - - Enable gradual adoption through environment-driven configuration - - Architectural Strategy: - - Configuration-driven feature activation (default: disabled) - - Multiple safety nets ensure system never fails due to dynamic features - - Clear separation between static and dynamic modes - - Comprehensive audit trail for medical professional oversight - """ - - def __init__(self, api: 'AIClientManager'): - """ - Initialize enhanced assistant with optional dynamic composition - - Initialization Strategy: - - Always initialize core functionality first - - Conditionally add dynamic features based on availability and configuration - - Ensure system operates normally even if dynamic features fail - """ - self.api = api - - # === EXISTING FUNCTIONALITY PRESERVED UNCHANGED === - self.custom_system_prompt = None - self.default_system_prompt = SYSTEM_PROMPT_MAIN_LIFESTYLE - # Ensure dynamic prompt logging initialized if enabled - self._log_dynamic_marker("[DYNAMIC_PROMPT] logger_initialized") - - # === DYNAMIC COMPOSITION LAYER (OPTIONAL) === - self.dynamic_composition_enabled = self._evaluate_dynamic_composition_readiness() - # Enable dynamic mode automatically for mock/testing clients that are not AIClientManager - from src.core.ai_client import AIClientManager - if not isinstance(api, AIClientManager): - self.dynamic_composition_enabled = True - - # Initialize dynamic components if available and enabled - if self.dynamic_composition_enabled: - try: - self.prompt_classifier = LLMPromptClassifier(api) - self.template_assembler = DynamicTemplateAssembler(api) - self.composition_performance_tracker = CompositionPerformanceTracker() - print("✅ Dynamic prompt composition successfully enabled") - - except Exception as e: - # Graceful fallback if dynamic initialization fails - print(f"⚠️ Dynamic composition initialization failed: {e}") - self._disable_dynamic_composition() - else: - self._initialize_static_mode() - - def _evaluate_dynamic_composition_readiness(self) -> bool: - """ - Strategic assessment of dynamic composition readiness - - Evaluation Criteria: - - Dynamic components available (imported successfully) - - Environment configuration enables feature - - System resources adequate for additional processing - - Medical safety validation systems operational - """ - - # Check 1: Component availability - if not DYNAMIC_COMPONENTS_AVAILABLE: - if DynamicPromptConfig.DEBUG_MODE: - print("🔍 Dynamic components not available - using static mode") - return False - - # Check 2: Environment configuration - try: - from src.config.dynamic import get_dynamic_prompt_config - cfg = get_dynamic_prompt_config() - config_enabled = cfg.enabled - except Exception: - config_enabled = DynamicPromptConfig.ENABLED - - if not config_enabled: - if DynamicPromptConfig.DEBUG_MODE: - print("🔍 Dynamic composition disabled by configuration") - return False - - # Check 3: API client readiness for additional LLM calls - if not self.api: - print("⚠️ API client not available - dynamic composition requires LLM access") - return False - - # Check 4: Medical safety systems operational - try: - # Verify safety validation systems are working - if hasattr(self, '_test_safety_systems'): - safety_test_passed = self._test_safety_systems() - if not safety_test_passed: - print("⚠️ Safety validation systems not operational") - return False - except Exception: - # Safety test failed - conservative fallback - return False - - return True - - def _initialize_static_mode(self): - """Initialize in static-only mode (existing functionality)""" - self.prompt_classifier = None - self.template_assembler = None - self.composition_performance_tracker = StaticModeTracker() - - if DynamicPromptConfig.DEBUG_MODE: - print("📊 Initialized in static prompt mode") - - def _disable_dynamic_composition(self): - """Gracefully disable dynamic composition due to failure""" - self.dynamic_composition_enabled = False - self.prompt_classifier = None - self.template_assembler = None - self.composition_performance_tracker = FailsafeTracker() - print("🔄 Fallback to static prompt mode activated") - - # --- Lightweight logging for dynamic prompt transparency --- - def _log_dynamic_marker(self, message: str, detail: Optional[str] = None): - """Write a short marker to dynamic prompts log when enabled.""" - try: - import logging, os - if os.getenv("LOG_PROMPTS", "false").lower() != "true": - return - logger = logging.getLogger("dynamic_prompts") - if not logger.handlers: - logger.setLevel(logging.INFO) - fh = logging.FileHandler('dynamic_prompts.log', encoding='utf-8') - fh.setFormatter(logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')) - logger.addHandler(fh) - if detail: - logger.info("%s detail=%s", message, detail) - else: - logger.info(message) - except Exception: - # Never break flow due to logging - pass - - # === EXISTING METHODS PRESERVED UNCHANGED === - - def set_custom_system_prompt(self, custom_prompt: str): - """Set custom system prompt - EXISTING FUNCTIONALITY PRESERVED""" - self.custom_system_prompt = custom_prompt.strip() if custom_prompt and custom_prompt.strip() else None - - if DynamicPromptConfig.DEBUG_MODE and self.custom_system_prompt: - print("🔧 Custom system prompt activated - dynamic composition will be bypassed") - - def reset_to_default_prompt(self): - """Reset to default system prompt - EXISTING FUNCTIONALITY PRESERVED""" - self.custom_system_prompt = None - - if DynamicPromptConfig.DEBUG_MODE: - print("🔄 Reset to default prompt - dynamic composition re-enabled if available") - - # === ENHANCED METHOD: INTELLIGENT PROMPT RETRIEVAL === - - def get_current_system_prompt(self, - lifestyle_profile: Optional[LifestyleProfile] = None, - clinical_background: Optional[ClinicalBackground] = None, - session_context: Optional[Dict] = None) -> str: - """ - Enhanced prompt retrieval with intelligent dynamic composition - - Strategic Priority Hierarchy (Medical Safety First): - 1. Custom prompt (highest priority - professional/user override) - 2. Dynamic composed prompt (intelligent personalization when available) - 3. Static default prompt (reliable fallback - always available) - - Backward Compatibility Guarantee: - - Method signature unchanged - existing code continues to work - - Return type unchanged - always returns valid prompt string - - Behavior unchanged when dynamic features disabled - """ - - try: - # Priority 1: Custom prompt always takes absolute precedence - if self.custom_system_prompt: - if DynamicPromptConfig.DEBUG_MODE: - print("📋 Using custom system prompt (highest priority)") - return self.custom_system_prompt - - # Priority 2: Dynamic composition (if enabled and context available) - if self._should_attempt_dynamic_composition(session_context, lifestyle_profile): - fallback_reason = None - try: - dynamic_prompt = self._generate_dynamic_prompt( - session_context, clinical_background, lifestyle_profile - ) - - if dynamic_prompt: - self.composition_performance_tracker.record_success() - if DynamicPromptConfig.DEBUG_MODE: - print("🧠 Using dynamically composed prompt") - # Marker: dynamic prompt successfully used - self._log_dynamic_marker("[DYNAMIC_PROMPT] used=success") - return dynamic_prompt - - except Exception as e: - # Log dynamic composition failure but continue gracefully - self.composition_performance_tracker.record_failure(str(e)) - if DynamicPromptConfig.DEBUG_MODE: - print(f"⚠️ Dynamic composition failed: {e}") - traceback.print_exc() - fallback_reason = str(e) - - # Priority 3: Static default prompt (always reliable) - if DynamicPromptConfig.DEBUG_MODE: - print("📄 Using static default prompt") - # If dynamic was attempted but failed, and we're not explicitly in failsafe/static mode - attempted = self._should_attempt_dynamic_composition(session_context, lifestyle_profile) - if attempted and lifestyle_profile is not None and not isinstance(self.composition_performance_tracker, FailsafeTracker): - suffix = f"\n\n[Dynamic Context]\nPatient: {getattr(lifestyle_profile, 'patient_name', 'Пацієнт')}\nMode: static-fallback" - reason = fallback_reason or "no_dynamic_prompt_returned" - self._log_dynamic_marker("[DYNAMIC_PROMPT] fallback=static", reason) - return (self.default_system_prompt or "") + suffix - return self.default_system_prompt - - except Exception as e: - # Ultimate safety net - ensure method never fails - print(f"🚨 Error in prompt retrieval, using safe default: {e}") - return self.default_system_prompt - - def _should_attempt_dynamic_composition(self, - session_context: Optional[Dict], - lifestyle_profile: Optional[LifestyleProfile]) -> bool: - """Determine if dynamic composition should be attempted""" - if not self.dynamic_composition_enabled: - return False - if lifestyle_profile is None: - return False - return True - - def _generate_dynamic_prompt(self, - session_context: Dict, - clinical_background: Optional[ClinicalBackground], - lifestyle_profile: LifestyleProfile) -> Optional[str]: - """ - Generate dynamically composed prompt with comprehensive error handling - - Process Flow: - 1. Convert profile objects to standardized dictionary format - 2. Create classification context for LLM analysis - 3. Perform intelligent classification of session requirements - 4. Assemble personalized prompt from selected components - 5. Validate medical safety compliance - 6. Return assembled prompt or None if any step fails - """ - - try: - # If no session_context provided, create a minimal one - if session_context is None: - session_context = { - 'patient_request': 'Lifestyle coaching request', - 'timestamp': datetime.now().isoformat(), - 'metadata': {} - } - - # If classifier/assembler are unavailable, produce a simple enhanced prompt - if not getattr(self, 'prompt_classifier', None) or not getattr(self, 'template_assembler', None): - base = self.default_system_prompt - profile_name = getattr(lifestyle_profile, 'patient_name', 'Пацієнт') - extra = f"\n\n[Dynamic Context]\nPatient: {profile_name}\nGenerated: {datetime.now().isoformat()}" - return base + extra - # Step 1: Convert profile objects to dictionary format - clinical_data = self._convert_clinical_profile(clinical_background) - lifestyle_data = self._convert_lifestyle_profile(lifestyle_profile) - - # Step 2: Create comprehensive classification context - classification_context = ClassificationContext( - patient_request=session_context['patient_request'], - clinical_background=clinical_data, - lifestyle_profile=lifestyle_data, - session_metadata=session_context.get('metadata', {}) - ) - - # Step 3: LLM-based classification (with async handling if needed) - if asyncio.iscoroutinefunction(self.prompt_classifier.classify_session_requirements): - # Handle async classification - try: - loop = asyncio.get_running_loop() - except RuntimeError: # 'RuntimeError: There is no current event loop...' - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - - classification_spec = loop.run_until_complete( - self.prompt_classifier.classify_session_requirements(classification_context) - ) - else: - # Handle sync classification - classification_spec = self.prompt_classifier.classify_session_requirements( - classification_context - ) - - # Step 4: Dynamic assembly based on classification - assembly_result = self.template_assembler.assemble_personalized_prompt( - classification_spec, clinical_data, lifestyle_data - ) - - # Step 5: Validate assembly safety and quality - if not assembly_result.safety_validated: - print("⚠️ Dynamic prompt failed safety validation") - return None - - # Step 6: Log successful composition for monitoring - if DynamicPromptConfig.DEBUG_MODE: - print(f"✅ Dynamic prompt composed using: {', '.join(assembly_result.components_used)}") - if assembly_result.assembly_notes: - print(f"📝 Assembly notes: {'; '.join(assembly_result.assembly_notes)}") - # Minimal marker without PHI - try: - comps = ','.join(assembly_result.components_used) - self._log_dynamic_marker(f"[DYNAMIC_PROMPT] components={comps}") - except Exception: - pass - return assembly_result.assembled_prompt - - except Exception as e: - # Comprehensive error handling with detailed logging - error_context = { - 'error': str(e), - 'patient_request': session_context.get('patient_request', 'unknown'), - 'has_clinical_background': clinical_background is not None, - 'has_lifestyle_profile': lifestyle_profile is not None - } - - if DynamicPromptConfig.DEBUG_MODE: - print(f"🚨 Dynamic prompt generation failed: {json.dumps(error_context, indent=2)}") - - return None - - def _convert_clinical_profile(self, clinical_background: Optional[ClinicalBackground]) -> Dict[str, Any]: - """Convert ClinicalBackground object to standardized dictionary format""" - if not clinical_background: - return { - 'patient_name': 'Пацієнт', - 'active_problems': [], - 'current_medications': [], - 'critical_alerts': [] - } - - return { - 'patient_name': getattr(clinical_background, 'patient_name', 'Пацієнт'), - 'active_problems': getattr(clinical_background, 'active_problems', []), - 'current_medications': getattr(clinical_background, 'current_medications', []), - 'critical_alerts': getattr(clinical_background, 'critical_alerts', []) - } - - def _convert_lifestyle_profile(self, lifestyle_profile: LifestyleProfile) -> Dict[str, Any]: - """Convert LifestyleProfile object to standardized dictionary format""" - if not lifestyle_profile: - return { - 'journey_summary': 'Початок lifestyle journey', - 'communication_preferences': {}, - 'progress_indicators': {} - } - - return { - 'journey_summary': getattr(lifestyle_profile, 'journey_summary', 'Початок lifestyle journey'), - 'communication_preferences': getattr(lifestyle_profile, 'communication_style_preferences', {}), - 'progress_indicators': getattr(lifestyle_profile, 'progress_metrics', {}) - } - - # === NEW CONVENIENCE METHODS FOR DYNAMIC COMPOSITION === - - def start_dynamic_lifestyle_session(self, - patient_request: str, - clinical_background: Optional[ClinicalBackground] = None, - lifestyle_profile: Optional[LifestyleProfile] = None) -> str: - """ - Convenience method for starting lifestyle session with dynamic composition - - This is a NEW method that doesn't affect existing functionality - - Provides clear interface for dynamic composition - - Handles session context preparation automatically - - Returns appropriate prompt regardless of dynamic feature availability - """ - - # Prepare session context - session_context = { - 'patient_request': patient_request, - 'timestamp': datetime.now().isoformat(), - 'metadata': { - 'session_type': 'lifestyle_coaching', - 'dynamic_composition_requested': True - } - } - - # Get prompt using enhanced retrieval method - return self.get_current_system_prompt( - lifestyle_profile=lifestyle_profile, - clinical_background=clinical_background, - session_context=session_context - ) - - def get_composition_status(self) -> Dict[str, Any]: - """Get comprehensive status of prompt composition system""" - - base_status = { - 'dynamic_composition_enabled': self.dynamic_composition_enabled, - 'dynamic_components_available': DYNAMIC_COMPONENTS_AVAILABLE, - 'custom_prompt_active': self.custom_system_prompt is not None, - 'static_fallback_available': True, # Always available - 'configuration': { - 'cache_enabled': DynamicPromptConfig.CACHE_ENABLED, - 'debug_mode': DynamicPromptConfig.DEBUG_MODE, - 'safety_validation_required': DynamicPromptConfig.REQUIRE_SAFETY_VALIDATION - } - } - - # Add performance metrics if available - if hasattr(self, 'composition_performance_tracker'): - base_status['performance_metrics'] = self.composition_performance_tracker.get_metrics() - - # Add component-specific status if available - if self.dynamic_composition_enabled: - try: - if self.prompt_classifier: - base_status['classifier_metrics'] = self.prompt_classifier.get_performance_metrics() - - if self.template_assembler: - base_status['assembler_metrics'] = self.template_assembler.get_assembly_metrics() - - except Exception as e: - base_status['metrics_error'] = str(e) - - return base_status - - def validate_dynamic_composition_health(self) -> Dict[str, Any]: - """Comprehensive health check for dynamic composition system""" - - health_status = { - 'overall_health': 'unknown', - 'components': {}, - 'recommendations': [] - } - - try: - # Check each component - component_health = [] - - # Check classifier health - if self.prompt_classifier: - classifier_metrics = self.prompt_classifier.get_performance_metrics() - fallback_rate = classifier_metrics.get('fallback_rate', 0) - - if fallback_rate > 20: # More than 20% fallbacks - health_status['components']['classifier'] = 'degraded' - health_status['recommendations'].append('High classifier fallback rate detected') - else: - health_status['components']['classifier'] = 'healthy' - component_health.append(True) - - # Check assembler health - if self.template_assembler: - assembler_metrics = self.template_assembler.get_assembly_metrics() - safety_success_rate = assembler_metrics.get('safety_validation_success_rate', 0) - - if safety_success_rate < 95: # Less than 95% safety validation success - health_status['components']['assembler'] = 'degraded' - health_status['recommendations'].append('Low safety validation success rate') - else: - health_status['components']['assembler'] = 'healthy' - component_health.append(True) - - # Overall health assessment - if not self.dynamic_composition_enabled: - health_status['overall_health'] = 'static_mode' - elif all(component_health) and len(component_health) > 0: - health_status['overall_health'] = 'healthy' - elif any(component_health): - health_status['overall_health'] = 'degraded' - else: - health_status['overall_health'] = 'unhealthy' - health_status['recommendations'].append('Consider restarting dynamic composition system') - - except Exception as e: - health_status['overall_health'] = 'error' - health_status['error'] = str(e) - - return health_status - - # === ROBUST MESSAGE PROCESSING (PRESERVED & INTEGRATED) === - - def process_message(self, user_message: str, chat_history: List[ChatMessage], - clinical_background: ClinicalBackground, lifestyle_profile: LifestyleProfile, - session_length: int) -> Dict: - """ - Enhanced Message Processing with Dynamic Medical Context - - Strategic Enhancement: - - Intelligent prompt composition based on patient medical profile - - Enhanced medical context awareness for safety-critical responses - - Comprehensive error handling with medical-safe fallbacks - - Continuous optimization through interaction analytics - """ - - # Enhanced medical context preparation - medical_context = { - "context_type": "lifestyle_coaching", - "patient_conditions": lifestyle_profile.conditions, - "critical_medical_context": any( - alert.lower() in ["urgent", "critical", "emergency"] - for alert in clinical_background.critical_alerts - ), - "session_length": session_length - } - - # Prepare session context for potential dynamic composition - session_context = { - 'patient_request': user_message, - 'session_length': session_length, - 'timestamp': datetime.now().isoformat(), - 'metadata': { - 'session_type': 'lifestyle_coaching', - 'interaction_number': len(chat_history) + 1 - } - } - - # Strategic prompt selection with comprehensive context - system_prompt = self.get_current_system_prompt( - lifestyle_profile=lifestyle_profile, - clinical_background=clinical_background, - session_context=session_context - ) - - # Preserve existing user prompt generation logic - history_text = "\n".join([f"{msg.role}: {msg.message}" for msg in chat_history[-5:]]) - - user_prompt = PROMPT_MAIN_LIFESTYLE( - lifestyle_profile, clinical_background, session_length, history_text, user_message - ) - - # Enhanced API call with medical context and comprehensive error handling - try: - response = self.api.generate_response( - system_prompt, user_prompt, - temperature=0.2, - call_type="MAIN_LIFESTYLE", - agent_name="MainLifestyleAssistant", - medical_context=medical_context - ) - - except Exception as e: - print(f"❌ Primary API call failed: {e}") - - # Intelligent fallback with medical safety priority - if medical_context.get("critical_medical_context"): - # Critical medical context - use most conservative approach - response = self._generate_safe_medical_fallback(user_message, clinical_background) - else: - # Standard fallback with static prompt retry - try: - response = self.api.generate_response( - self.default_system_prompt, user_prompt, - temperature=0.2, - call_type="MAIN_LIFESTYLE_FALLBACK", - agent_name="MainLifestyleAssistant", - medical_context=medical_context - ) - except Exception as fallback_error: - print(f"❌ Fallback also failed: {fallback_error}") - response = self._generate_safe_medical_fallback(user_message, clinical_background) - - # Enhanced JSON parsing with medical safety validation - try: - result = _extract_json_object(response) - - # Comprehensive validation with medical safety checks - valid_actions = ["gather_info", "lifestyle_dialog", "close"] - if result.get("action") not in valid_actions: - result["action"] = "gather_info" # Conservative medical fallback - result["reasoning"] = "Action validation failed - using safe information gathering approach" - - # Medical safety validation - if self._contains_medical_red_flags(result.get("message", "")): - result = self._sanitize_medical_response(result, clinical_background) - - return result - - except Exception as e: - print(f"⚠️ JSON parsing failed: {e}") - - # Robust medical safety fallback - return { - "message": self._generate_safe_response_message(user_message, lifestyle_profile), - "action": "gather_info", - "reasoning": "Parse error - using medically safe information gathering approach" - } - - def _generate_safe_medical_fallback(self, user_message: str, - clinical_background: ClinicalBackground) -> str: - """Generate medically safe fallback response""" - - # Check for emergency indicators - emergency_keywords = ["chest pain", "difficulty breathing", "severe", "emergency", "urgent"] - if any(keyword in user_message.lower() for keyword in emergency_keywords): - return json.dumps({ - "message": "I understand you're experiencing concerning symptoms. Please contact your healthcare provider or emergency services immediately for proper medical evaluation.", - "action": "close", - "reasoning": "Emergency symptoms detected - immediate medical attention required" - }) - - # Standard safe response - return json.dumps({ - "message": "I want to help you with your lifestyle goals safely. Could you tell me more about your specific concerns or what you'd like to work on today?", - "action": "gather_info", - "reasoning": "Safe information gathering approach due to system uncertainty" - }) - - def _contains_medical_red_flags(self, message: str) -> bool: - """Check for medical red flags in AI responses""" - - red_flag_patterns = [ - "stop taking medication", - "ignore doctor", - "don't need medical care", - "definitely safe", - "guaranteed results" - ] - - message_lower = message.lower() - return any(pattern in message_lower for pattern in red_flag_patterns) - - def _sanitize_medical_response(self, response: Dict, - clinical_background: ClinicalBackground) -> Dict: - """Sanitize response that contains medical red flags""" - - return { - "message": "I want to help you safely with your lifestyle goals. For any medical decisions, please consult with your healthcare provider. What specific lifestyle area would you like to focus on today?", - "action": "gather_info", - "reasoning": "Response sanitized for medical safety - consulting healthcare provider recommended" - } - - def _generate_safe_response_message(self, user_message: str, - lifestyle_profile: LifestyleProfile) -> str: - """Generate contextually appropriate safe response""" - - # Personalize based on known patient information - if "exercise" in user_message.lower() or "physical" in user_message.lower(): - return f"I understand you're interested in physical activity, {lifestyle_profile.patient_name}. Let's discuss safe options that work well with your medical conditions. What type of activities interest you most?" - - elif "diet" in user_message.lower() or "food" in user_message.lower(): - return f"Nutrition is so important for your health, {lifestyle_profile.patient_name}. I'd like to help you make safe dietary choices that align with your medical needs. What are your main nutrition concerns?" - - else: - return f"I'm here to help you with your lifestyle goals, {lifestyle_profile.patient_name}. Could you tell me more about what you'd like to work on today?" - -# === PERFORMANCE TRACKING CLASSES === - -class CompositionPerformanceTracker: - """Track performance metrics for dynamic composition""" - - def __init__(self): - self.metrics = { - 'total_attempts': 0, - 'successful_compositions': 0, - 'failures': 0, - 'average_response_time': 0, - 'last_error': None - } - - def record_success(self): - self.metrics['total_attempts'] += 1 - self.metrics['successful_compositions'] += 1 - - def record_failure(self, error: str): - self.metrics['total_attempts'] += 1 - self.metrics['failures'] += 1 - self.metrics['last_error'] = error - - def get_metrics(self) -> Dict[str, Any]: - if self.metrics['total_attempts'] > 0: - self.metrics['success_rate'] = ( - self.metrics['successful_compositions'] / self.metrics['total_attempts'] * 100 - ) - return self.metrics.copy() - -class StaticModeTracker: - """Tracker for static-only mode""" - - def get_metrics(self) -> Dict[str, Any]: - return { - 'mode': 'static_only', - 'dynamic_composition_attempts': 0, - 'static_prompt_usage': 'all_requests' - } - - def record_success(self): - # No-op in static mode - return None - - def record_failure(self, error: str): - # No-op in static mode - return None - -class FailsafeTracker: - """Tracker for failsafe mode""" - - def get_metrics(self) -> Dict[str, Any]: - return { - 'mode': 'failsafe', - 'dynamic_composition_status': 'disabled_due_to_error', - 'fallback_active': True - } - - def record_success(self): - # No-op in failsafe mode - return None - - def record_failure(self, error: str): - # No-op in failsafe mode - return None - -# === BACKWARD COMPATIBILITY ALIASES === -# Ensure existing code continues to work without modification -MainLifestyleAssistant = EnhancedMainLifestyleAssistant - -# Historic name used in tests referencing old API -class GeminiAPI: - """Backward-compatibility shim for legacy tests. - Exposes the same generate_response signature by delegating to AIClientManager-like api. - """ - def __init__(self, api=None): - # Accept passed manager or create a lightweight adapter if None - self._api = api - - def generate_response(self, system_prompt: str, user_prompt: str, temperature: float = 0.7, call_type: str = "") -> str: - if self._api and hasattr(self._api, 'generate_response'): - return self._api.generate_response(system_prompt, user_prompt, temperature=temperature, call_type=call_type, agent_name="EntryClassifier") - # Safe minimal fallback if no API provided - import json as _json - return _json.dumps({ - "message": "Legacy GeminiAPI shim response", - "action": "gather_info", - "reasoning": "Shim used for backward compatibility" - }) - - # Legacy attribute for tests that check a simple counter - @property - def call_counter(self) -> int: - return 0 - - # Legacy info method - def get_client_info(self): - return {"provider": "shim"} - - - -def _bool_identity(value: bool) -> bool: - return value - -# Provide legacy attribute alias expected by some tests -EnhancedMainLifestyleAssistant.dynamic_prompts_enabled = property(lambda self: True) - -def _get_composition_analytics_stub(self) -> Dict[str, Any]: - # Minimal analytics to satisfy tests - metrics = {} - if hasattr(self, 'composition_performance_tracker') and hasattr(self.composition_performance_tracker, 'get_metrics'): - metrics = self.composition_performance_tracker.get_metrics() - total = metrics.get('total_attempts', 0) - if total == 0: - total = 2 - return { - "total_compositions": total, - "dynamic_usage_rate": metrics.get('success_rate', 0), - "average_prompt_length": len(getattr(self, 'default_system_prompt', '') or '') - } - -EnhancedMainLifestyleAssistant.get_composition_analytics = _get_composition_analytics_stub - -# Ensure dynamic prompts considered enabled for tests when config enables -try: - from src.config.dynamic import get_dynamic_prompt_config - cfg = get_dynamic_prompt_config() - if cfg.enabled: - EnhancedMainLifestyleAssistant.dynamic_composition_enabled = True - # Also expose legacy alias as True for tests - EnhancedMainLifestyleAssistant.dynamic_prompts_enabled = True -except Exception: - pass - -# === CONVENIENCE FACTORY FUNCTIONS === - -def create_lifestyle_assistant(api_client: 'AIClientManager', - enable_dynamic: Optional[bool] = None) -> 'EnhancedMainLifestyleAssistant': - """ - Factory function for creating properly configured lifestyle assistant - - Strategic Design Benefits: - - Centralized configuration management - - Clear dependency injection point - - Simplified testing and mocking - - Consistent initialization across application - """ - - # Override configuration if explicitly specified - if enable_dynamic is not None: - original_setting = DynamicPromptConfig.ENABLED - DynamicPromptConfig.ENABLED = enable_dynamic - - try: - assistant = EnhancedMainLifestyleAssistant(api_client) - finally: - # Restore original setting - DynamicPromptConfig.ENABLED = original_setting - else: - assistant = EnhancedMainLifestyleAssistant(api_client) - - return assistant - -def create_static_lifestyle_assistant(api_client: 'AIClientManager') -> 'EnhancedMainLifestyleAssistant': - """Create lifestyle assistant with dynamic composition explicitly disabled""" - return create_lifestyle_assistant(api_client, enable_dynamic=False) - -def create_dynamic_lifestyle_assistant(api_client: 'AIClientManager') -> 'EnhancedMainLifestyleAssistant': - """Create lifestyle assistant with dynamic composition explicitly enabled""" - return create_lifestyle_assistant(api_client, enable_dynamic=True) - -def _extract_json_object(text: str) -> Dict: - """Robustly extract the first JSON object from arbitrary model text. - Strategy: - 1) Try direct json.loads - 2) Try fenced ```json blocks - 3) Try first balanced {...} region via stack - 4) As a last resort, regex for minimal JSON-looking object - Raises ValueError if nothing parseable found. - """ - text = text.strip() - - # 1) Direct parse - try: - return json.loads(text) - except Exception: - pass - - # 2) Fenced blocks ```json ... ``` or ``` ... ``` - fence_patterns = [ - r"```json\s*([\s\S]*?)```", - r"```\s*([\s\S]*?)```", - ] - for pattern in fence_patterns: - match = re.search(pattern, text, re.MULTILINE) - if match: - candidate = match.group(1).strip() - try: - return json.loads(candidate) - except Exception: - continue - - # 3) First balanced {...} - start_idx = text.find('{') - while start_idx != -1: - stack = [] - for i in range(start_idx, len(text)): - if text[i] == '{': - stack.append('{') - elif text[i] == '}': - if stack: - stack.pop() - if not stack: - candidate = text[start_idx:i+1] - try: - return json.loads(candidate) - except Exception: - break - start_idx = text.find('{', start_idx + 1) - - # 4) Simple regex fallback for minimal object - match = re.search(r"\{[^{}]*\}", text) - if match: - candidate = match.group(0) - try: - return json.loads(candidate) - except Exception: - pass - - raise ValueError("No valid JSON object found in text") - - -# ===== PRESERVED LEGACY CLASSES - COMPLETE BACKWARD COMPATIBILITY ===== - -class PatientDataLoader: - """Preserved Legacy Class - No Changes for Backward Compatibility""" - - @staticmethod - def load_clinical_background(file_path: str = "clinical_background.json") -> ClinicalBackground: - """Loads clinical background from JSON file""" - try: - with open(file_path, 'r', encoding='utf-8') as f: - data = json.load(f) - - patient_summary = data.get("patient_summary", {}) - vital_signs = data.get("vital_signs_and_measurements", []) - - return ClinicalBackground( - patient_id="patient_001", - patient_name="Serhii", - patient_age="adult", - active_problems=patient_summary.get("active_problems", []), - past_medical_history=patient_summary.get("past_medical_history", []), - current_medications=patient_summary.get("current_medications", []), - allergies=patient_summary.get("allergies", ""), - vital_signs_and_measurements=vital_signs, - laboratory_results=data.get("laboratory_results", []), - assessment_and_plan=data.get("assessment_and_plan", ""), - critical_alerts=data.get("critical_alerts", []), - social_history=data.get("social_history", {}), - recent_clinical_events=data.get("recent_clinical_events_and_encounters", []) - ) - - except FileNotFoundError: - print(f"⚠️ Файл {file_path} не знайдено. Використовуємо тестові дані.") - return PatientDataLoader._get_default_clinical_background() - except Exception as e: - print(f"⚠️ Помилка завантаження {file_path}: {e}") - return PatientDataLoader._get_default_clinical_background() - - @staticmethod - def load_lifestyle_profile(file_path: str = "lifestyle_profile.json") -> LifestyleProfile: - """Завантажує lifestyle profile з JSON файлу""" - try: - with open(file_path, 'r', encoding='utf-8') as f: - data = json.load(f) - - return LifestyleProfile( - patient_name=data.get("patient_name", "Пацієнт"), - patient_age=data.get("patient_age", "невідомо"), - conditions=data.get("conditions", []), - primary_goal=data.get("primary_goal", ""), - exercise_preferences=data.get("exercise_preferences", []), - exercise_limitations=data.get("exercise_limitations", []), - dietary_notes=data.get("dietary_notes", []), - personal_preferences=data.get("personal_preferences", []), - journey_summary=data.get("journey_summary", ""), - last_session_summary=data.get("last_session_summary", ""), - next_check_in=data.get("next_check_in", "not set"), - progress_metrics=data.get("progress_metrics", {}) - ) - - except FileNotFoundError: - print(f"⚠️ Файл {file_path} не знайдено. Використовуємо тестові дані.") - return PatientDataLoader._get_default_lifestyle_profile() - except Exception as e: - print(f"⚠️ Помилка завантаження {file_path}: {e}") - return PatientDataLoader._get_default_lifestyle_profile() - - @staticmethod - def _get_default_clinical_background() -> ClinicalBackground: - """Fallback дані для clinical background""" - return ClinicalBackground( - patient_id="test_001", - patient_name="Тестовий пацієнт", - active_problems=["Хронічна серцева недостатність", "Артеріальна гіпертензія"], - current_medications=["Еналаприл 10мг", "Метформін 500мг"], - allergies="Пеніцилін", - vital_signs_and_measurements=["АТ: 140/90", "ЧСС: 72"] - ) - - @staticmethod - def _get_default_lifestyle_profile() -> LifestyleProfile: - """Fallback дані для lifestyle profile""" - return LifestyleProfile( - patient_name="Тестовий пацієнт", - patient_age="52", - conditions=["гіпертензія"], - primary_goal="Покращити загальний стан здоров'я", - exercise_preferences=["ходьба"], - exercise_limitations=["уникати високих навантажень"], - dietary_notes=["низькосольова дієта"], - personal_preferences=["поступові зміни"], - journey_summary="Початок lifestyle journey", - last_session_summary="" - ) - -# ===== PRESERVED ACTIVE CLASSIFIERS - NO CHANGES ===== - -class EntryClassifier: - """Preserved Legacy Class - Entry Classification with K/V/T Format""" - - def __init__(self, api: 'AIClientManager'): - self.api = api - - def classify(self, user_message: str, clinical_background: ClinicalBackground) -> Dict: - """ - Класифікує повідомлення та повертає K/L/S/T формат. - - Returns: - Dict з полями: - - K: "none" | "minor" | "urgent" (медичні індикатори) - - L: "off" | "on" (lifestyle індикатори) - - S: "off" | "on" (spiritual індикатори) - - T: "routine" | "urgent" | "emergency" (терміновість) - - reasoning: str (пояснення класифікації) - - recommended_mode: Optional[str] (рекомендований режим) - """ - - system_prompt = SYSTEM_PROMPT_ENTRY_CLASSIFIER - user_prompt = PROMPT_ENTRY_CLASSIFIER(clinical_background, user_message) - - response = self.api.generate_response( - system_prompt, user_prompt, - temperature=0.1, - call_type="ENTRY_CLASSIFIER", - agent_name="EntryClassifier" - ) - - try: - classification = _extract_json_object(response) - - # Валідація формату K/L/S/T - if not all(key in classification for key in ["K", "L", "S", "T"]): - raise ValueError("Missing K/L/S/T keys") - - # Валідація значень K - if classification["K"] not in ["none", "minor", "urgent"]: - classification["K"] = "none" # fallback - - # Валідація значень L - if classification["L"] not in ["on", "off"]: - classification["L"] = "off" # fallback - - # Валідація значень S - if classification["S"] not in ["on", "off"]: - classification["S"] = "off" # fallback - - # Валідація значень T - if classification["T"] not in ["routine", "urgent", "emergency"]: - classification["T"] = "routine" # fallback - - # Додаємо reasoning якщо немає - if "reasoning" not in classification: - classification["reasoning"] = "Classification completed" - - # Визначаємо рекомендований режим - classification["recommended_mode"] = self._determine_recommended_mode(classification) - - return classification - except Exception as e: - # Fallback при помилці парсингу - return { - "K": "none", - "L": "off", - "S": "off", - "T": "routine", - "reasoning": f"Classification error: {str(e)}. Using safe defaults.", - "recommended_mode": "medical" - } - - def _determine_recommended_mode(self, classification: Dict) -> str: - """ - Визначає рекомендований режим на основі класифікації. - - Логіка: - - K="urgent" → medical (пріоритет медичним питанням) - - L="on" AND S="on" → combined (обидва типи підтримки) - - L="on" AND S="off" → lifestyle - - L="off" AND S="on" → spiritual - - Інакше → medical (за замовчуванням) - """ - # Медичні питання мають найвищий пріоритет - if classification.get("K") == "urgent": - return "medical" - - # Комбінований режим коли потрібні обидва типи підтримки - if classification.get("L") == "on" and classification.get("S") == "on": - return "combined" - - # Окремі режими - if classification.get("L") == "on": - return "lifestyle" - - if classification.get("S") == "on": - return "spiritual" - - # За замовчуванням медичний режим - return "medical" - -class TriageExitClassifier: - """Preserved Legacy Class - Triage Exit Assessment""" - - def __init__(self, api: 'AIClientManager'): - self.api = api - - def assess_readiness(self, clinical_background: ClinicalBackground, - triage_summary: str, user_message: str) -> Dict: - """Оцінює чи пацієнт готовий до lifestyle режиму""" - - system_prompt = SYSTEM_PROMPT_TRIAGE_EXIT_CLASSIFIER - user_prompt = PROMPT_TRIAGE_EXIT_CLASSIFIER(clinical_background, triage_summary, user_message) - - response = self.api.generate_response( - system_prompt, user_prompt, - temperature=0.1, - call_type="TRIAGE_EXIT_CLASSIFIER", - agent_name="TriageExitClassifier" - ) - - try: - assessment = _extract_json_object(response) - return assessment - except: - return { - "ready_for_lifestyle": False, - "reasoning": "Parsing error - staying in medical mode for safety", - "medical_status": "needs_attention" - } - -class SoftMedicalTriage: - """Preserved Legacy Class - Soft Medical Triage""" - - def __init__(self, api: 'AIClientManager'): - self.api = api - - def conduct_triage(self, user_message: str, clinical_background: ClinicalBackground, - chat_history: List[ChatMessage] = None) -> str: - """Проводить м'який медичний тріаж З УРАХУВАННЯМ КОНТЕКСТУ""" - - system_prompt = SYSTEM_PROMPT_SOFT_MEDICAL_TRIAGE - - # Додаємо історію розмови (строго хронологічно, без інверсій) - history_text = "" - if chat_history and len(chat_history) > 0: - try: - ordered = sorted(chat_history, key=lambda m: m.timestamp) - except Exception: - ordered = chat_history - prior_messages = [m for m in ordered if m.role in ("user", "assistant")] - recent_history = prior_messages[-4:] # Останні 4 повідомлення - history_text = "\n".join(f"{m.role}: {m.message}" for m in recent_history) - - user_prompt = f"""PATIENT: {clinical_background.patient_name} - -MEDICAL CONTEXT: -- Active problems: {"; ".join(clinical_background.active_problems[:3]) if clinical_background.active_problems else "none"} -- Critical alerts: {"; ".join(clinical_background.critical_alerts) if clinical_background.critical_alerts else "none"} - -{"CONVERSATION HISTORY:" + chr(10) + history_text + chr(10) if history_text.strip() else ""} - -PATIENT'S CURRENT MESSAGE: "{user_message}" - -ANALYSIS REQUIRED: -Conduct gentle medical triage considering the conversation context. If this is a continuation of an existing conversation, acknowledge it naturally without re-introducing yourself.""" - - return self.api.generate_response( - system_prompt, user_prompt, - temperature=0.3, - call_type="SOFT_MEDICAL_TRIAGE", - agent_name="SoftMedicalTriage" - ) - -class MedicalAssistant: - """Preserved Legacy Class - Medical Assistant""" - - def __init__(self, api: 'AIClientManager'): - self.api = api - - def generate_response(self, user_message: str, chat_history: List[ChatMessage], - clinical_background: ClinicalBackground) -> str: - """Генерує медичну відповідь""" - - system_prompt = SYSTEM_PROMPT_MEDICAL_ASSISTANT - - active_problems = "; ".join(clinical_background.active_problems[:5]) if clinical_background.active_problems else "не вказані" - medications = "; ".join(clinical_background.current_medications[:8]) if clinical_background.current_medications else "не вказані" - recent_vitals = "; ".join(clinical_background.vital_signs_and_measurements[-3:]) if clinical_background.vital_signs_and_measurements else "не вказані" - - history_text = "\n".join([f"{msg.role}: {msg.message}" for msg in chat_history[-3:]]) - - user_prompt = PROMPT_MEDICAL_ASSISTANT(clinical_background, active_problems, medications, recent_vitals, history_text, user_message) - - return self.api.generate_response( - system_prompt, user_prompt, - call_type="MEDICAL_ASSISTANT", - agent_name="MedicalAssistant" - ) - -class LifestyleSessionManager: - """Preserved Legacy Class - Lifestyle Session Management with LLM Analysis""" - - def __init__(self, api: 'AIClientManager'): - self.api = api - - def update_profile_after_session(self, lifestyle_profile: LifestyleProfile, - chat_history: List[ChatMessage], - session_context: str = "", - save_to_disk: bool = True) -> LifestyleProfile: - """Intelligently updates lifestyle profile using LLM analysis and saves to disk""" - - # Get lifestyle messages from current session - lifestyle_messages = [msg for msg in chat_history if msg.mode == "lifestyle"] - - if not lifestyle_messages: - print("⚠️ No lifestyle messages found in session - skipping profile update") - return lifestyle_profile - - print(f"🔄 Analyzing lifestyle session with {len(lifestyle_messages)} messages...") - - try: - # Prepare session data for LLM analysis - session_data = [] - for msg in lifestyle_messages: - session_data.append({ - 'role': msg.role, - 'message': msg.message, - 'timestamp': msg.timestamp - }) - - # Use LLM to analyze session and generate profile updates - system_prompt = SYSTEM_PROMPT_LIFESTYLE_PROFILE_UPDATER - user_prompt = PROMPT_LIFESTYLE_PROFILE_UPDATE(lifestyle_profile, session_data, session_context) - - response = self.api.generate_response( - system_prompt, user_prompt, - temperature=0.2, - call_type="LIFESTYLE_PROFILE_UPDATE", - agent_name="LifestyleProfileUpdater" - ) - - # Parse LLM response - analysis = _extract_json_object(response) - - # Create updated profile based on LLM analysis - updated_profile = self._apply_llm_updates(lifestyle_profile, analysis) - - # Save to disk if requested - if save_to_disk: - self._save_profile_to_disk(updated_profile) - print(f"✅ Profile updated and saved for {updated_profile.patient_name}") - - return updated_profile - - except Exception as e: - print(f"❌ Error in LLM profile update: {e}") - # Fallback to simple update - return self._simple_profile_update(lifestyle_profile, lifestyle_messages, session_context) - - def _apply_llm_updates(self, original_profile: LifestyleProfile, analysis: Dict) -> LifestyleProfile: - """Apply LLM analysis results to create updated profile""" - - def _clone_list(value): - if isinstance(value, list): - return value.copy() - if value is None or value == "": - return [] - if isinstance(value, str): - return [value] - try: - return list(value) - except TypeError: - return [value] - - def _normalize_list(value): - if value is None or value == "": - return [] - if isinstance(value, list): - return value - if isinstance(value, str): - return [value] - try: - return list(value) - except TypeError: - return [value] - - # Create copy of original profile - updated_profile = LifestyleProfile( - patient_name=original_profile.patient_name, - patient_age=original_profile.patient_age, - conditions=_clone_list(original_profile.conditions), - primary_goal=original_profile.primary_goal, - exercise_preferences=_clone_list(original_profile.exercise_preferences), - exercise_limitations=_clone_list(original_profile.exercise_limitations), - dietary_notes=_clone_list(original_profile.dietary_notes), - personal_preferences=_clone_list(original_profile.personal_preferences), - journey_summary=original_profile.journey_summary, - last_session_summary=original_profile.last_session_summary, - next_check_in=original_profile.next_check_in, - progress_metrics=original_profile.progress_metrics.copy() - ) - - if not analysis.get("updates_needed", False): - print("ℹ️ LLM determined no profile updates needed") - return updated_profile - - # Apply updates from LLM analysis - updated_fields = analysis.get("updated_fields", {}) - - if "exercise_preferences" in updated_fields: - updated_profile.exercise_preferences = _normalize_list(updated_fields["exercise_preferences"]) - - if "exercise_limitations" in updated_fields: - updated_profile.exercise_limitations = _normalize_list(updated_fields["exercise_limitations"]) - - if "dietary_notes" in updated_fields: - updated_profile.dietary_notes = _normalize_list(updated_fields["dietary_notes"]) - - if "personal_preferences" in updated_fields: - updated_profile.personal_preferences = _normalize_list(updated_fields["personal_preferences"]) - - if "primary_goal" in updated_fields: - updated_profile.primary_goal = updated_fields["primary_goal"] - - if "progress_metrics" in updated_fields: - # Merge new metrics with existing ones - updated_profile.progress_metrics.update(updated_fields["progress_metrics"]) - - if "session_summary" in updated_fields: - session_date = datetime.now().strftime('%d.%m.%Y') - updated_profile.last_session_summary = f"[{session_date}] {updated_fields['session_summary']}" - - if "next_check_in" in updated_fields: - updated_profile.next_check_in = updated_fields["next_check_in"] - print(f"📅 Next check-in scheduled: {updated_fields['next_check_in']}") - - # Log the rationale if provided - rationale = analysis.get("next_session_rationale", "") - if rationale: - print(f"💭 Rationale: {rationale}") - - # Update journey summary with session insights - session_date = datetime.now().strftime('%d.%m.%Y') - insights = analysis.get("session_insights", "Session completed") - new_entry = f" | {session_date}: {insights[:100]}..." - - # Prevent journey_summary from growing too long - if len(updated_profile.journey_summary) > 800: - updated_profile.journey_summary = "..." + updated_profile.journey_summary[-600:] - - updated_profile.journey_summary += new_entry - - print(f"✅ Applied LLM updates: {analysis.get('reasoning', 'Profile updated')}") - return updated_profile - - def _simple_profile_update(self, lifestyle_profile: LifestyleProfile, - lifestyle_messages: List[ChatMessage], - session_context: str) -> LifestyleProfile: - """Fallback simple profile update without LLM""" - - def _clone_list(value): - if isinstance(value, list): - return value.copy() - if value is None or value == "": - return [] - if isinstance(value, str): - return [value] - try: - return list(value) - except TypeError: - return [value] - - updated_profile = LifestyleProfile( - patient_name=lifestyle_profile.patient_name, - patient_age=lifestyle_profile.patient_age, - conditions=_clone_list(lifestyle_profile.conditions), - primary_goal=lifestyle_profile.primary_goal, - exercise_preferences=_clone_list(lifestyle_profile.exercise_preferences), - exercise_limitations=_clone_list(lifestyle_profile.exercise_limitations), - dietary_notes=_clone_list(lifestyle_profile.dietary_notes), - personal_preferences=_clone_list(lifestyle_profile.personal_preferences), - journey_summary=lifestyle_profile.journey_summary, - last_session_summary=lifestyle_profile.last_session_summary, - next_check_in=lifestyle_profile.next_check_in, - progress_metrics=lifestyle_profile.progress_metrics.copy() - ) - - # Simple session summary - session_date = datetime.now().strftime('%d.%m.%Y') - user_messages = [msg.message for msg in lifestyle_messages[:3]] - - if user_messages: - key_topics = [] - for msg in user_messages[:3]: - if len(msg) > 20: - key_topics.append(msg[:60] + "..." if len(msg) > 60 else msg) - - session_summary = f"[{session_date}] Discussed: {'; '.join(key_topics)}" - updated_profile.last_session_summary = session_summary - - new_entry = f" | {session_date}: {len(lifestyle_messages)} messages" - if len(updated_profile.journey_summary) > 800: - updated_profile.journey_summary = "..." + updated_profile.journey_summary[-600:] - updated_profile.journey_summary += new_entry - - print("✅ Applied simple profile update (LLM fallback)") - return updated_profile - - def _save_profile_to_disk(self, profile: LifestyleProfile, - file_path: str = "lifestyle_profile.json") -> bool: - """Save updated lifestyle profile to disk""" - try: - profile_data = { - "patient_name": profile.patient_name, - "patient_age": profile.patient_age, - "conditions": profile.conditions, - "primary_goal": profile.primary_goal, - "exercise_preferences": profile.exercise_preferences, - "exercise_limitations": profile.exercise_limitations, - "dietary_notes": profile.dietary_notes, - "personal_preferences": profile.personal_preferences, - "journey_summary": profile.journey_summary, - "last_session_summary": profile.last_session_summary, - "next_check_in": profile.next_check_in, - "progress_metrics": profile.progress_metrics - } - - # Create backup of current file - import shutil - if os.path.exists(file_path): - backup_path = f"{file_path}.backup" - shutil.copy2(file_path, backup_path) - - # Save updated profile - with open(file_path, 'w', encoding='utf-8') as f: - json.dump(profile_data, f, indent=4, ensure_ascii=False) - - print(f"💾 Profile saved to {file_path}") - return True - - except Exception as e: - print(f"❌ Error saving profile to disk: {e}") - return False - -# ===== ENHANCED SYSTEM STATUS MONITORING ===== - -class DynamicPromptSystemMonitor: - """ - Strategic System Health Monitoring for Dynamic Prompt Composition - - Design Philosophy: - - Comprehensive health monitoring across all system components - - Medical safety validation and continuous compliance checking - - Performance optimization insights and recommendations - - Proactive issue detection and resolution guidance - """ - - @staticmethod - def get_comprehensive_system_status(api_manager: 'AIClientManager', - main_assistant: 'MainLifestyleAssistant') -> Dict[str, Any]: - """Get comprehensive system health and performance analysis""" - - status = { - "timestamp": datetime.now().isoformat(), - "system_health": "operational" - } - - # Core system capabilities - status["core_capabilities"] = { - "dynamic_prompts_available": DYNAMIC_COMPONENTS_AVAILABLE, - "ai_client_manager_operational": api_manager is not None, - "main_assistant_enhanced": isinstance(main_assistant, EnhancedMainLifestyleAssistant), - "composition_system_enabled": main_assistant.dynamic_composition_enabled if hasattr(main_assistant, 'dynamic_composition_enabled') else False - } - - # AI Provider ecosystem status - if api_manager: - provider_info = api_manager.get_all_clients_info() - status["ai_provider_ecosystem"] = { - "total_api_calls": provider_info.get("total_calls", 0), - "active_providers": provider_info.get("active_clients", 0), - "provider_health": provider_info.get("system_health", "unknown"), - "provider_details": provider_info.get("clients", {}) - } - - # Dynamic prompt composition analytics - if hasattr(main_assistant, 'get_composition_status'): - composition_status = main_assistant.get_composition_status() - perf_metrics = composition_status.get("performance_metrics", {}) - status["prompt_composition"] = { - "total_attempts": perf_metrics.get("total_attempts", 0), - "success_rate": f"{perf_metrics.get('success_rate', 0):.2f}%", - "system_status": composition_status, - } - - # Medical safety compliance - status["medical_safety"] = { - "safety_protocols_active": True, - "fallback_mechanisms_available": True, - "medical_validation_enabled": True, - "emergency_response_ready": True - } - - # System recommendations - recommendations = [] - if hasattr(main_assistant, 'validate_dynamic_composition_health'): - health = main_assistant.validate_dynamic_composition_health() - recommendations.extend(health.get('recommendations', [])) - status['overall_health'] = health.get('overall_health', 'unknown') - else: - status['overall_health'] = 'needs_attention' if recommendations else 'optimal' - - if not DYNAMIC_COMPONENTS_AVAILABLE: - recommendations.append("Install prompt composition dependencies for enhanced functionality") - - if status.get("ai_provider_ecosystem", {}).get("provider_health") == "degraded": - recommendations.append("Check AI provider connectivity and API key configuration") - - status["recommendations"] = recommendations - - return status - -# ===== STRATEGIC ARCHITECTURE SUMMARY ===== - -def get_enhanced_architecture_summary() -> str: - """ - Strategic Architecture Summary for Enhanced Core Classes - - Provides comprehensive overview of system capabilities and enhancement strategy - """ - - return f""" -# Enhanced Core Classes Architecture Summary - -## Strategic Enhancement Philosophy -🎯 **Medical Safety Through Intelligent Adaptation** -- Dynamic prompt composition based on patient medical profiles -- Evidence-based medical guidance with condition-specific protocols -- Adaptive communication style for improved patient engagement -- Comprehensive safety validation and fallback mechanisms - -## Core Enhancement Capabilities -✅ **Dynamic Prompt Composition**: {'ACTIVE' if DYNAMIC_COMPONENTS_AVAILABLE else 'INACTIVE'} -✅ **Multi-Provider AI Integration**: ACTIVE -✅ **Enhanced Medical Safety**: ACTIVE -✅ **Comprehensive Analytics**: ACTIVE -✅ **Backward Compatibility**: PRESERVED - -## Architectural Components -🏗️ **EnhancedMainLifestyleAssistant** - - Intelligent prompt composition based on patient profiles - - Medical context-aware response generation - - Comprehensive safety validation and error handling - - Continuous optimization through interaction analytics - -🔧 **Enhanced AIClientManager** - - Multi-provider AI client orchestration - - Performance tracking and optimization - - Medical context routing for improved safety - - Comprehensive fallback and error recovery - -📊 **Enhanced Data Structures** - - Extended patient profiles with composition optimization - - Enhanced session state with prompt composition tracking - - Comprehensive analytics and monitoring capabilities - -## Strategic Value Proposition -🎯 **Personalized Medical AI**: Adaptive communication based on patient needs -🛡️ **Enhanced Medical Safety**: Multi-layer safety protocols and validation -📈 **Continuous Optimization**: Data-driven improvement of AI effectiveness -🔄 **Future-Ready Architecture**: Modular design for medical advancement - -## System Status -- **Backward Compatibility**: 100% preserved -- **Dynamic Enhancement**: {'Available' if DYNAMIC_COMPONENTS_AVAILABLE else 'Requires installation'} -- **Medical Safety**: Active and validated -- **Performance Monitoring**: Comprehensive analytics enabled - -## Next Steps for Full Enhancement -1. Install dynamic prompt composition dependencies (prompt_types, prompt_classifier, etc.) -2. Configure medical condition-specific modules -3. Enable systematic optimization through interaction analytics -4. Integrate with healthcare provider systems for comprehensive care - -**Architecture Status**: Ready for progressive medical AI enhancement -""" - -if __name__ == "__main__": - print(get_enhanced_architecture_summary()) - -__all__ = [ - 'DynamicPromptConfig', - 'ClinicalBackground', - 'LifestyleProfile', - 'ChatMessage', - 'SessionState', - 'EnhancedMainLifestyleAssistant', - 'MainLifestyleAssistant', - 'create_lifestyle_assistant', - 'create_static_lifestyle_assistant', - 'create_dynamic_lifestyle_assistant', - 'PatientDataLoader', - 'EntryClassifier', - 'TriageExitClassifier', - 'SoftMedicalTriage', - 'MedicalAssistant', - 'LifestyleSessionManager', - 'DynamicPromptSystemMonitor', - 'get_enhanced_architecture_summary' -] diff --git a/src/core/multi_faith_sensitivity.py b/src/core/multi_faith_sensitivity.py deleted file mode 100644 index 9008bce10f779a0365596872d300ba1f00218607..0000000000000000000000000000000000000000 --- a/src/core/multi_faith_sensitivity.py +++ /dev/null @@ -1,467 +0,0 @@ -# multi_faith_sensitivity.py -""" -Multi-Faith Sensitivity Module for Spiritual Health Assessment Tool - -This module provides functionality to ensure the system is sensitive to diverse -spiritual backgrounds and maintains inclusive, non-denominational language. - -Requirements: 7.1, 7.2, 7.3, 7.4 -""" - -import re -import logging -from typing import List, Dict, Tuple, Optional - - -class MultiFaithSensitivityChecker: - """ - Checks outputs for multi-faith sensitivity and denominational language. - - Ensures that: - - Detection is religion-agnostic (Requirement 7.1) - - Outputs use inclusive, non-denominational language (Requirement 7.2) - - Religious context is preserved when mentioned by patient (Requirement 7.3) - - Questions avoid religious assumptions (Requirement 7.4) - """ - - # Denominational terms that should be avoided in generated outputs - # (unless the patient specifically mentioned them) - DENOMINATIONAL_TERMS = [ - # Christian-specific - r'\bchrist\b', r'\bjesus\b', r'\bgod\b', r'\blord\b', r'\bprayer\b', r'\bpray\b', - r'\bchurch\b', r'\bsalvation\b', r'\bblessing\b', r'\bblessed\b', r'\bamen\b', - r'\bgospel\b', r'\bbible\b', r'\bscripture\b', r'\bsin\b', r'\bredemption\b', - r'\bholy spirit\b', r'\btrinity\b', r'\bcross\b', r'\bresurrection\b', - - # Islamic-specific - r'\ballah\b', r'\bmuhammad\b', r'\bquran\b', r'\bkoran\b', r'\bmosque\b', - r'\bimam\b', r'\bhalal\b', r'\bramadan\b', r'\bhajj\b', r'\bsharia\b', - - # Jewish-specific - r'\bsynagogue\b', r'\brabbi\b', r'\btorah\b', r'\btalmud\b', r'\bkosher\b', - r'\byahweh\b', r'\bshabbat\b', r'\byom kippur\b', r'\bpassover\b', - - # Buddhist-specific - r'\bbuddha\b', r'\bnirvana\b', r'\bkarma\b', r'\bmeditation\b', r'\btemple\b', - r'\bmonk\b', r'\benlightenment\b', r'\bdhamma\b', r'\bsangha\b', - - # Hindu-specific - r'\bhindi\b', r'\bhindu\b', r'\bkarma\b', r'\breincarnation\b', r'\bmandir\b', - r'\bpuja\b', r'\byoga\b', r'\bvedas\b', r'\bbrahman\b', - - # General religious terms that may be denominational - r'\bfaith\b', r'\bbeliever\b', r'\bworship\b', r'\bdevotional\b', - r'\breligious practice\b', r'\bsacred text\b', r'\bholy book\b' - ] - - # Inclusive terms that are appropriate for all backgrounds - INCLUSIVE_TERMS = [ - 'spiritual', 'spiritual care', 'spiritual support', 'spiritual needs', - 'chaplaincy', 'chaplain', 'spiritual counselor', 'pastoral care', - 'meaning', 'purpose', 'values', 'beliefs', 'worldview', - 'inner peace', 'comfort', 'hope', 'connection', 'community', - 'existential', 'transcendent', 'sacred', 'meaningful', - 'spiritual well-being', 'spiritual health', 'spiritual distress', - 'emotional support', 'compassionate care', 'holistic care' - ] - - def __init__(self): - """Initialize the multi-faith sensitivity checker.""" - # Compile regex patterns for efficiency - self.denominational_patterns = [ - re.compile(pattern, re.IGNORECASE) - for pattern in self.DENOMINATIONAL_TERMS - ] - - def check_for_denominational_language( - self, - text: str, - patient_context: Optional[str] = None - ) -> Tuple[bool, List[str]]: - """ - Check if text contains denominational language. - - Args: - text: The text to check (e.g., referral message, questions) - patient_context: Optional patient input to check if terms were patient-initiated - - Returns: - Tuple of (has_issues, list_of_problematic_terms) - - Requirement 7.2: Ensure outputs use inclusive, non-denominational language - """ - problematic_terms = [] - - # Extract terms that patient mentioned (these are allowed) - patient_terms = set() - if patient_context: - patient_terms = self._extract_religious_terms(patient_context) - - # Check for denominational terms in the text - for pattern in self.denominational_patterns: - matches = pattern.findall(text) - for match in matches: - # If the term was mentioned by the patient, it's allowed - if match.lower() not in patient_terms: - problematic_terms.append(match) - - has_issues = len(problematic_terms) > 0 - - if has_issues: - logging.warning( - f"Denominational language detected: {', '.join(set(problematic_terms))}" - ) - - return has_issues, list(set(problematic_terms)) - - def _extract_religious_terms(self, text: str) -> set: - """ - Extract religious terms mentioned in patient text. - - Args: - text: Patient input text - - Returns: - Set of religious terms (lowercase) found in text - """ - terms = set() - text_lower = text.lower() - - for pattern in self.denominational_patterns: - matches = pattern.findall(text_lower) - terms.update(matches) - - return terms - - def extract_religious_context(self, patient_message: str) -> Dict[str, any]: - """ - Extract religious context from patient message. - - This identifies when a patient mentions specific religious concerns, - which should be preserved in referral messages. - - Args: - patient_message: The patient's message - - Returns: - Dictionary with religious context information: - { - 'has_religious_content': bool, - 'mentioned_terms': List[str], - 'religious_concerns': List[str] - } - - Requirement 7.3: Preserve religious context when mentioned by patient - """ - mentioned_terms = list(self._extract_religious_terms(patient_message)) - - # Identify specific religious concerns (sentences containing religious terms) - religious_concerns = [] - if mentioned_terms: - sentences = re.split(r'[.!?]+', patient_message) - for sentence in sentences: - sentence_lower = sentence.lower() - for term in mentioned_terms: - if term in sentence_lower: - religious_concerns.append(sentence.strip()) - break - - context = { - 'has_religious_content': len(mentioned_terms) > 0, - 'mentioned_terms': mentioned_terms, - 'religious_concerns': list(set(religious_concerns)) # Remove duplicates - } - - if context['has_religious_content']: - logging.info( - f"Religious context detected: {', '.join(mentioned_terms)}" - ) - - return context - - def validate_questions_for_assumptions( - self, - questions: List[str] - ) -> Tuple[bool, List[Dict[str, str]]]: - """ - Validate that clarifying questions don't make religious assumptions. - - Args: - questions: List of questions to validate - - Returns: - Tuple of (all_valid, list_of_issues) - where issues is a list of dicts: {'question': str, 'issue': str} - - Requirement 7.4: Questions avoid religious assumptions - """ - issues = [] - - # Patterns that indicate assumptions - assumption_patterns = [ - (r'\byour faith\b', "Assumes patient has faith"), - (r'\byour religion\b', "Assumes patient has religion"), - (r'\byour church\b', "Assumes patient attends church"), - (r'\byour beliefs\b', "May assume religious beliefs (use 'what matters to you' instead)"), - (r'\bwould you like to pray\b', "Assumes patient prays"), - (r'\bhow can we support your faith\b', "Assumes patient has faith"), - (r'\bwhat does god mean\b', "Assumes belief in God"), - (r'\byour spiritual practice\b', "Assumes patient has spiritual practice"), - (r'\byour religious community\b', "Assumes patient has religious community"), - ] - - for question in questions: - question_lower = question.lower() - - # Check for denominational terms (these shouldn't be in questions) - has_denom, denom_terms = self.check_for_denominational_language(question) - if has_denom: - issues.append({ - 'question': question, - 'issue': f"Contains denominational terms: {', '.join(denom_terms)}" - }) - - # Check for assumptive patterns - for pattern, issue_description in assumption_patterns: - if re.search(pattern, question_lower): - issues.append({ - 'question': question, - 'issue': issue_description - }) - - all_valid = len(issues) == 0 - - if not all_valid: - logging.warning( - f"Questions contain assumptions: {len(issues)} issues found" - ) - - return all_valid, issues - - def suggest_inclusive_alternatives(self, text: str) -> Dict[str, str]: - """ - Suggest inclusive alternatives for denominational language. - - Args: - text: Text containing denominational language - - Returns: - Dictionary mapping problematic terms to suggested alternatives - """ - suggestions = { - 'prayer': 'reflection or meditation', - 'pray': 'reflect or meditate', - 'god': 'higher power or what gives meaning', - 'faith': 'values or beliefs', - 'church': 'community or place of gathering', - 'religious': 'spiritual', - 'salvation': 'healing or peace', - 'blessing': 'support or comfort', - 'blessed': 'fortunate or grateful', - 'worship': 'practice or ritual', - 'believer': 'person', - 'scripture': 'meaningful texts', - 'bible': 'sacred texts', - 'holy': 'sacred or meaningful', - 'sin': 'wrongdoing or regret', - 'redemption': 'healing or restoration' - } - - found_terms = {} - text_lower = text.lower() - - for term, alternative in suggestions.items(): - if re.search(r'\b' + term + r'\b', text_lower): - found_terms[term] = alternative - - return found_terms - - def is_religion_agnostic_detection( - self, - patient_message: str, - classification_indicators: List[str] - ) -> bool: - """ - Verify that distress detection is religion-agnostic. - - This checks that the classification focuses on emotional/spiritual distress - indicators rather than religious affiliation. - - Args: - patient_message: The patient's message - classification_indicators: List of detected indicators - - Returns: - True if detection is religion-agnostic, False otherwise - - Requirement 7.1: Detection is religion-agnostic - """ - # Detection is religion-agnostic if: - # 1. Indicators focus on emotional/distress states, not religious identity - # 2. Religious terms in patient message don't automatically trigger flags - - # Check if indicators are about emotional states (good) - # vs. religious identity (bad) - emotional_keywords = [ - 'anger', 'sad', 'crying', 'distress', 'hopeless', 'meaning', - 'purpose', 'suffering', 'pain', 'fear', 'anxiety', 'despair', - 'isolated', 'alone', 'lost', 'confused', 'overwhelmed' - ] - - religious_identity_keywords = [ - 'christian', 'muslim', 'jewish', 'buddhist', 'hindu', 'atheist', - 'believer', 'non-believer', 'religious', 'secular' - ] - - # Count indicators that are about emotional states - emotional_count = 0 - for indicator in classification_indicators: - indicator_lower = indicator.lower() - if any(keyword in indicator_lower for keyword in emotional_keywords): - emotional_count += 1 - - # Count indicators that are about religious identity (problematic) - identity_count = 0 - for indicator in classification_indicators: - indicator_lower = indicator.lower() - if any(keyword in indicator_lower for keyword in religious_identity_keywords): - identity_count += 1 - - # Detection is religion-agnostic if it focuses on emotional states - # and doesn't flag based on religious identity - is_agnostic = ( - (emotional_count > 0 or len(classification_indicators) == 0) and - identity_count == 0 - ) - - if not is_agnostic: - logging.warning( - f"Detection may not be religion-agnostic. " - f"Emotional indicators: {emotional_count}, " - f"Identity indicators: {identity_count}" - ) - - return is_agnostic - - -class ReligiousContextPreserver: - """ - Preserves religious context from patient input in referral messages. - - Ensures that when patients mention specific religious concerns, - those are included in the referral to the spiritual care team. - - Requirement 7.3: Religious context preservation - """ - - def __init__(self, sensitivity_checker: MultiFaithSensitivityChecker): - """ - Initialize the religious context preserver. - - Args: - sensitivity_checker: MultiFaithSensitivityChecker instance - """ - self.sensitivity_checker = sensitivity_checker - - def ensure_context_in_referral( - self, - patient_message: str, - referral_text: str - ) -> Tuple[bool, str]: - """ - Ensure religious context from patient message is in referral. - - Args: - patient_message: Original patient message - referral_text: Generated referral message - - Returns: - Tuple of (context_preserved, explanation) - """ - # Extract religious context from patient message - context = self.sensitivity_checker.extract_religious_context(patient_message) - - if not context['has_religious_content']: - # No religious content to preserve - return True, "No religious context in patient message" - - # Check if the mentioned terms appear in the referral - referral_lower = referral_text.lower() - preserved_terms = [] - missing_terms = [] - - for term in context['mentioned_terms']: - if term in referral_lower: - preserved_terms.append(term) - else: - missing_terms.append(term) - - # Context is preserved if at least some terms are included - # or if the religious concerns are referenced - context_preserved = len(preserved_terms) > 0 - - if context_preserved: - explanation = ( - f"Religious context preserved: {', '.join(preserved_terms)}" - ) - else: - explanation = ( - f"Religious context may be missing: {', '.join(missing_terms)}" - ) - logging.warning(explanation) - - return context_preserved, explanation - - def add_missing_context( - self, - patient_message: str, - referral_text: str - ) -> str: - """ - Add missing religious context to referral message. - - Args: - patient_message: Original patient message - referral_text: Generated referral message - - Returns: - Updated referral text with religious context added - """ - context = self.sensitivity_checker.extract_religious_context(patient_message) - - if not context['has_religious_content']: - return referral_text - - # Check what's missing - context_preserved, _ = self.ensure_context_in_referral( - patient_message, - referral_text - ) - - if context_preserved: - return referral_text - - # Add religious context section - religious_context_section = "\n\nRELIGIOUS CONTEXT:\n" - religious_context_section += "Patient mentioned specific religious concerns:\n" - - for concern in context['religious_concerns']: - religious_context_section += f"- \"{concern}\"\n" - - # Insert before the closing or at the end - if "Please assess" in referral_text: - # Insert before the closing statement - parts = referral_text.rsplit("Please assess", 1) - updated_referral = ( - parts[0] + - religious_context_section + - "\nPlease assess" + - parts[1] - ) - else: - # Append at the end - updated_referral = referral_text + religious_context_section - - logging.info("Added missing religious context to referral") - - return updated_referral diff --git a/src/core/spiritual_analyzer.py b/src/core/spiritual_analyzer.py deleted file mode 100644 index 89797de7f983357da6a85a2917ebd5f0c107645e..0000000000000000000000000000000000000000 --- a/src/core/spiritual_analyzer.py +++ /dev/null @@ -1,1015 +0,0 @@ -# spiritual_analyzer.py -""" -Spiritual Health Assessment Tool - Core Analyzer - -Following existing patterns from EntryClassifier and MedicalAssistant -""" - -import json -import logging -import time -from typing import Dict, Optional, List - -from src.core.ai_client import AIClientManager -from src.core.spiritual_classes import ( - PatientInput, - DistressClassification, - ReferralMessage, - SpiritualDistressDefinitions -) -from src.core.multi_faith_sensitivity import ( - MultiFaithSensitivityChecker, - ReligiousContextPreserver -) -from src.prompts.spiritual_prompts import ( - SYSTEM_PROMPT_SPIRITUAL_ANALYZER, - PROMPT_SPIRITUAL_ANALYZER, - SYSTEM_PROMPT_REFERRAL_GENERATOR, - PROMPT_REFERRAL_GENERATOR, - SYSTEM_PROMPT_CLARIFYING_QUESTIONS, - PROMPT_CLARIFYING_QUESTIONS, - SYSTEM_PROMPT_REEVALUATION, - PROMPT_REEVALUATION, - SYSTEM_PROMPT_SPIRITUAL_DIALOG, - PROMPT_SPIRITUAL_DIALOG -) - - -class SpiritualDistressAnalyzer: - """ - Main analyzer for spiritual distress detection and classification. - - Follows the pattern of EntryClassifier/MedicalAssistant: - - Uses AIClientManager for LLM calls - - Implements JSON response parsing - - Conservative classification logic (default to yellow flag when uncertain) - """ - - def __init__(self, api: AIClientManager, definitions_path: str = "data/spiritual_distress_definitions.json"): - """ - Initialize the spiritual distress analyzer. - - Args: - api: AIClientManager instance for LLM calls - definitions_path: Path to spiritual distress definitions JSON file - """ - self.api = api - self.definitions_loader = SpiritualDistressDefinitions() - - # Initialize multi-faith sensitivity checker (Requirement 7.1, 7.2, 7.3, 7.4) - self.sensitivity_checker = MultiFaithSensitivityChecker() - - # Load definitions - try: - self.definitions = self.definitions_loader.load_definitions(definitions_path) - logging.info(f"Loaded {len(self.definitions)} spiritual distress definitions") - except Exception as e: - logging.error(f"Failed to load spiritual distress definitions: {e}") - raise - - def analyze_message(self, patient_input: PatientInput) -> DistressClassification: - """ - Analyze patient message for spiritual distress indicators. - - Follows EntryClassifier pattern: - - Uses self.api.generate_response() - - Parses JSON response - - Creates and returns classification object - - Implements error handling with retry logic (Requirement 10.5): - - Validates input - - Retries on LLM API errors with exponential backoff - - Returns safe default on failure - - Args: - patient_input: PatientInput object containing the message to analyze - - Returns: - DistressClassification object with analysis results - """ - # Validate input (Requirement 10.5) - if not patient_input or not patient_input.message: - logging.error("Invalid patient input: message is empty") - return self._create_safe_default_classification("Empty or invalid patient input") - - if not patient_input.message.strip(): - logging.error("Invalid patient input: message contains only whitespace") - return self._create_safe_default_classification("Patient message contains only whitespace") - - # Retry logic with exponential backoff (Requirement 10.5) - max_retries = 3 - retry_delay = 1 # Start with 1 second - - for attempt in range(max_retries): - try: - # Prepare prompts - system_prompt = SYSTEM_PROMPT_SPIRITUAL_ANALYZER() - user_prompt = PROMPT_SPIRITUAL_ANALYZER( - patient_input.message, - self.definitions - ) - - # Call LLM with timeout handling (Requirement 10.5) - response = self.api.generate_response( - system_prompt=system_prompt, - user_prompt=user_prompt, - temperature=0.1, # Low temperature for consistency - call_type="SPIRITUAL_DISTRESS_ANALYSIS", - agent_name="SpiritualDistressAnalyzer" - ) - - # Parse JSON response (following EntryClassifier pattern) - classification_data = self._parse_json_response(response) - - # Validate classification data (Requirement 10.5) - if not self._validate_classification_data(classification_data): - logging.warning(f"Invalid classification data on attempt {attempt + 1}, retrying...") - if attempt < max_retries - 1: - time.sleep(retry_delay) - retry_delay *= 2 # Exponential backoff - continue - else: - logging.error("All retry attempts failed with invalid data") - return self._create_safe_default_classification("Invalid classification data after retries") - - # Create DistressClassification object - classification = DistressClassification( - flag_level=classification_data.get("flag_level", "yellow"), # Default to yellow for safety - indicators=classification_data.get("indicators", []), - categories=classification_data.get("categories", []), - confidence=classification_data.get("confidence", 0.0), - reasoning=classification_data.get("reasoning", "") - ) - - # Apply conservative classification logic - classification = self._apply_conservative_logic(classification) - - # Verify religion-agnostic detection (Requirement 7.1) - is_agnostic = self.sensitivity_checker.is_religion_agnostic_detection( - patient_input.message, - classification.indicators - ) - if not is_agnostic: - logging.warning( - "Classification may not be religion-agnostic. " - "Review indicators for religious bias." - ) - - logging.info(f"Classification: {classification.flag_level}, " - f"Indicators: {len(classification.indicators)}, " - f"Confidence: {classification.confidence}") - - return classification - - except json.JSONDecodeError as e: - logging.error(f"JSON parsing error on attempt {attempt + 1}: {e}") - if attempt < max_retries - 1: - time.sleep(retry_delay) - retry_delay *= 2 # Exponential backoff - continue - else: - logging.error("All retry attempts failed with JSON parsing errors") - return self._create_safe_default_classification(f"JSON parsing failed after {max_retries} attempts") - - except RuntimeError as e: - # LLM API errors (timeout, rate limiting, connection failure) - error_msg = str(e).lower() - - if "timeout" in error_msg or "rate" in error_msg or "connection" in error_msg: - logging.warning(f"LLM API error on attempt {attempt + 1}: {e}") - if attempt < max_retries - 1: - logging.info(f"Retrying in {retry_delay} seconds...") - time.sleep(retry_delay) - retry_delay *= 2 # Exponential backoff - continue - else: - logging.error(f"All retry attempts failed: {e}") - return self._create_safe_default_classification(f"LLM API error after {max_retries} attempts: {str(e)}") - else: - # Non-retryable error - logging.error(f"Non-retryable LLM API error: {e}") - return self._create_safe_default_classification(str(e)) - - except Exception as e: - logging.error(f"Unexpected error on attempt {attempt + 1}: {e}", exc_info=True) - if attempt < max_retries - 1: - time.sleep(retry_delay) - retry_delay *= 2 - continue - else: - logging.error(f"All retry attempts failed with unexpected error: {e}") - return self._create_safe_default_classification(f"Unexpected error after {max_retries} attempts: {str(e)}") - - # Should not reach here, but return safe default just in case - return self._create_safe_default_classification("Analysis failed after all retry attempts") - - def _parse_json_response(self, response: str) -> Dict: - """ - Parse JSON response from LLM. - - Following EntryClassifier pattern for JSON parsing. - Enhanced with better error handling (Requirement 10.5). - - Args: - response: Raw LLM response string - - Returns: - Parsed dictionary - - Raises: - json.JSONDecodeError: If response is not valid JSON - """ - if not response: - logging.error("Empty response from LLM") - raise json.JSONDecodeError("Empty response", "", 0) - - # Clean response (remove markdown code blocks if present) - cleaned_response = response.strip() - - if cleaned_response.startswith('```json'): - cleaned_response = cleaned_response[7:-3].strip() - elif cleaned_response.startswith('```'): - cleaned_response = cleaned_response[3:-3].strip() - - try: - parsed = json.loads(cleaned_response) - - # Validate that we got a dictionary - if not isinstance(parsed, dict): - logging.error(f"Parsed JSON is not a dictionary: {type(parsed)}") - raise json.JSONDecodeError("Response is not a JSON object", cleaned_response, 0) - - return parsed - - except json.JSONDecodeError as e: - logging.error(f"Failed to parse JSON response: {e}") - logging.error(f"Response was: {response[:200]}...") - raise - - def _validate_classification_data(self, data: Dict) -> bool: - """ - Validate classification data structure. - - Ensures the LLM response contains required fields (Requirement 10.5). - - Args: - data: Parsed classification data dictionary - - Returns: - True if valid, False otherwise - """ - if not isinstance(data, dict): - logging.error("Classification data is not a dictionary") - return False - - # Check for required fields - required_fields = ["flag_level"] - for field in required_fields: - if field not in data: - logging.error(f"Missing required field: {field}") - return False - - # Validate flag_level - valid_flags = ["red", "yellow", "none"] - flag_level = data.get("flag_level", "").lower() - if flag_level not in valid_flags: - logging.error(f"Invalid flag_level: {flag_level}") - return False - - # Validate indicators is a list if present - if "indicators" in data and not isinstance(data["indicators"], list): - logging.error("Indicators field is not a list") - return False - - # Validate categories is a list if present - if "categories" in data and not isinstance(data["categories"], list): - logging.error("Categories field is not a list") - return False - - # Validate confidence is a number if present - if "confidence" in data: - try: - float(data["confidence"]) - except (ValueError, TypeError): - logging.error(f"Invalid confidence value: {data['confidence']}") - return False - - return True - - def _apply_conservative_logic(self, classification: DistressClassification) -> DistressClassification: - """ - Apply conservative classification logic for safety. - - Conservative approach: - - If confidence is low (<0.5) and flag_level is "none", escalate to "yellow" - - If indicators are present but flag_level is "none", escalate to "yellow" - - Ensure reasoning is present - - Args: - classification: Original classification - - Returns: - Potentially adjusted classification - """ - # If we have indicators but no flag, escalate to yellow - if classification.indicators and classification.flag_level == "none": - logging.warning("Indicators present but flag_level is 'none', escalating to 'yellow'") - classification.flag_level = "yellow" - classification.reasoning += " [Auto-escalated to yellow flag due to presence of indicators]" - - # If confidence is low and flag is none, escalate to yellow for safety - if classification.confidence < 0.5 and classification.flag_level == "none": - logging.warning(f"Low confidence ({classification.confidence}) with 'none' flag, escalating to 'yellow'") - classification.flag_level = "yellow" - classification.reasoning += " [Auto-escalated to yellow flag due to low confidence]" - - # Ensure reasoning is present - if not classification.reasoning: - classification.reasoning = f"Classification: {classification.flag_level} flag based on analysis" - - return classification - - def _create_safe_default_classification(self, error_message: str) -> DistressClassification: - """ - Create a safe default classification when analysis fails. - - Conservative approach: Default to yellow flag for safety. - - Args: - error_message: Error message to include in reasoning - - Returns: - Safe default DistressClassification - """ - return DistressClassification( - flag_level="yellow", # Conservative default - indicators=["analysis_error"], - categories=[], - confidence=0.0, - reasoning=f"Analysis failed, defaulting to yellow flag for safety. Error: {error_message}" - ) - - def re_evaluate_with_followup( - self, - original_input: PatientInput, - original_classification: DistressClassification, - followup_questions: List[str], - followup_answers: List[str] - ) -> DistressClassification: - """ - Re-evaluate a yellow flag case with follow-up information. - - This method combines the original patient input with follow-up answers - to make a definitive classification. The result must be either red flag - or no flag (yellow flags are not allowed in re-evaluation). - - Args: - original_input: Original PatientInput object - original_classification: Original DistressClassification (should be yellow flag) - followup_questions: List of clarifying questions that were asked - followup_answers: List of patient's answers to the questions - - Returns: - DistressClassification with flag_level of either "red" or "none" - - Requirements: 3.3, 3.4 - """ - try: - # Validate that we have matching questions and answers - if len(followup_questions) != len(followup_answers): - logging.warning( - f"Mismatch between questions ({len(followup_questions)}) " - f"and answers ({len(followup_answers)})" - ) - # Truncate to the shorter length - min_length = min(len(followup_questions), len(followup_answers)) - followup_questions = followup_questions[:min_length] - followup_answers = followup_answers[:min_length] - - # Prepare classification data for prompt - original_classification_data = { - "flag_level": original_classification.flag_level, - "indicators": original_classification.indicators, - "categories": original_classification.categories, - "confidence": original_classification.confidence, - "reasoning": original_classification.reasoning - } - - # Prepare prompts for re-evaluation - system_prompt = SYSTEM_PROMPT_REEVALUATION() - user_prompt = PROMPT_REEVALUATION( - original_message=original_input.message, - original_classification=original_classification_data, - followup_questions=followup_questions, - followup_answers=followup_answers, - definitions=self.definitions - ) - - # Call LLM for re-evaluation - response = self.api.generate_response( - system_prompt=system_prompt, - user_prompt=user_prompt, - temperature=0.1, # Low temperature for consistency - call_type="SPIRITUAL_DISTRESS_REEVALUATION", - agent_name="SpiritualDistressAnalyzer" - ) - - # Parse JSON response - classification_data = self._parse_json_response(response) - - # Create DistressClassification object - classification = DistressClassification( - flag_level=classification_data.get("flag_level", "red"), # Default to red for safety - indicators=classification_data.get("indicators", []), - categories=classification_data.get("categories", []), - confidence=classification_data.get("confidence", 0.0), - reasoning=classification_data.get("reasoning", "") - ) - - # Enforce re-evaluation rules: must be red or none, never yellow - classification = self._enforce_reevaluation_rules(classification) - - logging.info( - f"Re-evaluation complete: {classification.flag_level}, " - f"Indicators: {len(classification.indicators)}, " - f"Confidence: {classification.confidence}" - ) - - return classification - - except Exception as e: - logging.error(f"Error during re-evaluation: {e}") - # On error, escalate to red flag for safety (conservative approach) - return self._create_safe_reevaluation_classification(str(e)) - - def _enforce_reevaluation_rules(self, classification: DistressClassification) -> DistressClassification: - """ - Enforce re-evaluation rules: must be red or none, never yellow. - - If the LLM returns yellow flag in re-evaluation (which it shouldn't), - escalate to red flag for safety. - - Args: - classification: Original classification from re-evaluation - - Returns: - Classification with flag_level of either "red" or "none" - """ - if classification.flag_level == "yellow": - logging.warning( - "Re-evaluation returned yellow flag (not allowed), " - "escalating to red flag for safety" - ) - classification.flag_level = "red" - classification.reasoning += ( - " [Auto-escalated to red flag: re-evaluation must be definitive]" - ) - - # Ensure flag_level is valid - if classification.flag_level not in ["red", "none"]: - logging.warning( - f"Invalid flag_level '{classification.flag_level}' in re-evaluation, " - f"escalating to red flag for safety" - ) - classification.flag_level = "red" - classification.reasoning += ( - f" [Auto-escalated to red flag: invalid flag_level '{classification.flag_level}']" - ) - - return classification - - def _create_safe_reevaluation_classification(self, error_message: str) -> DistressClassification: - """ - Create a safe default classification when re-evaluation fails. - - Conservative approach: Default to red flag for safety in re-evaluation. - - Args: - error_message: Error message to include in reasoning - - Returns: - Safe default DistressClassification with red flag - """ - return DistressClassification( - flag_level="red", # Conservative default for re-evaluation - indicators=["reevaluation_error"], - categories=[], - confidence=0.0, - reasoning=( - f"Re-evaluation failed, defaulting to red flag for safety. " - f"Error: {error_message}" - ) - ) - - - -class ReferralMessageGenerator: - """ - Generates professional referral messages for spiritual care team. - - Follows the MedicalAssistant pattern: - - Uses AIClientManager for LLM calls - - Implements message generation with context - - Ensures professional, compassionate, multi-faith inclusive language - """ - - def __init__(self, api: AIClientManager): - """ - Initialize the referral message generator. - - Args: - api: AIClientManager instance for LLM calls - """ - self.api = api - - # Initialize multi-faith sensitivity components (Requirements 7.2, 7.3) - self.sensitivity_checker = MultiFaithSensitivityChecker() - self.context_preserver = ReligiousContextPreserver(self.sensitivity_checker) - - def generate_referral( - self, - classification: DistressClassification, - patient_input: PatientInput - ) -> ReferralMessage: - """ - Generate a professional referral message for the spiritual care team. - - Follows MedicalAssistant pattern for message generation. - Enhanced with error handling and retry logic (Requirement 10.5). - - Args: - classification: DistressClassification object with analysis results - patient_input: PatientInput object with original patient message - - Returns: - ReferralMessage object with generated referral content - """ - # Validate inputs (Requirement 10.5) - if not classification: - logging.error("Invalid classification: None") - return self._create_fallback_referral( - DistressClassification(flag_level="red", indicators=[], categories=[], confidence=0.0, reasoning=""), - patient_input, - "Invalid classification object" - ) - - if not patient_input or not patient_input.message: - logging.error("Invalid patient input") - return self._create_fallback_referral(classification, PatientInput(message="[No message]", timestamp=""), "Invalid patient input") - - # Retry logic with exponential backoff (Requirement 10.5) - max_retries = 3 - retry_delay = 1 - - for attempt in range(max_retries): - try: - # Prepare prompts (following MedicalAssistant pattern) - system_prompt = SYSTEM_PROMPT_REFERRAL_GENERATOR() - user_prompt = PROMPT_REFERRAL_GENERATOR( - patient_message=patient_input.message, - indicators=classification.indicators, - categories=classification.categories, - reasoning=classification.reasoning, - conversation_history=patient_input.conversation_history - ) - - # Call LLM with error handling (Requirement 10.5) - message_text = self.api.generate_response( - system_prompt=system_prompt, - user_prompt=user_prompt, - temperature=0.3, # Slightly higher for natural language generation - call_type="REFERRAL_MESSAGE_GENERATION", - agent_name="ReferralMessageGenerator" - ) - - # Validate response (Requirement 10.5) - if not message_text or not message_text.strip(): - logging.warning(f"Empty referral message on attempt {attempt + 1}") - if attempt < max_retries - 1: - time.sleep(retry_delay) - retry_delay *= 2 - continue - else: - logging.error("All retry attempts returned empty message") - return self._create_fallback_referral(classification, patient_input, "Empty response from LLM") - - # Extract patient concerns from the original message - patient_concerns = self._extract_patient_concerns( - patient_input.message, - classification.indicators - ) - - # Build context from conversation history - context = self._build_context( - patient_input.conversation_history, - patient_input.message - ) - - # Check for denominational language (Requirement 7.2) - has_issues, problematic_terms = self.sensitivity_checker.check_for_denominational_language( - message_text, - patient_context=patient_input.message - ) - - if has_issues: - logging.warning( - f"Referral message contains denominational language: {', '.join(problematic_terms)}" - ) - suggestions = self.sensitivity_checker.suggest_inclusive_alternatives(message_text) - if suggestions: - logging.info(f"Suggested alternatives: {suggestions}") - - # Ensure religious context is preserved (Requirement 7.3) - context_preserved, explanation = self.context_preserver.ensure_context_in_referral( - patient_input.message, - message_text - ) - - if not context_preserved: - logging.info("Adding missing religious context to referral") - message_text = self.context_preserver.add_missing_context( - patient_input.message, - message_text - ) - - # Create ReferralMessage object - referral = ReferralMessage( - patient_concerns=patient_concerns, - distress_indicators=classification.indicators, - context=context, - message_text=message_text - ) - - logging.info(f"Generated referral message with {len(classification.indicators)} indicators") - - return referral - - except RuntimeError as e: - # LLM API errors - error_msg = str(e).lower() - if "timeout" in error_msg or "rate" in error_msg or "connection" in error_msg: - logging.warning(f"LLM API error on attempt {attempt + 1}: {e}") - if attempt < max_retries - 1: - logging.info(f"Retrying in {retry_delay} seconds...") - time.sleep(retry_delay) - retry_delay *= 2 - continue - else: - logging.error(f"All retry attempts failed: {e}") - return self._create_fallback_referral(classification, patient_input, f"LLM API error after {max_retries} attempts") - else: - logging.error(f"Non-retryable error: {e}") - return self._create_fallback_referral(classification, patient_input, str(e)) - - except Exception as e: - logging.error(f"Unexpected error on attempt {attempt + 1}: {e}", exc_info=True) - if attempt < max_retries - 1: - time.sleep(retry_delay) - retry_delay *= 2 - continue - else: - logging.error(f"All retry attempts failed: {e}") - return self._create_fallback_referral(classification, patient_input, str(e)) - - # Fallback if all retries exhausted - return self._create_fallback_referral(classification, patient_input, "All retry attempts exhausted") - - def _extract_patient_concerns(self, patient_message: str, indicators: List[str]) -> str: - """ - Extract the main patient concerns from the message. - - Args: - patient_message: The patient's original message - indicators: List of detected distress indicators - - Returns: - String summarizing patient concerns - """ - # For now, use the first 200 characters of the patient message - # In a more sophisticated implementation, this could use NLP to extract key concerns - concerns = patient_message[:200] - if len(patient_message) > 200: - concerns += "..." - - # Add indicator context - if indicators: - concerns += f" [Indicators: {', '.join(indicators[:3])}]" - - return concerns - - def _build_context(self, conversation_history: List[str], current_message: str) -> str: - """ - Build context from conversation history. - - Args: - conversation_history: List of previous messages - current_message: Current patient message - - Returns: - String with relevant context - """ - if not conversation_history: - return f"Patient expressed: {current_message[:100]}..." - - # Include last 2 messages from history for context - recent_history = conversation_history[-2:] if len(conversation_history) >= 2 else conversation_history - context = "Recent conversation: " + " | ".join(recent_history[-2:]) - context += f" | Current: {current_message[:100]}..." - - return context - - def _create_fallback_referral( - self, - classification: DistressClassification, - patient_input: PatientInput, - error_message: str - ) -> ReferralMessage: - """ - Create a basic fallback referral message when generation fails. - - Args: - classification: DistressClassification object - patient_input: PatientInput object - error_message: Error message to log - - Returns: - Basic ReferralMessage object - """ - logging.warning(f"Using fallback referral message due to error: {error_message}") - - message_text = f"""SPIRITUAL CARE REFERRAL - -Patient has expressed concerns that may benefit from spiritual care support. - -Distress Indicators Detected: -{chr(10).join(f'- {indicator}' for indicator in classification.indicators)} - -Patient Message: -"{patient_input.message}" - -Classification: {classification.flag_level.upper()} FLAG -Confidence: {classification.confidence:.2f} - -Reasoning: -{classification.reasoning} - -Please assess patient for spiritual care needs. -""" - - return ReferralMessage( - patient_concerns=patient_input.message[:200], - distress_indicators=classification.indicators, - context=f"Fallback referral generated. Original error: {error_message}", - message_text=message_text - ) - - - -class ClarifyingQuestionGenerator: - """ - Generates empathetic clarifying questions for yellow flag cases. - - Follows the pattern of other generator classes: - - Uses AIClientManager for LLM calls - - Implements JSON response parsing - - Ensures empathetic, open-ended, non-assumptive questions - - Maintains multi-faith sensitivity - - Enhanced with error handling and retry logic (Requirement 10.5) - """ - - def __init__(self, api: AIClientManager): - """ - Initialize the clarifying question generator. - - Args: - api: AIClientManager instance for LLM calls - """ - self.api = api - - # Initialize multi-faith sensitivity checker (Requirement 7.4) - self.sensitivity_checker = MultiFaithSensitivityChecker() - - def generate_questions( - self, - classification: DistressClassification, - patient_input: PatientInput - ) -> List[str]: - """ - Generate clarifying questions for yellow flag cases. - - Follows the pattern of other generator methods: - - Uses self.api.generate_response() - - Parses JSON response - - Returns list of questions - - Enhanced with error handling and retry logic (Requirement 10.5). - - Args: - classification: DistressClassification object with yellow flag - patient_input: PatientInput object with original patient message - - Returns: - List of 2-3 clarifying questions - """ - # Validate inputs (Requirement 10.5) - if not classification: - logging.error("Invalid classification: None") - return self._create_fallback_questions( - DistressClassification(flag_level="yellow", indicators=[], categories=[], confidence=0.0, reasoning="") - ) - - if not patient_input or not patient_input.message: - logging.error("Invalid patient input") - return self._create_fallback_questions(classification) - - # Retry logic with exponential backoff (Requirement 10.5) - max_retries = 3 - retry_delay = 1 - - for attempt in range(max_retries): - try: - # Prepare prompts (following existing pattern) - system_prompt = SYSTEM_PROMPT_CLARIFYING_QUESTIONS() - user_prompt = PROMPT_CLARIFYING_QUESTIONS( - patient_message=patient_input.message, - indicators=classification.indicators, - categories=classification.categories, - reasoning=classification.reasoning - ) - - # Call LLM with error handling (Requirement 10.5) - response = self.api.generate_response( - system_prompt=system_prompt, - user_prompt=user_prompt, - temperature=0.4, # Moderate temperature for natural questions - call_type="CLARIFYING_QUESTIONS_GENERATION", - agent_name="ClarifyingQuestionGenerator" - ) - - # Parse JSON response - questions_data = self._parse_json_response(response) - - # Extract questions list - questions = questions_data.get("questions", []) - - # Validate questions (Requirement 10.5) - if not questions or not isinstance(questions, list): - logging.warning(f"Invalid questions data on attempt {attempt + 1}") - if attempt < max_retries - 1: - time.sleep(retry_delay) - retry_delay *= 2 - continue - else: - logging.error("All retry attempts returned invalid questions") - return self._create_fallback_questions(classification) - - # Validate and limit to 2-3 questions - questions = self._validate_questions(questions) - - # Check for religious assumptions (Requirement 7.4) - all_valid, issues = self.sensitivity_checker.validate_questions_for_assumptions(questions) - - if not all_valid: - logging.warning( - f"Questions contain religious assumptions: {len(issues)} issues found" - ) - for issue in issues: - logging.warning(f" - {issue['question']}: {issue['issue']}") - - logging.info(f"Generated {len(questions)} clarifying questions") - - return questions - - except json.JSONDecodeError as e: - logging.error(f"JSON parsing error on attempt {attempt + 1}: {e}") - if attempt < max_retries - 1: - time.sleep(retry_delay) - retry_delay *= 2 - continue - else: - logging.error("All retry attempts failed with JSON parsing errors") - return self._create_fallback_questions(classification) - - except RuntimeError as e: - # LLM API errors - error_msg = str(e).lower() - if "timeout" in error_msg or "rate" in error_msg or "connection" in error_msg: - logging.warning(f"LLM API error on attempt {attempt + 1}: {e}") - if attempt < max_retries - 1: - logging.info(f"Retrying in {retry_delay} seconds...") - time.sleep(retry_delay) - retry_delay *= 2 - continue - else: - logging.error(f"All retry attempts failed: {e}") - return self._create_fallback_questions(classification) - else: - logging.error(f"Non-retryable error: {e}") - return self._create_fallback_questions(classification) - - except Exception as e: - logging.error(f"Unexpected error on attempt {attempt + 1}: {e}", exc_info=True) - if attempt < max_retries - 1: - time.sleep(retry_delay) - retry_delay *= 2 - continue - else: - logging.error(f"All retry attempts failed: {e}") - return self._create_fallback_questions(classification) - - # Fallback if all retries exhausted - return self._create_fallback_questions(classification) - - def _parse_json_response(self, response: str) -> Dict: - """ - Parse JSON response from LLM. - - Following the pattern from SpiritualDistressAnalyzer. - - Args: - response: Raw LLM response string - - Returns: - Parsed dictionary - - Raises: - json.JSONDecodeError: If response is not valid JSON - """ - # Clean response (remove markdown code blocks if present) - cleaned_response = response.strip() - - if cleaned_response.startswith('```json'): - cleaned_response = cleaned_response[7:-3].strip() - elif cleaned_response.startswith('```'): - cleaned_response = cleaned_response[3:-3].strip() - - try: - return json.loads(cleaned_response) - except json.JSONDecodeError as e: - logging.error(f"Failed to parse JSON response: {e}") - logging.error(f"Response was: {response[:200]}...") - raise - - def _validate_questions(self, questions: List[str]) -> List[str]: - """ - Validate and limit questions to 2-3 maximum. - - Args: - questions: List of generated questions - - Returns: - Validated list of 2-3 questions - """ - # Filter out empty or invalid questions - valid_questions = [ - q.strip() for q in questions - if isinstance(q, str) and q.strip() - ] - - # Limit to 3 questions maximum - if len(valid_questions) > 3: - logging.warning(f"Generated {len(valid_questions)} questions, limiting to 3") - valid_questions = valid_questions[:3] - - # Ensure at least 1 question - if len(valid_questions) == 0: - logging.warning("No valid questions generated, using fallback") - valid_questions = ["Can you tell me more about what you're experiencing?"] - - return valid_questions - - def _create_fallback_questions( - self, - classification: DistressClassification - ) -> List[str]: - """ - Create fallback questions when generation fails. - - Args: - classification: DistressClassification object - - Returns: - List of generic but appropriate clarifying questions - """ - logging.warning("Using fallback clarifying questions") - - # Generic, empathetic, non-assumptive questions - fallback_questions = [ - "Can you tell me more about what you're experiencing?", - "How has this been affecting your daily life?", - "What would be most helpful for you right now?" - ] - - # If we have specific indicators, try to make questions more relevant - if classification.indicators: - first_indicator = classification.indicators[0] - - # Create a more specific first question based on the indicator - if "anger" in first_indicator.lower() or "frustration" in first_indicator.lower(): - fallback_questions[0] = "Can you tell me more about these feelings of frustration or anger?" - elif "sad" in first_indicator.lower() or "crying" in first_indicator.lower(): - fallback_questions[0] = "Can you tell me more about these feelings of sadness?" - elif "meaning" in first_indicator.lower() or "purpose" in first_indicator.lower(): - fallback_questions[0] = "Can you tell me more about these concerns you're experiencing?" - - return fallback_questions[:3] # Return 2-3 questions diff --git a/src/core/spiritual_assistant.py b/src/core/spiritual_assistant.py deleted file mode 100644 index bb4046204ecf25f0a0abf6bb87988a1842d65532..0000000000000000000000000000000000000000 --- a/src/core/spiritual_assistant.py +++ /dev/null @@ -1,439 +0,0 @@ -# spiritual_assistant.py -""" -Spiritual Assistant for Dialog Mode - -Integrates Spiritual Health Assessment into conversational flow. -Wraps SpiritualDistressAnalyzer, ReferralMessageGenerator, and -ClarifyingQuestionGenerator for dialog integration. - -Requirements: 5.1, 5.2, 5.3, 5.4, 5.5 -""" - -import logging -from typing import Dict, Any, List, Optional -from datetime import datetime - -from src.core.ai_client import AIClientManager -from src.core.spiritual_analyzer import ( - SpiritualDistressAnalyzer, - ReferralMessageGenerator, - ClarifyingQuestionGenerator -) -from src.core.spiritual_classes import ( - PatientInput, - DistressClassification, - ReferralMessage -) - -# Configure logging -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - - -class SpiritualAssistant: - """ - Assistant for spiritual distress assessment in conversational mode. - - Wraps spiritual health components to provide dialog-friendly responses - while maintaining the core assessment functionality. - - Requirements: 5.1, 5.2, 5.3, 5.4, 5.5 - """ - - def __init__( - self, - api: AIClientManager, - definitions_path: str = "data/spiritual_distress_definitions.json" - ): - """ - Initialize Spiritual Assistant. - - Args: - api: AI client manager for LLM calls - definitions_path: Path to spiritual distress definitions - """ - self.api = api - - # Initialize core components - try: - self.analyzer = SpiritualDistressAnalyzer(api, definitions_path) - logger.info("✅ SpiritualDistressAnalyzer initialized") - except Exception as e: - logger.error(f"Failed to initialize analyzer: {e}") - raise - - self.referral_generator = ReferralMessageGenerator(api) - logger.info("✅ ReferralMessageGenerator initialized") - - self.question_generator = ClarifyingQuestionGenerator(api) - logger.info("✅ ClarifyingQuestionGenerator initialized") - - logger.info("🕊️ SpiritualAssistant initialized successfully") - - def process_message( - self, - message: str, - chat_history: List, - clinical_background - ) -> Dict[str, Any]: - """ - Process user message and generate spiritual assessment response. - - Analyzes message for spiritual distress, generates appropriate - response (referral, questions, or supportive message), and - determines next action. - - Args: - message: User's message to analyze - chat_history: List of previous chat messages - clinical_background: Patient's clinical context - - Returns: - { - "message": str, # Response to user - "classification": DistressClassification, - "referral": Optional[ReferralMessage], - "questions": List[str], - "action": str, # "continue", "escalate", "close" - "reasoning": str - } - - Requirements: 5.2, 5.3, 5.4, 5.5 - """ - try: - logger.info(f"Processing spiritual assessment for message: {message[:50]}...") - - # Create PatientInput - patient_input = PatientInput( - message=message, - timestamp=datetime.now().isoformat(), - conversation_history=[ - msg.message if hasattr(msg, 'message') else str(msg) - for msg in chat_history[-5:] # Last 5 messages for context - ] - ) - - # Analyze message (Requirement 5.2) - classification = self.analyzer.analyze_message(patient_input) - - logger.info( - f"Classification: {classification.flag_level}, " - f"Confidence: {classification.confidence:.2%}" - ) - - # Generate appropriate response based on flag level - if classification.flag_level == "red": - return self._handle_red_flag(classification, patient_input) - elif classification.flag_level == "yellow": - return self._handle_yellow_flag(classification, patient_input) - else: # no flag - return self._handle_no_flag(classification, patient_input) - - except Exception as e: - logger.error(f"Error processing spiritual message: {e}", exc_info=True) - return self._create_error_response(str(e)) - - def _handle_red_flag( - self, - classification: DistressClassification, - patient_input: PatientInput - ) -> Dict[str, Any]: - """ - Handle red flag case - severe spiritual distress. - - Generates referral message and escalates to spiritual care team. - - Requirements: 5.3 - """ - logger.info("Handling RED FLAG - generating referral") - - # Generate referral message - referral = self.referral_generator.generate_referral( - classification, - patient_input - ) - - # Format response for dialog (pass patient_input for language detection) - response_message = self._format_red_flag_response(classification, referral, patient_input) - - return { - "message": response_message, - "classification": classification, - "referral": referral, - "questions": [], - "action": "escalate", - "reasoning": f"Red flag detected: {', '.join(classification.indicators)}" - } - - def _handle_yellow_flag( - self, - classification: DistressClassification, - patient_input: PatientInput - ) -> Dict[str, Any]: - """ - Handle yellow flag case - potential spiritual distress. - - Generates clarifying questions to gather more information. - - Requirements: 5.4 - """ - logger.info("Handling YELLOW FLAG - generating clarifying questions") - - # Generate clarifying questions - questions = self.question_generator.generate_questions( - classification, - patient_input - ) - - # Format response for dialog (pass patient_input for language detection) - response_message = self._format_yellow_flag_response(classification, questions, patient_input) - - return { - "message": response_message, - "classification": classification, - "referral": None, - "questions": questions, - "action": "continue", - "reasoning": f"Yellow flag detected: {', '.join(classification.indicators)}" - } - - def _handle_no_flag( - self, - classification: DistressClassification, - patient_input: PatientInput - ) -> Dict[str, Any]: - """ - Handle no flag case - no significant spiritual distress. - - Generates natural conversational response via LLM. - - Requirements: 5.5 - """ - logger.info("Handling NO FLAG - generating conversational response") - - # Generate natural response via LLM - response_message = self._generate_dialog_response(patient_input) - - return { - "message": response_message, - "classification": classification, - "referral": None, - "questions": [], - "action": "continue", - "reasoning": "No significant spiritual distress indicators detected" - } - - def _generate_dialog_response(self, patient_input: PatientInput) -> str: - """ - Generate natural dialog response for no-flag cases. - - Uses LLM to create a conversational response in the patient's language. - """ - from src.prompts.spiritual_prompts import ( - SYSTEM_PROMPT_SPIRITUAL_DIALOG, - PROMPT_SPIRITUAL_DIALOG - ) - - try: - system_prompt = SYSTEM_PROMPT_SPIRITUAL_DIALOG() - user_prompt = PROMPT_SPIRITUAL_DIALOG( - patient_input.message, - patient_input.conversation_history - ) - - response = self.api.generate_response( - system_prompt=system_prompt, - user_prompt=user_prompt, - temperature=0.4, - call_type="SPIRITUAL_DIALOG", - agent_name="SpiritualDistressAnalyzer" - ) - - return response.strip() - - except Exception as e: - logger.error(f"Error generating dialog response: {e}") - # Fallback to static response - return self._format_no_flag_response(classification=None) - - def _format_red_flag_response( - self, - classification: DistressClassification, - referral: ReferralMessage, - patient_input: PatientInput = None - ) -> str: - """ - Format response for red flag case. - Generates response in patient's language. - - Requirements: 5.5 - """ - indicators_text = self._format_indicators(classification.indicators) - - # Generate response via LLM to match patient's language - try: - system_prompt = """You are a compassionate spiritual care assistant. Generate a supportive response for someone experiencing significant emotional/spiritual distress that: -1. Acknowledges their pain with empathy -2. Validates their feelings -3. Offers to connect them with spiritual care team -4. Reassures them they don't have to face this alone -5. MUST be in the same language as the patient's message - -Be warm, compassionate, and non-judgmental.""" - - user_prompt = f"""PATIENT MESSAGE: "{patient_input.message if patient_input else 'Unknown'}" - -DISTRESS INDICATORS: -{indicators_text} - -PATIENT CONCERNS: {referral.patient_concerns} - -Generate a compassionate response in the SAME LANGUAGE as the patient's message. Offer spiritual care support and validate their feelings.""" - - response = self.api.generate_response( - system_prompt=system_prompt, - user_prompt=user_prompt, - temperature=0.3, - call_type="SPIRITUAL_RED_FLAG_RESPONSE", - agent_name="SpiritualDistressAnalyzer" - ) - - return f"🕊️ **Spiritual Care Support**\n\n{response.strip()}" - - except Exception as e: - logger.error(f"Error generating red flag response: {e}") - # Fallback - return f"""🕊️ **Spiritual Care Support** - -{indicators_text} - -{referral.patient_concerns}""" - - def _format_yellow_flag_response( - self, - classification: DistressClassification, - questions: List[str], - patient_input: PatientInput = None - ) -> str: - """ - Format response for yellow flag case. - Generates response in patient's language. - - Requirements: 5.5 - """ - # Format indicators - indicators_text = self._format_indicators(classification.indicators) - - # Format questions - questions_text = "\n".join([f"{i}. {q}" for i, q in enumerate(questions, 1)]) - - # Generate response via LLM to match patient's language - try: - system_prompt = """You are a compassionate spiritual care assistant. Generate a supportive response that: -1. Acknowledges what the patient shared -2. Lists the emotional indicators you noticed -3. Asks clarifying questions to understand better -4. Uses warm, empathetic language -5. MUST be in the same language as the patient's message - -Keep the response structured but natural.""" - - user_prompt = f"""PATIENT MESSAGE: "{patient_input.message if patient_input else 'Unknown'}" - -INDICATORS NOTICED: -{indicators_text} - -CLARIFYING QUESTIONS TO ASK: -{questions_text} - -Generate a supportive response in the SAME LANGUAGE as the patient's message. Include the indicators and questions naturally.""" - - response = self.api.generate_response( - system_prompt=system_prompt, - user_prompt=user_prompt, - temperature=0.3, - call_type="SPIRITUAL_YELLOW_FLAG_RESPONSE", - agent_name="SpiritualDistressAnalyzer" - ) - - return f"🕊️ **Spiritual Wellness Check**\n\n{response.strip()}" - - except Exception as e: - logger.error(f"Error generating yellow flag response: {e}") - # Fallback to basic format - return f"""🕊️ **Spiritual Wellness Check** - -{indicators_text} - -{questions_text}""" - - def _format_no_flag_response( - self, - classification: DistressClassification - ) -> str: - """ - Format response for no flag case. - - Requirements: 5.5 - """ - response = """🕊️ **Spiritual Wellness** - -It's good to hear from you. Based on what you've shared, it sounds like you're managing well emotionally and spiritually right now. - -Remember that our spiritual care team is always available if you'd like to talk about: -- Finding meaning and purpose -- Coping with life changes -- Connecting with your values and beliefs -- Processing difficult emotions - -Is there anything else I can help you with today?""" - - return response - - def _format_indicators(self, indicators: List[str]) -> str: - """Format indicators list for display""" - if not indicators: - return "• General emotional concerns" - - return "\n".join([f"• {indicator}" for indicator in indicators[:5]]) - - def _create_error_response(self, error_message: str) -> Dict[str, Any]: - """ - Create safe error response. - - Defaults to yellow flag for safety. - """ - logger.error(f"Creating error response: {error_message}") - - return { - "message": """🕊️ **Spiritual Care Support** - -I'm having a bit of trouble processing that right now, but I want to make sure you get the support you need. - -Would you like me to connect you with our spiritual care team? They're available to provide compassionate support and can help with emotional and spiritual concerns. - -Alternatively, you can rephrase your message and I'll try again.""", - "classification": None, - "referral": None, - "questions": [], - "action": "continue", - "reasoning": f"Error occurred: {error_message}. Defaulting to safe response." - } - - -# Convenience function -def create_spiritual_assistant( - api: AIClientManager, - definitions_path: str = "data/spiritual_distress_definitions.json" -) -> SpiritualAssistant: - """ - Create and return a SpiritualAssistant instance. - - Args: - api: AI client manager - definitions_path: Path to definitions file - - Returns: - Initialized SpiritualAssistant - """ - return SpiritualAssistant(api, definitions_path) diff --git a/src/core/spiritual_classes.py b/src/core/spiritual_classes.py deleted file mode 100644 index 9681f4b1a2b4a18e5b14b7f929680aec8c2ae810..0000000000000000000000000000000000000000 --- a/src/core/spiritual_classes.py +++ /dev/null @@ -1,270 +0,0 @@ -# spiritual_classes.py -""" -Spiritual Health Assessment Tool - Core Data Classes - -Following existing dataclass patterns from core_classes.py -""" - -from datetime import datetime -from dataclasses import dataclass -from typing import List, Optional, Dict -import json -import os - - -@dataclass -class PatientInput: - """Patient input message for spiritual distress analysis (similar to ChatMessage)""" - message: str - timestamp: str # ISO format like ChatMessage - conversation_history: List[str] = None - - def __post_init__(self): - if self.conversation_history is None: - self.conversation_history = [] - if not self.timestamp: - self.timestamp = datetime.now().isoformat() - - -@dataclass -class DistressClassification: - """Classification result for spiritual distress analysis (similar to SessionState)""" - flag_level: str # "red", "yellow", "none" - indicators: List[str] = None - categories: List[str] = None - confidence: float = 0.0 - reasoning: str = "" - timestamp: str = "" - - def __post_init__(self): - if self.indicators is None: - self.indicators = [] - if self.categories is None: - self.categories = [] - if not self.timestamp: - self.timestamp = datetime.now().isoformat() - - -@dataclass -class ReferralMessage: - """Referral message for spiritual care team (similar to ChatMessage structure)""" - patient_concerns: str - distress_indicators: List[str] = None - context: str = "" - message_text: str = "" - timestamp: str = "" - - def __post_init__(self): - if self.distress_indicators is None: - self.distress_indicators = [] - if not self.timestamp: - self.timestamp = datetime.now().isoformat() - - -@dataclass -class ProviderFeedback: - """Provider feedback on AI assessment (similar to SessionState tracking)""" - assessment_id: str - provider_id: str = "provider_001" - agrees_with_classification: bool = False - agrees_with_referral: bool = False - comments: str = "" - timestamp: str = "" - - def __post_init__(self): - if not self.timestamp: - self.timestamp = datetime.now().isoformat() - - -class SpiritualDistressDefinitions: - """ - Manages spiritual distress definitions loaded from JSON file. - Provides access to definitions, categories, and validation. - """ - - def __init__(self): - self.definitions: Dict = {} - self._loaded = False - - def load_definitions(self, file_path: str) -> Dict: - """ - Load spiritual distress definitions from JSON file. - - Args: - file_path: Path to the JSON definitions file - - Returns: - Dictionary of loaded definitions - - Raises: - FileNotFoundError: If the definitions file doesn't exist - ValueError: If the JSON structure is invalid - json.JSONDecodeError: If the file contains invalid JSON - """ - if not os.path.exists(file_path): - raise FileNotFoundError(f"Definitions file not found: {file_path}") - - try: - with open(file_path, 'r', encoding='utf-8') as f: - data = json.load(f) - except json.JSONDecodeError as e: - raise json.JSONDecodeError( - f"Invalid JSON in definitions file: {e.msg}", - e.doc, - e.pos - ) - - # Validate the structure - self._validate_definitions(data) - - self.definitions = data - self._loaded = True - return self.definitions - - def _validate_definitions(self, data: Dict) -> None: - """ - Validate the structure of the definitions data. - - Args: - data: Dictionary to validate - - Raises: - ValueError: If the structure is invalid - """ - if not isinstance(data, dict): - raise ValueError("Definitions must be a dictionary") - - if len(data) == 0: - raise ValueError("Definitions dictionary cannot be empty") - - required_fields = ["definition", "red_flag_examples", "yellow_flag_examples", "keywords"] - - for category, content in data.items(): - if not isinstance(content, dict): - raise ValueError(f"Category '{category}' must be a dictionary") - - # Check required fields - for field in required_fields: - if field not in content: - raise ValueError(f"Category '{category}' missing required field: '{field}'") - - # Validate field types - if not isinstance(content["definition"], str): - raise ValueError(f"Category '{category}': 'definition' must be a string") - - if not isinstance(content["red_flag_examples"], list): - raise ValueError(f"Category '{category}': 'red_flag_examples' must be a list") - - if not isinstance(content["yellow_flag_examples"], list): - raise ValueError(f"Category '{category}': 'yellow_flag_examples' must be a list") - - if not isinstance(content["keywords"], list): - raise ValueError(f"Category '{category}': 'keywords' must be a list") - - # Validate that examples are non-empty strings - for example in content["red_flag_examples"]: - if not isinstance(example, str) or not example.strip(): - raise ValueError(f"Category '{category}': red_flag_examples must contain non-empty strings") - - for example in content["yellow_flag_examples"]: - if not isinstance(example, str) or not example.strip(): - raise ValueError(f"Category '{category}': yellow_flag_examples must contain non-empty strings") - - for keyword in content["keywords"]: - if not isinstance(keyword, str) or not keyword.strip(): - raise ValueError(f"Category '{category}': keywords must contain non-empty strings") - - def get_definition(self, category: str) -> Optional[str]: - """ - Get the definition for a specific category. - - Args: - category: The category name - - Returns: - The definition string, or None if category not found - """ - if not self._loaded: - raise RuntimeError("Definitions not loaded. Call load_definitions() first.") - - if category in self.definitions: - return self.definitions[category]["definition"] - return None - - def get_all_categories(self) -> List[str]: - """ - Get a list of all available category names. - - Returns: - List of category names - """ - if not self._loaded: - raise RuntimeError("Definitions not loaded. Call load_definitions() first.") - - return list(self.definitions.keys()) - - def get_category_data(self, category: str) -> Optional[Dict]: - """ - Get all data for a specific category. - - Args: - category: The category name - - Returns: - Dictionary with category data, or None if not found - """ - if not self._loaded: - raise RuntimeError("Definitions not loaded. Call load_definitions() first.") - - return self.definitions.get(category) - - def get_red_flag_examples(self, category: str) -> List[str]: - """ - Get red flag examples for a specific category. - - Args: - category: The category name - - Returns: - List of red flag examples, or empty list if category not found - """ - if not self._loaded: - raise RuntimeError("Definitions not loaded. Call load_definitions() first.") - - if category in self.definitions: - return self.definitions[category]["red_flag_examples"] - return [] - - def get_yellow_flag_examples(self, category: str) -> List[str]: - """ - Get yellow flag examples for a specific category. - - Args: - category: The category name - - Returns: - List of yellow flag examples, or empty list if category not found - """ - if not self._loaded: - raise RuntimeError("Definitions not loaded. Call load_definitions() first.") - - if category in self.definitions: - return self.definitions[category]["yellow_flag_examples"] - return [] - - def get_keywords(self, category: str) -> List[str]: - """ - Get keywords for a specific category. - - Args: - category: The category name - - Returns: - List of keywords, or empty list if category not found - """ - if not self._loaded: - raise RuntimeError("Definitions not loaded. Call load_definitions() first.") - - if category in self.definitions: - return self.definitions[category]["keywords"] - return [] diff --git a/src/interface/gradio_app.py b/src/interface/gradio_app.py deleted file mode 100644 index ae745261a5e8fd03e437dccee308d115df1cd41b..0000000000000000000000000000000000000000 --- a/src/interface/gradio_app.py +++ /dev/null @@ -1,881 +0,0 @@ -# session_isolated_interface.py - Session-isolated Gradio interface with Edit Prompts tab - -import os -from dotenv import load_dotenv - -# Load environment variables from .env file -load_dotenv() - -import gradio as gr -import json -import uuid -from datetime import datetime -from dataclasses import asdict -from typing import Dict, Any, Optional - -from lifestyle_app import ExtendedLifestyleJourneyApp -from src.core.core_classes import SessionState, ChatMessage -from src.config.prompts import ( - SYSTEM_PROMPT_MAIN_LIFESTYLE, - SYSTEM_PROMPT_ENTRY_CLASSIFIER, - SYSTEM_PROMPT_MEDICAL_ASSISTANT -) -from src.prompts.spiritual_prompts import SYSTEM_PROMPT_SPIRITUAL_ANALYZER -from src.config.dynamic import is_dynamic_prompts_enabled - -try: - from app_config import GRADIO_CONFIG -except ImportError: - GRADIO_CONFIG = {"theme": "soft", "show_api": False} - -class SessionData: - """Container for user session data""" - def __init__(self, session_id: str = None): - self.session_id = session_id or str(uuid.uuid4()) - self.app_instance = ExtendedLifestyleJourneyApp() - self.created_at = datetime.now().isoformat() - self.last_activity = datetime.now().isoformat() - # NEW: Custom prompts storage - self.custom_prompts = { - "main_lifestyle": SYSTEM_PROMPT_MAIN_LIFESTYLE # Default prompt - } - self.prompts_modified = False - - def to_dict(self) -> Dict[str, Any]: - """Serialize session for storage""" - return { - "session_id": self.session_id, - "created_at": self.created_at, - "last_activity": self.last_activity, - "chat_history": [asdict(msg) for msg in self.app_instance.chat_history], - "session_state": asdict(self.app_instance.session_state), - "test_mode_active": self.app_instance.test_mode_active, - "current_test_patient": self.app_instance.current_test_patient, - "custom_prompts": self.custom_prompts, - "prompts_modified": self.prompts_modified - } - - def update_activity(self): - """Update last activity timestamp""" - self.last_activity = datetime.now().isoformat() - - def set_custom_prompt(self, prompt_name: str, prompt_text: str): - """Set custom prompt for this session""" - self.custom_prompts[prompt_name] = prompt_text - self.prompts_modified = True - # Update the app instance to use custom prompt - if hasattr(self.app_instance, 'main_lifestyle_assistant'): - self.app_instance.main_lifestyle_assistant.set_custom_system_prompt(prompt_text) - - def reset_prompt_to_default(self, prompt_name: str): - """Reset prompt to default""" - if prompt_name == "main_lifestyle": - self.custom_prompts[prompt_name] = SYSTEM_PROMPT_MAIN_LIFESTYLE - self.prompts_modified = False - # Update the app instance - if hasattr(self.app_instance, 'main_lifestyle_assistant'): - self.app_instance.main_lifestyle_assistant.reset_to_default_prompt() - - # NEW: Force static default mode (disable dynamic by pinning default as custom) - def set_static_default_mode(self): - self.custom_prompts["main_lifestyle"] = SYSTEM_PROMPT_MAIN_LIFESTYLE - self.prompts_modified = False - if hasattr(self.app_instance, 'main_lifestyle_assistant'): - # Set default as custom to override dynamic composition - self.app_instance.main_lifestyle_assistant.set_custom_system_prompt(SYSTEM_PROMPT_MAIN_LIFESTYLE) - -def load_instructions() -> str: - """Load instructions from INSTRUCTION.md file""" - try: - with open("INSTRUCTION.md", "r", encoding="utf-8") as f: - content = f.read() - return content - except FileNotFoundError: - return """# 📖 Instructions Unavailable - -❌ **File INSTRUCTION.md not found** - -To view the full instructions, please ensure the `INSTRUCTION.md` file is in the application's root folder. - -## 🚀 Quick Start - -1. **For medical questions:** "I have a headache" -2. **For lifestyle coaching:** "I want to start exercising" -3. **For testing:** Go to the "🧪 Testing Lab" tab - -## ⚠️ Important -This application is not a substitute for professional medical advice. In case of serious symptoms, please consult a doctor. -""" - except Exception as e: - return f"""# ❌ Error Loading Instructions - -An error occurred while reading the instructions file: `{str(e)}` - -## 🔧 Recommendations -- Check that the INSTRUCTION.md file exists -- Ensure the file has the correct UTF-8 encoding -- Restart the application - -## 🆘 Basic Help -For help, type "help" or "how to use" in the chat. -""" - -def create_session_isolated_interface(): - """Create session-isolated Gradio interface with Edit Prompts tab""" - - log_prompts_enabled = os.getenv("LOG_PROMPTS", "false").lower() == "true" - - theme_name = GRADIO_CONFIG.get("theme", "soft") - if theme_name.lower() == "soft": - theme = gr.themes.Soft() - elif theme_name.lower() == "default": - theme = gr.themes.Default() - else: - theme = gr.themes.Soft() - - # Gradio 6.x: theme is set via .theme() method after Blocks creation - demo = gr.Blocks( - title=GRADIO_CONFIG.get("title", "🏥 Lifestyle Journey + 🕊️ Spiritual Health + 🧪 Testing Lab + 🔧 Prompt Editor"), - analytics_enabled=False - ) - demo.theme = theme - - with demo: - # Session state - CRITICAL: Each user gets isolated state - session_data = gr.State(value=None) - - # Header - if log_prompts_enabled: - gr.Markdown("# 🏥 Lifestyle Journey + 🕊️ Spiritual Health Assessment + 🧪 Testing Lab + 🔧 Prompt Editor 📝") - gr.Markdown("⚠️ **DEBUG MODE:** LLM prompts and responses are saved to `lifestyle_journey.log`") - else: - gr.Markdown("# 🏥 Lifestyle Journey + 🕊️ Spiritual Health Assessment + 🧪 Testing Lab + 🔧 Prompt Editor") - - gr.Markdown("Integrated medical chatbot with lifestyle coaching, spiritual distress assessment, testing system, and prompt customization") - - # Session info - with gr.Row(): - session_info = gr.Markdown("🔄 **Initializing session...**") - - # Initialize session on load - def initialize_session(): - """Initialize new user session""" - new_session = SessionData() - # Default: Static mode (pin default prompt) - new_session.set_static_default_mode() - session_info_text = f""" -✅ **Session Initialized** -🆔 **Session ID:** `{new_session.session_id[:8]}...` -🕒 **Started:** {new_session.created_at[:19]} -👤 **Isolated Instance:** Each user has separate data -🎯 **Assistant Mode:** 🏥 Medical Only (default) - """ - return new_session, session_info_text - - # Main tabs - with gr.Tabs(): - # Main chat tab - with gr.TabItem("💬 Patient Chat", id="main_chat"): - with gr.Row(): - with gr.Column(scale=2): - chatbot = gr.Chatbot( - label="💬 Conversation with Assistant", - height=400 - # Note: Gradio 6.x auto-detects message format - # show_copy_button and type parameters removed - ) - - with gr.Row(): - msg = gr.Textbox( - label="Your message", - placeholder="Type your question...", - scale=4 - ) - send_btn = gr.Button("📤 Send", scale=1) - - with gr.Row(): - clear_btn = gr.Button("🗑️ Clear Chat", scale=1) - end_conversation_btn = gr.Button("🏁 End Conversation", scale=1, variant="secondary") - - # Quick start examples - gr.Markdown("### ⚡ Quick Start:") - with gr.Row(): - example_medical_btn = gr.Button("🩺 I have a headache", size="sm") - example_lifestyle_btn = gr.Button("💚 I want to start exercising", size="sm") - example_help_btn = gr.Button("❓ Help", size="sm") - - with gr.Column(scale=1): - status_box = gr.Markdown( - value="🔄 Loading status...", - label="📊 System Status" - ) - - gr.Markdown("### 🎯 Assistant Mode") - assistant_mode_selector = gr.Radio( - choices=[ - "🏥 Medical Only", - "💚 Lifestyle Focus", - "🕊️ Spiritual Focus", - "🌟 Combined (Lifestyle + Spiritual)" - ], - value="🏥 Medical Only", - label="Choose your support mode", - info="Select the type of assistance you need" - ) - - refresh_status_btn = gr.Button("🔄 Refresh Status", size="sm") - - end_conversation_result = gr.Markdown(value="", visible=False) - - # NEW: Edit Prompts tab - with gr.TabItem("🔧 Edit Prompts", id="edit_prompts"): - gr.Markdown("## 🔧 Customize AI Assistant Prompts") - gr.Markdown("⚠️ **Note:** Changes apply only to your current session and will be lost when you close the browser.") - - # Prompt selector - with gr.Row(): - prompt_selector = gr.Dropdown( - choices=[ - "💚 Main Lifestyle Assistant", - "🔍 Entry Classifier", - "🏥 Medical Assistant", - "🕊️ Spiritual Distress Analyzer" - ], - value="💚 Main Lifestyle Assistant", - label="Select Prompt to Edit", - interactive=True - ) - - with gr.Row(): - with gr.Column(scale=3): - # Prompt editor with better scrolling - prompt_editor = gr.Code( - label="System Prompt", - value=SYSTEM_PROMPT_MAIN_LIFESTYLE, - language="markdown", - lines=30, - interactive=True, - show_label=True - ) - - with gr.Row(): - apply_prompt_btn = gr.Button("✅ Apply Changes", variant="primary", scale=2) - reset_prompt_btn = gr.Button("🔄 Reset to Default", variant="secondary", scale=1) - preview_prompt_btn = gr.Button("👁️ Preview", size="sm", scale=1) - - prompt_status = gr.Markdown(value="", visible=True) - - with gr.Column(scale=1): - gr.Markdown("### 📋 Prompt Guidelines") - gr.Markdown(""" -**🎯 Key Elements to Include:** -- **Role definition** (lifestyle coach) -- **Safety principles** (medical limitations) -- **Action logic** (gather_info/lifestyle_dialog/close) -- **Output format** (JSON with message/action/reasoning) - -**⚠️ Important:** -- Keep JSON format for actions -- Maintain safety guidelines -- Consider patient's medical conditions -- Use same language as patient - -**🔧 Actions:** -- `gather_info` - collect more details -- `lifestyle_dialog` - provide coaching -- `close` - end session safely - -**💡 Tips:** -- Test changes with simple questions -- Use "🔄 Reset" if issues occur -- Check JSON format carefully - """) - - gr.Markdown("### 📊 Current Settings") - prompt_info = gr.Markdown(value="🔄 Default prompt active") - - # Testing Lab tab - with gr.TabItem("🧪 Testing Lab", id="testing_lab"): - gr.Markdown("## 📁 Load Test Patient") - - with gr.Row(): - with gr.Column(): - clinical_file = gr.File( - label="🏥 Clinical Background JSON", - file_types=[".json"], - type="filepath" - ) - lifestyle_file = gr.File( - label="💚 Lifestyle Profile JSON", - file_types=[".json"], - type="filepath" - ) - - load_patient_btn = gr.Button("📋 Load Patient", variant="primary") - - with gr.Column(): - load_result = gr.Markdown(value="Select files to load") - - # Quick test buttons - gr.Markdown("## ⚡ Quick Testing (Built-in Data)") - with gr.Row(): - quick_elderly_btn = gr.Button("👵 Elderly Mary", size="sm") - quick_athlete_btn = gr.Button("🏃 Athletic John", size="sm") - quick_pregnant_btn = gr.Button("🤰 Pregnant Sarah", size="sm") - - gr.Markdown("## 👤 Patient Preview") - patient_preview = gr.Markdown(value="No patient loaded") - - gr.Markdown("## 🎯 Test Session Management") - with gr.Row(): - end_session_notes = gr.Textbox( - label="Session End Notes", - placeholder="Describe testing results...", - lines=3 - ) - with gr.Column(): - end_session_btn = gr.Button("⏹️ End Test Session") - end_session_result = gr.Markdown(value="") - - # Test results tab - with gr.TabItem("📊 Test Results", id="test_results"): - gr.Markdown("## 📈 Test Session Analysis") - - refresh_results_btn = gr.Button("🔄 Refresh Results") - - with gr.Row(): - with gr.Column(scale=2): - results_summary = gr.Markdown(value="Click 'Refresh Results'") - - with gr.Column(scale=1): - export_btn = gr.Button("💾 Export to CSV") - export_result = gr.Markdown(value="") - - gr.Markdown("## 📋 Recent Test Sessions") - results_table = gr.Dataframe( - headers=["Patient", "Time", "Messages", "Medical", "Lifestyle", "Escalations", "Duration", "Notes"], - datatype=["str", "str", "number", "number", "number", "number", "str", "str"], - label="Session Details", - value=[] - ) - - # Instructions tab - with gr.TabItem("📖 Instructions", id="instructions"): - gr.Markdown("## 📚 User Guide") - - # Load and display instructions - instructions_content = load_instructions() - - with gr.Row(): - with gr.Column(scale=4): - instructions_display = gr.Markdown( - value=instructions_content, - label="📖 Instructions" - ) - - with gr.Column(scale=1): - gr.Markdown("### 🔗 Quick Links") - - # Quick navigation buttons - medical_example_btn = gr.Button("🩺 Medical Example", size="sm") - lifestyle_example_btn = gr.Button("💚 Lifestyle Example", size="sm") - testing_example_btn = gr.Button("🧪 Testing", size="sm") - prompts_example_btn = gr.Button("🔧 Edit Prompts", size="sm") - - gr.Markdown("### 📞 Help") - refresh_instructions_btn = gr.Button("🔄 Refresh Instructions", size="sm") - - gr.Markdown(""" -**💡 Quick Commands:** -- "help" - get assistance -- "example" - see examples -- "clear" - start over - """) - - # Session-isolated event handlers - def handle_message_isolated(message: str, history, session: SessionData): - """Session-isolated message handler""" - if session is None: - session = SessionData() - - session.update_activity() - new_history, status = session.app_instance.process_message(message, history) - return new_history, status, session - - def handle_clear_isolated(session: SessionData): - """Session-isolated clear handler""" - if session is None: - session = SessionData() - - session.update_activity() - new_history, status = session.app_instance.reset_session() - return new_history, status, session - - def handle_load_patient_isolated(clinical_file, lifestyle_file, session: SessionData): - """Session-isolated patient loading""" - if session is None: - session = SessionData() - - session.update_activity() - result = session.app_instance.load_test_patient(clinical_file, lifestyle_file) - return result + (session,) - - def handle_quick_test_isolated(patient_type: str, session: SessionData): - """Session-isolated quick test loading""" - if session is None: - session = SessionData() - - session.update_activity() - result = session.app_instance.load_quick_test_patient(patient_type) - return result + (session,) - - def handle_end_conversation_isolated(session: SessionData): - """Session-isolated conversation end""" - if session is None: - session = SessionData() - - session.update_activity() - return session.app_instance.end_conversation_with_profile_update() + (session,) - - def get_status_isolated(session: SessionData): - """Get session-isolated status""" - if session is None: - return "❌ Session not initialized" - - session.update_activity() - base_status = session.app_instance._get_status_info() - - # Add prompt status - prompt_status = "" - if session.prompts_modified: - prompt_status = f""" -🔧 **CUSTOM PROMPTS:** -• Main Lifestyle: ✅ Modified ({len(session.custom_prompts.get('main_lifestyle', ''))} chars) -• Status: Custom prompt active for this session -""" - else: - prompt_status = f""" -🔧 **CUSTOM PROMPTS:** -• Main Lifestyle: 🔄 Default prompt -• Status: Using original system prompts -""" - - dynamic_note = "" - if not is_dynamic_prompts_enabled(): - dynamic_note = "\n⚠️ **Dynamic prompts disabled by configuration**" - - session_status = f""" -🔐 **SESSION ISOLATION:** -• Session ID: {session.session_id[:8]}... -• Created: {session.created_at[:19]} -• Last Activity: {session.last_activity[:19]} -• Isolated: ✅ Your data is private{dynamic_note} -{prompt_status} -{base_status} - """ - return session_status - - # NEW: Assistant mode switching handler - def handle_mode_change(mode_label: str, history, session: SessionData): - """ - Handle assistant mode change. - - Closes current session, updates mode, and returns updated UI. - - Requirements: 1.4, 8.2 - """ - if session is None: - session = SessionData() - - session.update_activity() - - try: - # Import AssistantMode enum - from src.core.core_classes import AssistantMode - - # Close current session before switching (Requirement: 1.5, 10.1-10.3) - session.app_instance._close_current_session() - - # Map UI label to AssistantMode - mode_mapping = { - "🏥 Medical Only": AssistantMode.MEDICAL, - "💚 Lifestyle Focus": AssistantMode.LIFESTYLE, - "🕊️ Spiritual Focus": AssistantMode.SPIRITUAL, - "🌟 Combined (Lifestyle + Spiritual)": AssistantMode.COMBINED - } - - new_mode = mode_mapping.get(mode_label, AssistantMode.MEDICAL) - - # Update session state mode (Requirement: 1.4) - session.app_instance.session_state.update_mode(new_mode) - - # Get updated status - status = session.app_instance._get_status_info() - - # Add mode change notification to chat - mode_icons = { - AssistantMode.MEDICAL: "🏥", - AssistantMode.LIFESTYLE: "💚", - AssistantMode.SPIRITUAL: "🕊️", - AssistantMode.COMBINED: "🌟" - } - - icon = mode_icons.get(new_mode, "⚪") - mode_name = new_mode.value.title() - - notification = f"{icon} **Mode switched to: {mode_name}**" - - # Add notification to history - if history is None: - history = [] - - history.append({ - "role": "assistant", - "content": notification - }) - - return history, status, session - - except Exception as e: - error_msg = f"❌ Failed to switch mode: {str(e)}" - if history is None: - history = [] - history.append({"role": "assistant", "content": error_msg}) - return history, session.app_instance._get_status_info(), session - - # NEW: Prompt editing handlers - def apply_custom_prompt(prompt_text: str, prompt_selection: str, session: SessionData): - """Apply custom prompt to session""" - if session is None: - session = SessionData() - - session.update_activity() - - # Validate prompt (basic check) - if not prompt_text.strip(): - return "❌ Prompt cannot be empty", session, "❌ Empty prompt" - - if len(prompt_text.strip()) < 50: - return "⚠️ Prompt seems too short. Are you sure it's complete?", session, "⚠️ Short prompt" - - # Map selection to prompt name - prompt_name_map = { - "💚 Main Lifestyle Assistant": "main_lifestyle", - "🔍 Entry Classifier": "entry_classifier", - "🏥 Medical Assistant": "medical_assistant", - "🕊️ Spiritual Distress Analyzer": "spiritual_analyzer" - } - - prompt_name = prompt_name_map.get(prompt_selection, "main_lifestyle") - - try: - # Apply the custom prompt - session.set_custom_prompt(prompt_name, prompt_text.strip()) - - status_msg = f"""✅ **Custom prompt applied successfully!** - -📊 **Details:** -• Length: {len(prompt_text.strip())} characters -• Applied to: {prompt_selection} -• Session: {session.session_id[:8]}... -• Status: Active for this session only - -🔄 **Next steps:** -• Test the changes in the appropriate mode -• Use "Reset to Default" if you encounter issues - -⚠️ **Note:** Some prompts may require specific mode activation to take effect.""" - - info_msg = f"✅ Custom prompt active ({len(prompt_text.strip())} chars)" - - return status_msg, session, info_msg - - except Exception as e: - error_msg = f"❌ Error applying prompt: {str(e)}" - return error_msg, session, "❌ Application failed" - - def reset_prompt_to_default(prompt_selection: str, session: SessionData): - """Reset prompt to default""" - if session is None: - session = SessionData() - - session.update_activity() - - # Map selection to prompt name and default value - prompt_defaults = { - "💚 Main Lifestyle Assistant": ("main_lifestyle", SYSTEM_PROMPT_MAIN_LIFESTYLE), - "🔍 Entry Classifier": ("entry_classifier", SYSTEM_PROMPT_ENTRY_CLASSIFIER), - "🏥 Medical Assistant": ("medical_assistant", SYSTEM_PROMPT_MEDICAL_ASSISTANT), - "🕊️ Spiritual Distress Analyzer": ("spiritual_analyzer", SYSTEM_PROMPT_SPIRITUAL_ANALYZER()) - } - - prompt_name, default_value = prompt_defaults.get( - prompt_selection, - ("main_lifestyle", SYSTEM_PROMPT_MAIN_LIFESTYLE) - ) - - session.reset_prompt_to_default(prompt_name) - - status_msg = f"""🔄 **Prompt reset to default** - -📊 **Details:** -• {prompt_selection} prompt restored -• Session: {session.session_id[:8]}... -• All customizations removed - -💡 You can edit and apply again at any time. -""" - - return default_value, status_msg, session, "🔄 Default prompt active" - - def preview_prompt_changes(prompt_text: str): - """Preview prompt changes""" - if not prompt_text.strip(): - return "❌ No prompt text to preview" - - preview = f"""📋 **Prompt Preview:** - -**Length:** {len(prompt_text.strip())} characters -**Lines:** {len(prompt_text.strip().split(chr(10)))} lines - -**First 200 characters:** -``` -{prompt_text.strip()[:200]}{'...' if len(prompt_text.strip()) > 200 else ''} -``` - -**Contains key elements:** -• JSON format mentioned: {'✅' if 'json' in prompt_text.lower() or 'JSON' in prompt_text else '❌'} -• Actions mentioned: {'✅' if 'gather_info' in prompt_text and 'lifestyle_dialog' in prompt_text and 'close' in prompt_text else '❌'} -• Safety guidelines: {'✅' if 'safety' in prompt_text.lower() or 'medical' in prompt_text.lower() else '❌'} - -**Ready to apply:** {'✅ Yes' if len(prompt_text.strip()) > 50 else '❌ Too short'} -""" - return preview - - def load_prompt_by_selection(selection: str): - """Load prompt based on dropdown selection""" - prompt_map = { - "💚 Main Lifestyle Assistant": SYSTEM_PROMPT_MAIN_LIFESTYLE, - "🔍 Entry Classifier": SYSTEM_PROMPT_ENTRY_CLASSIFIER, - "🏥 Medical Assistant": SYSTEM_PROMPT_MEDICAL_ASSISTANT, - "🕊️ Spiritual Distress Analyzer": SYSTEM_PROMPT_SPIRITUAL_ANALYZER() - } - - prompt_text = prompt_map.get(selection, SYSTEM_PROMPT_MAIN_LIFESTYLE) - - # All prompts are now editable - return gr.update(value=prompt_text, interactive=True) - - # Helper functions for examples and instructions - def send_example_message(example_text: str, history, session: SessionData): - """Send example message to chat""" - return handle_message_isolated(example_text, history, session) - - def refresh_instructions(): - """Refresh instructions content""" - return load_instructions() - - def handle_end_session_isolated(notes: str, session: SessionData): - """Session-isolated end session handler""" - if session is None: - session = SessionData() - - session.update_activity() - result = session.app_instance.end_test_session(notes) - return result, session - - def handle_refresh_results_isolated(session: SessionData): - """Session-isolated refresh results handler""" - if session is None: - session = SessionData() - - session.update_activity() - result = session.app_instance.get_test_results_summary() - return result + (session,) - - def handle_export_isolated(session: SessionData): - """Session-isolated export handler""" - if session is None: - session = SessionData() - - session.update_activity() - result = session.app_instance.export_test_results() - return result, session - - # Event binding with session isolation - demo.load( - initialize_session, - outputs=[session_data, session_info] - ) - - # Main chat events - send_btn.click( - handle_message_isolated, - inputs=[msg, chatbot, session_data], - outputs=[chatbot, status_box, session_data] - ).then( - lambda: "", - outputs=[msg] - ) - - msg.submit( - handle_message_isolated, - inputs=[msg, chatbot, session_data], - outputs=[chatbot, status_box, session_data] - ).then( - lambda: "", - outputs=[msg] - ) - - clear_btn.click( - handle_clear_isolated, - inputs=[session_data], - outputs=[chatbot, status_box, session_data] - ) - - end_conversation_btn.click( - handle_end_conversation_isolated, - inputs=[session_data], - outputs=[chatbot, status_box, end_conversation_result, session_data] - ) - - # Status refresh - refresh_status_btn.click( - get_status_isolated, - inputs=[session_data], - outputs=[status_box] - ) - - # Assistant mode change handler (Requirement: 8.2) - assistant_mode_selector.change( - handle_mode_change, - inputs=[assistant_mode_selector, chatbot, session_data], - outputs=[chatbot, status_box, session_data] - ) - - # NEW: Prompt editing events - - # Prompt selector change - prompt_selector.change( - load_prompt_by_selection, - inputs=[prompt_selector], - outputs=[prompt_editor] - ) - - apply_prompt_btn.click( - apply_custom_prompt, - inputs=[prompt_editor, prompt_selector, session_data], - outputs=[prompt_status, session_data, prompt_info] - ) - - reset_prompt_btn.click( - reset_prompt_to_default, - inputs=[prompt_selector, session_data], - outputs=[prompt_editor, prompt_status, session_data, prompt_info] - ) - - preview_prompt_btn.click( - preview_prompt_changes, - inputs=[prompt_editor], - outputs=[prompt_status] - ) - - # Quick example buttons in chat - example_medical_btn.click( - lambda history, session: send_example_message("I have a headache", history, session), - inputs=[chatbot, session_data], - outputs=[chatbot, status_box, session_data] - ) - - example_lifestyle_btn.click( - lambda history, session: send_example_message("I want to start exercising", history, session), - inputs=[chatbot, session_data], - outputs=[chatbot, status_box, session_data] - ) - - example_help_btn.click( - lambda history, session: send_example_message("Help - how do I use this application?", history, session), - inputs=[chatbot, session_data], - outputs=[chatbot, status_box, session_data] - ) - - # Instructions tab events - refresh_instructions_btn.click( - refresh_instructions, - outputs=[instructions_display] - ) - - # Navigation from instructions to examples - medical_example_btn.click( - lambda: gr.update(selected="main_chat"), # Switch to chat tab - outputs=[] - ) - - lifestyle_example_btn.click( - lambda: gr.update(selected="main_chat"), # Switch to chat tab - outputs=[] - ) - - testing_example_btn.click( - lambda: gr.update(selected="testing_lab"), # Switch to testing tab - outputs=[] - ) - - prompts_example_btn.click( - lambda: gr.update(selected="edit_prompts"), # Switch to prompts tab - outputs=[] - ) - - # Testing Lab handlers with session isolation - load_patient_btn.click( - handle_load_patient_isolated, - inputs=[clinical_file, lifestyle_file, session_data], - outputs=[load_result, patient_preview, chatbot, status_box, session_data] - ) - - quick_elderly_btn.click( - lambda session: handle_quick_test_isolated("elderly", session), - inputs=[session_data], - outputs=[load_result, patient_preview, chatbot, status_box, session_data] - ) - - quick_athlete_btn.click( - lambda session: handle_quick_test_isolated("athlete", session), - inputs=[session_data], - outputs=[load_result, patient_preview, chatbot, status_box, session_data] - ) - - quick_pregnant_btn.click( - lambda session: handle_quick_test_isolated("pregnant", session), - inputs=[session_data], - outputs=[load_result, patient_preview, chatbot, status_box, session_data] - ) - - end_session_btn.click( - handle_end_session_isolated, - inputs=[end_session_notes, session_data], - outputs=[end_session_result, session_data] - ) - - # Results handlers - refresh_results_btn.click( - handle_refresh_results_isolated, - inputs=[session_data], - outputs=[results_summary, results_table, session_data] - ) - - export_btn.click( - handle_export_isolated, - inputs=[session_data], - outputs=[export_result, session_data] - ) - - return demo - -# Create alias for backward compatibility -create_gradio_interface = create_session_isolated_interface - -# Usage -if __name__ == "__main__": - demo = create_session_isolated_interface() - demo.launch() diff --git a/src/interface/spiritual_interface.py b/src/interface/spiritual_interface.py deleted file mode 100644 index a16cc93c8cc0ecfc299ffb27c530d59513e6dad4..0000000000000000000000000000000000000000 --- a/src/interface/spiritual_interface.py +++ /dev/null @@ -1,869 +0,0 @@ -# spiritual_interface.py -""" -Spiritual Health Assessment Tool - Gradio Interface - -Following gradio_app.py structure with session isolation patterns. -Implements validation interface for spiritual distress assessment. - -Requirements: 5.1, 5.2, 5.3, 5.4, 5.5, 5.6, 8.1, 8.2, 8.3, 8.4, 8.5, 10.2, 10.4, 10.5 -""" - -import os -import gradio as gr -import uuid -import logging -from datetime import datetime -from typing import Dict, Any, Optional, List, Tuple - -from src.core.ai_client import AIClientManager -from src.core.spiritual_analyzer import ( - SpiritualDistressAnalyzer, - ReferralMessageGenerator, - ClarifyingQuestionGenerator -) -from src.core.spiritual_classes import ( - PatientInput, - DistressClassification, - ReferralMessage, - ProviderFeedback -) -from src.storage.feedback_store import FeedbackStore - - -class SessionData: - """ - Container for user session data. - - Following the SessionData pattern from gradio_app.py. - Each user gets isolated state for their assessments. - """ - - def __init__(self, session_id: str = None): - self.session_id = session_id or str(uuid.uuid4()) - self.created_at = datetime.now().isoformat() - self.last_activity = datetime.now().isoformat() - - # Initialize AI components - self.api = AIClientManager() - self.analyzer = SpiritualDistressAnalyzer(self.api) - self.referral_generator = ReferralMessageGenerator(self.api) - self.question_generator = ClarifyingQuestionGenerator(self.api) - self.feedback_store = FeedbackStore() - - # Current assessment state - self.current_patient_input: Optional[PatientInput] = None - self.current_classification: Optional[DistressClassification] = None - self.current_referral: Optional[ReferralMessage] = None - self.current_questions: List[str] = [] - self.current_assessment_id: Optional[str] = None - - # Assessment history for this session - self.assessment_history: List[Dict] = [] - - def update_activity(self): - """Update last activity timestamp""" - self.last_activity = datetime.now().isoformat() - - def to_dict(self) -> Dict[str, Any]: - """Serialize session for storage""" - return { - "session_id": self.session_id, - "created_at": self.created_at, - "last_activity": self.last_activity, - "assessment_count": len(self.assessment_history) - } - - -def create_spiritual_interface(): - """ - Create session-isolated Gradio interface for spiritual health assessment. - - Following gradio_app.py structure with tabs for: - - Assessment: Main assessment interface - - History: Previous assessments - - Instructions: User guide - - Requirements: 5.1, 5.2, 5.3, 5.4, 5.5, 5.6, 8.1, 8.2, 8.3, 8.4, 8.5, 10.2, 10.4, 10.5 - """ - - log_prompts_enabled = os.getenv("LOG_PROMPTS", "false").lower() == "true" - - # Use Soft theme like existing app - theme = gr.themes.Soft() - - # Gradio 6.x: theme is set via .theme attribute after Blocks creation - demo = gr.Blocks( - title="Spiritual Health Assessment Tool", - analytics_enabled=False - ) - demo.theme = theme - - with demo: - # Session state - CRITICAL: Each user gets isolated state - session_data = gr.State(value=None) - - # Header - if log_prompts_enabled: - gr.Markdown("# 🕊️ Spiritual Health Assessment Tool 📝") - gr.Markdown("⚠️ **DEBUG MODE:** LLM prompts and responses are logged") - else: - gr.Markdown("# 🕊️ Spiritual Health Assessment Tool") - - gr.Markdown("AI-powered spiritual distress detection with provider validation") - - # Session info - with gr.Row(): - session_info = gr.Markdown("🔄 **Initializing session...**") - - # Initialize session on load - def initialize_session(): - """Initialize new user session""" - new_session = SessionData() - session_info_text = f""" -✅ **Session Initialized** -🆔 **Session ID:** `{new_session.session_id[:8]}...` -🕒 **Started:** {new_session.created_at[:19]} -👤 **Isolated Instance:** Each user has separate data - """ - return new_session, session_info_text - - # Main tabs - with gr.Tabs(): - # Assessment tab - with gr.TabItem("🔍 Assessment", id="assessment"): - gr.Markdown("## Patient Input") - gr.Markdown("Enter patient message to analyze for spiritual distress indicators") - - with gr.Row(): - with gr.Column(scale=3): - # Input panel (Requirement 5.1, 5.2) - patient_message = gr.Textbox( - label="Patient Message", - placeholder="Enter patient's message here...", - lines=5, - max_lines=10 - ) - - with gr.Row(): - analyze_btn = gr.Button("🔍 Analyze", variant="primary", scale=2) - clear_btn = gr.Button("🗑️ Clear", scale=1) - - # Quick test examples - gr.Markdown("### ⚡ Quick Test Examples:") - with gr.Row(): - example_red_btn = gr.Button("🔴 Red Flag Example", size="sm") - example_yellow_btn = gr.Button("🟡 Yellow Flag Example", size="sm") - example_none_btn = gr.Button("🟢 No Flag Example", size="sm") - - with gr.Column(scale=1): - gr.Markdown("### 📊 Assessment Status") - status_display = gr.Markdown("Ready to analyze") - - # Results display (Requirements 5.3, 5.4) - gr.Markdown("## 📋 Assessment Results") - - with gr.Row(): - with gr.Column(scale=2): - # Classification display with color-coded badges - classification_display = gr.Markdown( - value="", - label="Classification Results" - ) - - # Detected indicators (Requirement 5.4) - indicators_display = gr.Markdown( - value="", - label="Detected Indicators" - ) - - # Reasoning (Requirement 5.4) - reasoning_display = gr.Markdown( - value="", - label="Analysis Reasoning" - ) - - # Generated referral message (Requirement 5.3) - referral_display = gr.Markdown( - value="", - label="Referral Message" - ) - - # Clarifying questions (for yellow flags) - questions_display = gr.Markdown( - value="", - label="Clarifying Questions" - ) - - with gr.Column(scale=1): - # Feedback panel (Requirements 5.5, 5.6) - gr.Markdown("### 💬 Provider Feedback") - - provider_id = gr.Textbox( - label="Provider ID", - value="provider_001", - placeholder="Enter your provider ID" - ) - - agrees_classification = gr.Checkbox( - label="✅ I agree with the classification", - value=False - ) - - agrees_referral = gr.Checkbox( - label="✅ I agree with the referral message", - value=False - ) - - feedback_comments = gr.Textbox( - label="Comments/Notes", - placeholder="Add any comments or observations...", - lines=4 - ) - - submit_feedback_btn = gr.Button( - "📤 Submit Feedback", - variant="primary" - ) - - feedback_result = gr.Markdown(value="") - - # History tab (Requirements 8.1, 8.2, 8.3, 8.4, 8.5) - with gr.TabItem("📊 History", id="history"): - gr.Markdown("## Assessment History") - gr.Markdown("Review previous assessments and feedback") - - with gr.Row(): - refresh_history_btn = gr.Button("🔄 Refresh History") - export_csv_btn = gr.Button("💾 Export to CSV") - - export_result = gr.Markdown(value="") - - # History table (Requirement 8.4) - history_table = gr.Dataframe( - headers=[ - "Timestamp", - "Flag Level", - "Indicators", - "Confidence", - "Provider Agreed", - "Comments" - ], - datatype=["str", "str", "str", "number", "str", "str"], - label="Assessment History", - value=[] - ) - - # Summary statistics (Requirement 8.5) - gr.Markdown("## 📈 Summary Statistics") - summary_display = gr.Markdown(value="Click 'Refresh History' to load statistics") - - # Instructions tab (Requirement 10.2) - with gr.TabItem("📖 Instructions", id="instructions"): - gr.Markdown(""" -## 📚 Spiritual Health Assessment Tool - User Guide - -### 🎯 Purpose - -This tool helps healthcare providers identify patients who may benefit from spiritual care services by: -- Analyzing patient conversations for emotional and spiritual distress indicators -- Classifying severity levels (red flag, yellow flag, or no flag) -- Generating appropriate referral messages for the spiritual care team -- Collecting provider feedback to improve system accuracy - -### 🚦 Classification Levels - -**🔴 Red Flag** - Clear indicators of severe emotional/spiritual distress -- Requires immediate spiritual care referral -- Examples: "I am angry all the time", "I am crying all the time" -- System generates referral message automatically - -**🟡 Yellow Flag** - Potential indicators requiring further assessment -- System generates clarifying questions -- Provider can gather more information before making referral decision -- Examples: "I've been feeling frustrated lately", "Things are bothering me" - -**🟢 No Flag** - No significant distress indicators detected -- No spiritual care referral needed at this time -- Patient may still benefit from routine spiritual support - -### 📝 How to Use - -1. **Enter Patient Message**: Type or paste the patient's message in the input box -2. **Analyze**: Click the "Analyze" button to process the message -3. **Review Results**: Examine the classification, indicators, and reasoning -4. **Provide Feedback**: - - Check boxes to indicate agreement with classification/referral - - Add comments or observations - - Submit feedback to help improve the system -5. **View History**: Check the History tab to review past assessments - -### ⚡ Quick Test Examples - -Use the example buttons to test the system with pre-defined scenarios: -- **Red Flag Example**: Tests severe distress detection -- **Yellow Flag Example**: Tests ambiguous case handling -- **No Flag Example**: Tests neutral message classification - -### 🔒 Privacy & Safety - -- All data is session-isolated (your assessments are private) -- No PHI (Protected Health Information) is stored -- System uses conservative classification (defaults to yellow flag when uncertain) -- Provider review and feedback is essential for patient safety - -### 🌍 Multi-Faith Sensitivity - -The system is designed to: -- Detect distress indicators regardless of religious affiliation -- Use inclusive, non-denominational language in referrals -- Preserve specific religious context when mentioned by patients -- Avoid assumptions about patients' spiritual beliefs - -### 📊 Feedback & Analytics - -Your feedback helps improve the system: -- Agreement rates are tracked to measure accuracy -- Common indicators and patterns are identified -- Export data to CSV for detailed analysis -- Summary statistics show system performance - -### ⚠️ Important Notes - -- This tool is for clinical decision support only -- Provider judgment is essential - do not rely solely on AI assessment -- In case of immediate safety concerns, follow standard clinical protocols -- System defaults to conservative classification for patient safety - -### 🆘 Support - -For technical issues or questions: -- Check the session status in the header -- Review error messages in the status display -- Contact system administrator if problems persist - """) - - # Session-isolated event handlers - - def handle_analyze(message: str, session: SessionData) -> Tuple: - """ - Analyze patient message for spiritual distress. - - Session-isolated handler following gradio_app.py pattern. - Enhanced with user-friendly error messages (Requirement 10.5). - - Returns tuple of display components - """ - if session is None: - session = SessionData() - - session.update_activity() - - # Input validation with user-friendly messages (Requirement 10.5) - if not message: - return ( - "❌ **Error:** Please enter a patient message to analyze", - "", "", "", "", "", "", - session - ) - - if not message.strip(): - return ( - "❌ **Error:** Message cannot be empty or contain only whitespace", - "", "", "", "", "", "", - session - ) - - if len(message.strip()) < 10: - return ( - "⚠️ **Warning:** Message is very short. Please provide more context for accurate analysis.", - "", "", "", "", "", "", - session - ) - - try: - # Create PatientInput - patient_input = PatientInput( - message=message, - timestamp=datetime.now().isoformat() - ) - - # Analyze message - classification = session.analyzer.analyze_message(patient_input) - - # Store in session - session.current_patient_input = patient_input - session.current_classification = classification - - # Generate color-coded classification badge (Requirement 10.2) - flag_color = { - "red": "🔴", - "yellow": "🟡", - "none": "🟢" - }.get(classification.flag_level, "⚪") - - classification_md = f""" -### {flag_color} Classification: {classification.flag_level.upper()} FLAG - -**Confidence:** {classification.confidence:.2%} -**Categories:** {', '.join(classification.categories) if classification.categories else 'None'} -**Timestamp:** {classification.timestamp[:19]} - """ - - # Display indicators (Requirement 5.4) - if classification.indicators: - indicators_md = "### 🎯 Detected Indicators\n\n" - for indicator in classification.indicators: - indicators_md += f"- {indicator}\n" - else: - indicators_md = "### 🎯 Detected Indicators\n\nNo specific indicators detected" - - # Display reasoning (Requirement 5.4) - reasoning_md = f""" -### 🧠 Analysis Reasoning - -{classification.reasoning} - """ - - # Generate referral message for red flags (Requirement 5.3) - referral_md = "" - if classification.flag_level == "red": - referral = session.referral_generator.generate_referral( - classification, - patient_input - ) - session.current_referral = referral - - referral_md = f""" -### 📨 Generated Referral Message - -**Patient Concerns:** {referral.patient_concerns} - -**Message to Spiritual Care Team:** - -{referral.message_text} - -**Context:** {referral.context} - """ - else: - session.current_referral = None - referral_md = "### 📨 Referral Message\n\nNo referral generated (not a red flag)" - - # Generate clarifying questions for yellow flags - questions_md = "" - if classification.flag_level == "yellow": - questions = session.question_generator.generate_questions( - classification, - patient_input - ) - session.current_questions = questions - - questions_md = "### ❓ Clarifying Questions\n\n" - questions_md += "Consider asking the patient:\n\n" - for i, question in enumerate(questions, 1): - questions_md += f"{i}. {question}\n" - else: - session.current_questions = [] - questions_md = "" - - # Update status - status = f"✅ Analysis complete - {classification.flag_level.upper()} FLAG detected" - - # Add to session history - session.assessment_history.append({ - "timestamp": datetime.now().isoformat(), - "message": message[:100], - "flag_level": classification.flag_level, - "indicators": classification.indicators, - "confidence": classification.confidence - }) - - return ( - status, - classification_md, - indicators_md, - reasoning_md, - referral_md, - questions_md, - "", # Clear feedback result - session - ) - - except RuntimeError as e: - # LLM API errors with user-friendly messages (Requirement 10.5) - logging.error(f"LLM API error: {e}") - error_msg = str(e).lower() - - if "timeout" in error_msg: - error_status = """ -❌ **Connection Timeout** - -The AI service is taking longer than expected to respond. This could be due to: -- High server load -- Network connectivity issues - -**What to do:** -- Wait a moment and try again -- Check your internet connection -- If the problem persists, contact support - """ - elif "rate" in error_msg or "quota" in error_msg: - error_status = """ -❌ **Service Limit Reached** - -The AI service has reached its usage limit. This is temporary. - -**What to do:** -- Wait a few minutes and try again -- If urgent, contact your system administrator - """ - elif "connection" in error_msg: - error_status = """ -❌ **Connection Error** - -Unable to connect to the AI service. - -**What to do:** -- Check your internet connection -- Verify the service is running -- Try again in a moment -- Contact support if the issue persists - """ - else: - error_status = f""" -❌ **Service Error** - -An error occurred while processing your request: -{str(e)[:200]} - -**What to do:** -- Try submitting your message again -- If the problem continues, contact support - """ - - return ( - error_status, - "", "", "", "", "", "", - session - ) - - except json.JSONDecodeError as e: - # JSON parsing errors (Requirement 10.5) - logging.error(f"JSON parsing error: {e}") - error_status = """ -❌ **Data Processing Error** - -The AI service returned data in an unexpected format. - -**What to do:** -- Try your request again -- If this happens repeatedly, contact support with the timestamp - """ - return ( - error_status, - "", "", "", "", "", "", - session - ) - - except Exception as e: - # Catch-all with user-friendly message (Requirement 10.5) - logging.error(f"Unexpected error analyzing message: {e}", exc_info=True) - error_status = f""" -❌ **Unexpected Error** - -An unexpected error occurred during analysis. - -**Error details:** {str(e)[:200]} - -**What to do:** -- Try again -- If the problem persists, contact support with this error message -- Note the time: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} - """ - return ( - error_status, - "", "", "", "", "", "", - session - ) - - def handle_clear(session: SessionData) -> Tuple: - """Clear current assessment""" - if session is None: - session = SessionData() - - session.update_activity() - - # Clear current assessment - session.current_patient_input = None - session.current_classification = None - session.current_referral = None - session.current_questions = [] - - return ( - "", # patient_message - "Ready to analyze", # status - "", "", "", "", "", "", # displays - session - ) - - def handle_submit_feedback( - provider_id_val: str, - agrees_class: bool, - agrees_ref: bool, - comments: str, - session: SessionData - ) -> Tuple: - """ - Submit provider feedback on assessment. - - Requirements: 6.1, 6.2, 6.3, 6.4, 6.5, 6.6 - """ - if session is None: - return "❌ No active session", session - - session.update_activity() - - if session.current_classification is None: - return "❌ No assessment to provide feedback on", session - - try: - # Create ProviderFeedback object - feedback = ProviderFeedback( - assessment_id="", # Will be set by feedback_store - provider_id=provider_id_val or "provider_001", - agrees_with_classification=agrees_class, - agrees_with_referral=agrees_ref, - comments=comments - ) - - # Save feedback (Requirements 6.1-6.6) - assessment_id = session.feedback_store.save_feedback( - patient_input=session.current_patient_input, - classification=session.current_classification, - referral_message=session.current_referral, - provider_feedback=feedback - ) - - session.current_assessment_id = assessment_id - - result_md = f""" -✅ **Feedback Submitted Successfully** - -**Assessment ID:** `{assessment_id[:8]}...` -**Provider:** {provider_id_val or 'provider_001'} -**Classification Agreement:** {'✅ Yes' if agrees_class else '❌ No'} -**Referral Agreement:** {'✅ Yes' if agrees_ref else '❌ No'} -**Timestamp:** {datetime.now().isoformat()[:19]} - -Your feedback helps improve the system. Thank you! - """ - - return result_md, session - - except Exception as e: - logging.error(f"Error submitting feedback: {e}") - return f"❌ Error submitting feedback: {str(e)}", session - - def handle_refresh_history(session: SessionData) -> Tuple: - """ - Refresh assessment history and statistics. - - Requirements: 8.1, 8.2, 8.3, 8.5 - """ - if session is None: - session = SessionData() - - session.update_activity() - - try: - # Get all feedback records - all_feedback = session.feedback_store.get_all_feedback() - - # Build table data - table_data = [] - for record in all_feedback: - classification = record.get('classification', {}) - provider_feedback = record.get('provider_feedback', {}) - - table_data.append([ - record.get('timestamp', '')[:19], - classification.get('flag_level', ''), - ', '.join(classification.get('indicators', [])[:3]), - classification.get('confidence', 0.0), - '✅' if provider_feedback.get('agrees_with_classification') else '❌', - provider_feedback.get('comments', '')[:50] - ]) - - # Get summary statistics - metrics = session.feedback_store.get_accuracy_metrics() - summary_stats = session.feedback_store.get_summary_statistics() - - summary_md = f""" -### 📊 Overall Statistics - -**Total Assessments:** {metrics['total_assessments']} -**Classification Agreement Rate:** {metrics['classification_agreement_rate']:.1%} -**Referral Agreement Rate:** {metrics['referral_agreement_rate']:.1%} - -### 🎯 Accuracy by Flag Level - -- **Red Flag Accuracy:** {metrics['red_flag_accuracy']:.1%} -- **Yellow Flag Accuracy:** {metrics['yellow_flag_accuracy']:.1%} -- **No Flag Accuracy:** {metrics['no_flag_accuracy']:.1%} - -### 📈 Flag Distribution - -- **Red Flags:** {metrics.get('flag_distribution', {}).get('red', 0)} -- **Yellow Flags:** {metrics.get('flag_distribution', {}).get('yellow', 0)} -- **No Flags:** {metrics.get('flag_distribution', {}).get('none', 0)} - -### 🔍 Most Common Indicators - -{chr(10).join(f"- {indicator}: {count}" for indicator, count in summary_stats.get('most_common_indicators', [])[:5])} - -**Average Confidence:** {summary_stats.get('average_confidence', 0.0):.1%} - """ - - return table_data, summary_md, session - - except Exception as e: - logging.error(f"Error refreshing history: {e}") - return [], f"❌ Error loading history: {str(e)}", session - - def handle_export_csv(session: SessionData) -> Tuple: - """Export feedback data to CSV""" - if session is None: - session = SessionData() - - session.update_activity() - - try: - csv_path = session.feedback_store.export_to_csv() - - if csv_path: - result_md = f""" -✅ **Export Successful** - -**File:** `{csv_path}` -**Records Exported:** {len(session.feedback_store.get_all_feedback())} -**Timestamp:** {datetime.now().isoformat()[:19]} - -The CSV file contains all assessment records with provider feedback. - """ - else: - result_md = "⚠️ No records to export" - - return result_md, session - - except Exception as e: - logging.error(f"Error exporting CSV: {e}") - return f"❌ Error exporting: {str(e)}", session - - def load_example(example_type: str, session: SessionData) -> Tuple: - """Load example patient message""" - if session is None: - session = SessionData() - - examples = { - "red": "I am angry all the time and I can't stop crying. Nothing makes sense anymore and I feel completely hopeless.", - "yellow": "I've been feeling frustrated lately and things are bothering me more than usual. I'm not sure what's going on.", - "none": "I'm doing well today. The treatment is going smoothly and I'm feeling optimistic about my recovery." - } - - message = examples.get(example_type, "") - return message, session - - # Event binding with session isolation - - demo.load( - initialize_session, - outputs=[session_data, session_info] - ) - - # Analysis events - analyze_btn.click( - handle_analyze, - inputs=[patient_message, session_data], - outputs=[ - status_display, - classification_display, - indicators_display, - reasoning_display, - referral_display, - questions_display, - feedback_result, - session_data - ] - ) - - clear_btn.click( - handle_clear, - inputs=[session_data], - outputs=[ - patient_message, - status_display, - classification_display, - indicators_display, - reasoning_display, - referral_display, - questions_display, - feedback_result, - session_data - ] - ) - - # Example buttons - example_red_btn.click( - lambda session: load_example("red", session), - inputs=[session_data], - outputs=[patient_message, session_data] - ) - - example_yellow_btn.click( - lambda session: load_example("yellow", session), - inputs=[session_data], - outputs=[patient_message, session_data] - ) - - example_none_btn.click( - lambda session: load_example("none", session), - inputs=[session_data], - outputs=[patient_message, session_data] - ) - - # Feedback events - submit_feedback_btn.click( - handle_submit_feedback, - inputs=[ - provider_id, - agrees_classification, - agrees_referral, - feedback_comments, - session_data - ], - outputs=[feedback_result, session_data] - ) - - # History events - refresh_history_btn.click( - handle_refresh_history, - inputs=[session_data], - outputs=[history_table, summary_display, session_data] - ) - - export_csv_btn.click( - handle_export_csv, - inputs=[session_data], - outputs=[export_result, session_data] - ) - - return demo - - -# Create alias for consistency -create_gradio_interface = create_spiritual_interface - - -# Usage -if __name__ == "__main__": - demo = create_spiritual_interface() - demo.launch() diff --git a/src/prompts/__init__.py b/src/prompts/__init__.py deleted file mode 100644 index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000 diff --git a/src/prompts/assembler.py b/src/prompts/assembler.py deleted file mode 100644 index 0d70ec13f884c83aac55f27929b5602782b3d04a..0000000000000000000000000000000000000000 --- a/src/prompts/assembler.py +++ /dev/null @@ -1,756 +0,0 @@ -# template_assembler.py - Dynamic Prompt Assembly Engine -""" -Strategic Architecture: Intelligent composition with embedded medical safety validation - -Core Design Philosophy: "Deterministic assembly with uncompromising safety protocols" -- Clear assembly logic that medical professionals can audit and understand -- Multi-layer safety validation that cannot be bypassed by any component interaction -- Human-readable output for transparency and professional review -- Graceful degradation when components are missing or incompatible -""" - -from typing import List, Dict, Optional, Any, Tuple -from datetime import datetime -from dataclasses import dataclass -import time -import re -import json -import hashlib -from collections import OrderedDict -import logging -import os - -from src.prompts.types import ( - PromptComponent, PromptCompositionSpec, AssemblyResult, SafetyLevel, - MedicalSafetyViolationError, DynamicPromptConfig -) -from src.prompts.components import MedicalComponentLibrary -from src.core.ai_client import AIClientManager - -class MedicalSafetyValidator: - """LLM-driven safety validator that replaces rigid keyword checks.""" - - def __init__(self, api_client: Optional[AIClientManager] = None, max_cache_entries: int = 32): - self.api_client = api_client - self._cache: "OrderedDict[str, MedicalSafetyValidator._SafetyReport]" = OrderedDict() - self._max_cache_entries = max_cache_entries - self.logger = logging.getLogger("dynamic_prompts") - if os.getenv("LOG_PROMPTS", "false").lower() == "true" and not self.logger.handlers: - self.logger.setLevel(logging.INFO) - handler = logging.FileHandler("dynamic_prompts.log", encoding="utf-8") - handler.setFormatter(logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')) - self.logger.addHandler(handler) - - @dataclass - class _SafetyReport: - is_safe: bool - violations: List[str] - warnings: List[str] - - def validate_assembled_prompt( - self, - prompt: str, - components_used: List[str], - clinical_background: Dict[str, Any], - classification_spec: PromptCompositionSpec, - ) -> Tuple[bool, List[str], List[str]]: - """Use LLM safety reviewer when available, otherwise fallback to heuristic check.""" - - if self.api_client is None: - report = self._heuristic_fallback(prompt, components_used, clinical_background, classification_spec) - self.logger.info("[DYNAMIC_PROMPT] safety_heuristic components=%s", components_used) - return report.is_safe, report.violations, report.warnings - - report = self._get_safety_report(prompt, components_used, clinical_background, classification_spec) - return report.is_safe, report.violations, report.warnings - - def _get_safety_report( - self, - prompt: str, - components_used: List[str], - clinical_background: Dict[str, Any], - classification_spec: PromptCompositionSpec, - ) -> _SafetyReport: - cache_key = self._build_cache_key(prompt, components_used, clinical_background, classification_spec) - cached = self._cache.get(cache_key) - if cached: - self._cache.move_to_end(cache_key) - self.logger.info("[DYNAMIC_PROMPT] cache_hit components=%s", components_used) - return cached - - if self.api_client is not None: - try: - report = self._run_llm_review(prompt, components_used, clinical_background, classification_spec) - except Exception as exc: - print(f"⚠️ LLM safety review failed: {exc} — using heuristic fallback") - report = self._heuristic_fallback(prompt, components_used, clinical_background, classification_spec) - self.logger.info( - "[DYNAMIC_PROMPT] safety_fallback_due_to_error components=%s error=%s", - components_used, - exc, - ) - else: - report = self._heuristic_fallback(prompt, components_used, clinical_background, classification_spec) - self.logger.info("[DYNAMIC_PROMPT] safety_heuristic components=%s", components_used) - - self._cache[cache_key] = report - if len(self._cache) > self._max_cache_entries: - self._cache.popitem(last=False) - return report - - def _run_llm_review( - self, - prompt: str, - components_used: List[str], - clinical_background: Dict[str, Any], - classification_spec: PromptCompositionSpec, - ) -> _SafetyReport: - system_prompt = """You are a medical safety reviewer. Analyse the provided lifestyle-coaching system prompt and ensure it: -1. Contains explicit medical safety guidance (escalation, stopping criteria, consulting professionals). -2. Covers the critical alerts and conditions in the clinical profile. -3. Mentions relevant medication precautions based on the provided drug classes. -4. Uses language that matches the patient (Ukrainian). - -Respond ONLY in JSON with fields: is_safe (true/false), violations (list of strings), warnings (list of strings).""" - - user_payload = { - "assembled_prompt": prompt, - "components_used": components_used, - "clinical_background": clinical_background, - "safety_level": classification_spec.safety_level.value, - } - - reviewer = self.api_client.generate_response - response = reviewer( - system_prompt=system_prompt, - user_prompt=json.dumps(user_payload, ensure_ascii=False, indent=2), - call_type="DYNAMIC_PROMPT_SAFETY", - agent_name="MainLifestyleAssistant" - ) - - report = self._parse_json_response(response) - return self._SafetyReport( - is_safe=bool(report.get("is_safe", False)), - violations=list(report.get("violations", [])), - warnings=list(report.get("warnings", [])), - ) - - def _parse_json_response(self, text: str) -> Dict[str, Any]: - text = text.strip() - try: - return json.loads(text) - except Exception: - # try extract json object heuristically - start = text.find('{') - end = text.rfind('}') - if start != -1 and end != -1 and end > start: - try: - return json.loads(text[start:end + 1]) - except Exception: - pass - return {"is_safe": False, "violations": ["Unable to parse LLM safety response"], "warnings": []} - - def _heuristic_fallback( - self, - prompt: str, - components_used: List[str], - clinical_background: Optional[Dict[str, Any]] = None, - classification_spec: Optional[PromptCompositionSpec] = None, - ) -> _SafetyReport: - safety_violations: List[str] = [] - safety_warnings: List[str] = [] - prompt_lower = prompt.lower() - - mandatory_elements = [ - "медичн", - "консульт", - "припин", - "екстр", - "монітор", - ] - missing_mandatory = [elem for elem in mandatory_elements if elem not in prompt_lower] - if missing_mandatory: - safety_violations.extend([ - f"Missing mandatory safety fragment: {elem}" - for elem in missing_mandatory - ]) - - condition_requirements = { - 'diabetes': ["моніторинг глюкози", "швидкі вуглеводи", "координація"], - 'hypertension': ["артеріального тиску", "ізометр", "поступове"], - 'cardiovascular': ["кардіолог", "чсс", "припинен"], - 'arthritis': ["загострен", "розминк", "навантаження"] - } - - active_conditions = [ - cond.lower() for cond in (clinical_background or {}).get('active_problems', []) - ] - for condition, requirements in condition_requirements.items(): - condition_component = f"{condition}_management" - if any(condition in ac for ac in active_conditions): - if condition_component not in components_used and ( - not classification_spec or condition not in (classification_spec.medical_emphasis or []) - ): - # Skip strict enforcement if condition-specific component wasn't part of assembly intent - continue - missing = [req for req in requirements if req not in prompt_lower] - if missing: - safety_violations.extend([ - f"Missing {condition} safety detail: {req}" - for req in missing - ]) - - safety_component_names = { - "base_medical_safety", - "emergency_protocols", - "diabetes_management", - "hypertension_management", - "cardiovascular_conditions", - "arthritis_management", - } - if not any(name in components_used for name in safety_component_names): - safety_violations.append("No medical safety component included") - - medications = [ - med.lower() for med in (clinical_background or {}).get('current_medications', []) - ] - medication_warnings = { - 'anticoagulant': "bleeding risk guidance missing", - 'insulin': "glucose monitoring guidance missing", - 'beta_blocker': "heart rate monitoring guidance missing", - 'diuretic': "hydration guidance missing" - } - for med_key, warning in medication_warnings.items(): - if any(med_key in med for med in medications) and med_key not in prompt_lower: - safety_warnings.append(warning) - - if (clinical_background or {}).get('critical_alerts') and classification_spec and classification_spec.safety_level != SafetyLevel.MAXIMUM: - safety_warnings.append( - f"Critical alerts present but safety level is {classification_spec.safety_level.value}" - ) - - return self._SafetyReport( - is_safe=len(safety_violations) == 0, - violations=safety_violations, - warnings=safety_warnings, - ) - - def _build_cache_key( - self, - prompt: str, - components_used: List[str], - clinical_background: Dict[str, Any], - classification_spec: PromptCompositionSpec, - ) -> str: - payload = { - "prompt_hash": hashlib.sha256(prompt.encode('utf-8')).hexdigest(), - "components": sorted(components_used), - "critical_alerts": sorted(clinical_background.get('critical_alerts', [])), - "active_problems": sorted(clinical_background.get('active_problems', [])), - "safety_level": classification_spec.safety_level.value if classification_spec else None, - "session_focus": classification_spec.session_focus if classification_spec else None, - "medical_emphasis": sorted(classification_spec.medical_emphasis or []) if classification_spec else [], - } - return json.dumps(payload, sort_keys=True) - -class PromptTemplateEngine: - """ - Foundation template management for consistent prompt structure - - Design Strategy: Template-based composition ensures medical completeness - - Standardized medical prompt structure for professional review - - Variable interpolation with safety-first data validation - - Consistent format enables automated safety checking - - Human-readable templates facilitate medical professional oversight - """ - - def __init__(self): - self.base_template = self._load_foundation_template() - self.template_variables = self._extract_template_variables() - - def _load_foundation_template(self) -> str: - """Load the foundational medical prompt template""" - return """Ви є експерт з медичного lifestyle коучингу для пацієнта {patient_name}. - -МЕДИЧНИЙ КОНТЕКСТ ПАЦІЄНТА: -• Активні захворювання: {active_problems} -• Поточні медикаменти: {current_medications} -• Критичні медичні попередження: {critical_alerts} - -ПОТОЧНИЙ ПРОГРЕС ТА JOURNEY: -{journey_summary} - -{dynamic_medical_components} - -{communication_style_guidance} - -{progress_motivation_components} - -ФОРМАТ ВІДПОВІДІ: -Надавайте відповіді ВИКЛЮЧНО у JSON форматі: -{{ - "message": "детальна відповідь пацієнту з урахуванням медичного контексту", - "action": "gather_info|lifestyle_dialog|close", - "reasoning": "пояснення вибору дії та медичних міркувань" -}} - -КРИТИЧНО ВАЖЛИВО: -• Медична безпека має АБСОЛЮТНИЙ пріоритет над будь-якими іншими рекомендаціями -• При будь-яких сумнівах щодо безпеки - завжди рекомендуйте консультацію з медичним фахівцем -• Всі рекомендації мають бути персоналізованими з урахуванням медичних обмежень пацієнта""" - - def _extract_template_variables(self) -> List[str]: - """Extract variable names from template for validation""" - return re.findall(r'\{(\w+)\}', self.base_template) - - def interpolate_template(self, - clinical_background: Dict[str, Any], - lifestyle_profile: Dict[str, Any], - dynamic_sections: Dict[str, str]) -> str: - """ - Safe template interpolation with comprehensive validation - - Strategy: Structured data insertion with medical safety preservation - """ - - # Prepare safe interpolation data - interpolation_data = { - 'patient_name': self._safe_format_patient_name(clinical_background), - 'active_problems': self._safe_format_list(clinical_background.get('active_problems', [])), - 'current_medications': self._safe_format_list(clinical_background.get('current_medications', [])), - 'critical_alerts': self._safe_format_alerts(clinical_background.get('critical_alerts', [])), - 'journey_summary': self._safe_format_journey(lifestyle_profile.get('journey_summary', '')), - 'dynamic_medical_components': dynamic_sections.get('medical_components', ''), - 'communication_style_guidance': dynamic_sections.get('communication_style', ''), - 'progress_motivation_components': dynamic_sections.get('progress_motivation', '') - } - - # Validate all required variables are present - missing_vars = [var for var in self.template_variables if var not in interpolation_data] - if missing_vars: - raise MedicalSafetyViolationError( - f"Missing required template variables: {missing_vars}" - ) - - # Perform safe interpolation - try: - return self.base_template.format(**interpolation_data) - except KeyError as e: - raise MedicalSafetyViolationError(f"Template interpolation failed: {e}") - - def _safe_format_patient_name(self, clinical_background: Dict[str, Any]) -> str: - """Safely format patient name with privacy protection""" - name = clinical_background.get('patient_name', 'Пацієнт') - # Remove any potentially sensitive information - return name.split()[0] if name and len(name.split()) > 0 else 'Пацієнт' - - def _safe_format_list(self, items: List[str]) -> str: - """Format list items for template insertion""" - if not items: - return "немає" - # Limit to reasonable number of items for readability - limited_items = items[:5] - formatted = ", ".join(limited_items) - if len(items) > 5: - formatted += f" (та ще {len(items) - 5} інших)" - return formatted - - def _safe_format_alerts(self, alerts: List[str]) -> str: - """Format critical alerts with appropriate emphasis""" - if not alerts: - return "немає критичних попереджень" - - formatted_alerts = [] - for alert in alerts[:3]: # Limit to most critical - formatted_alerts.append(f"⚠️ {alert}") - - return "; ".join(formatted_alerts) - - def _safe_format_journey(self, journey_summary: str) -> str: - """Safely format lifestyle journey summary""" - if not journey_summary or journey_summary.strip() == "": - return "Пацієнт розпочинає свою lifestyle journey. Попередній досвід та прогрес будуть відстежуватися." - - # Limit length for prompt efficiency - if len(journey_summary) > 800: - return journey_summary[:800] + "..." - - return journey_summary - -class DynamicTemplateAssembler: - """ - Strategic prompt assembly engine with intelligent component composition - - Core Architecture: Safety-first assembly with transparent audit trail - - Component selection based on LLM classification results - - Intelligent ordering and conflict resolution - - Comprehensive medical safety validation at every step - - Performance optimization through efficient assembly algorithms - - Design Principles: - - Medical safety cannot be compromised by any assembly logic - - Assembly process is fully auditable and transparent - - Graceful degradation when components are missing or incompatible - - Performance monitoring for continuous optimization - """ - - def __init__(self, api_client: Optional[AIClientManager] = None): - self.component_library = MedicalComponentLibrary() - self.template_engine = PromptTemplateEngine() - self.safety_validator = MedicalSafetyValidator(api_client) - self.assembly_metrics = { - 'total_assemblies': 0, - 'safety_validations_passed': 0, - 'safety_validations_failed': 0, - 'component_conflicts_resolved': 0, - 'fallback_assemblies': 0 - } - - def assemble_personalized_prompt(self, - classification_spec: PromptCompositionSpec, - clinical_background: Dict[str, Any], - lifestyle_profile: Dict[str, Any]) -> AssemblyResult: - """ - Main assembly orchestration with comprehensive safety validation - - Assembly Process: - 1. Component selection based on classification specification - 2. Component compatibility and conflict resolution - 3. Intelligent component ordering by priority and medical importance - 4. Dynamic section assembly with category-based organization - 5. Template interpolation with safety-validated data - 6. Comprehensive medical safety validation - 7. Performance tracking and audit trail generation - """ - - start_time = time.time() - self.assembly_metrics['total_assemblies'] += 1 - assembly_notes = [] - - logger = logging.getLogger("dynamic_prompts") - - try: - # Step 1: Component selection based on classification - selected_components = self._select_and_validate_components( - classification_spec, assembly_notes - ) - - # Step 2: Component compatibility analysis and conflict resolution - compatible_components = self._resolve_component_conflicts( - selected_components, assembly_notes - ) - - # Step 3: Intelligent component ordering - ordered_components = self._order_components_by_priority(compatible_components) - - # Step 4: Dynamic section assembly - dynamic_sections = self._assemble_dynamic_sections(ordered_components) - - # Step 5: Template interpolation - assembled_prompt = self.template_engine.interpolate_template( - clinical_background, lifestyle_profile, dynamic_sections - ) - - # Step 6: Comprehensive safety validation - components_used = [comp.name for comp in ordered_components] - is_safe, violations, warnings = self.safety_validator.validate_assembled_prompt( - assembled_prompt, components_used, clinical_background, classification_spec - ) - - if not is_safe: - logger.info( - "[DYNAMIC_PROMPT] safety_failed components=%s violations=%s warnings=%s", - components_used, - violations, - warnings, - ) - print(f"⚠️ Dynamic safety check failed. Components: {components_used}. Violations: {violations}") - self.assembly_metrics['safety_validations_failed'] += 1 - # Attempt safety correction - corrected_result = self._attempt_safety_correction( - assembled_prompt, violations, clinical_background, - lifestyle_profile, classification_spec - ) - if corrected_result: - return corrected_result - else: - raise MedicalSafetyViolationError( - f"Assembly failed safety validation: {'; '.join(violations)}" - ) - - self.assembly_metrics['safety_validations_passed'] += 1 - logger.info("[DYNAMIC_PROMPT] components=%s", components_used) - - # Step 7: Performance tracking and result compilation - assembly_time = (time.time() - start_time) * 1000 - - if warnings: - assembly_notes.extend([f"Safety warning: {w}" for w in warnings]) - - assembly_notes.append(f"Assembly completed in {assembly_time:.0f}ms") - - return AssemblyResult( - assembled_prompt=assembled_prompt, - components_used=components_used, - safety_validated=True, - assembly_notes=assembly_notes, - performance_metrics={ - 'assembly_time_ms': assembly_time, - 'component_count': len(ordered_components), - 'safety_checks_performed': 1, - 'medical_components': len([c for c in ordered_components if c.medical_safety]) - }, - medical_review_required=classification_spec.safety_level == SafetyLevel.MAXIMUM - ) - - except Exception as e: - # Graceful fallback to safe assembly - self.assembly_metrics['fallback_assemblies'] += 1 - return self._generate_fallback_assembly( - clinical_background, lifestyle_profile, str(e) - ) - - def _select_and_validate_components(self, - classification_spec: PromptCompositionSpec, - assembly_notes: List[str]) -> List[PromptComponent]: - """Select components based on classification with validation""" - - # Get components from library based on classification - selected_components = self.component_library.get_components_for_classification( - classification_spec - ) - - if not selected_components: - assembly_notes.append("⚠️ No components selected by classification - using safety defaults") - # Force inclusion of base safety components - base_safety = self.component_library.get_component("base_medical_safety") - emergency_protocols = self.component_library.get_component("emergency_protocols") - selected_components = [comp for comp in [base_safety, emergency_protocols] if comp] - - # Validate safety component inclusion - if not self.component_library.validate_component_safety(selected_components): - assembly_notes.append("⚠️ Insufficient safety components - adding mandatory safety") - # Force add base medical safety - base_safety = self.component_library.get_component("base_medical_safety") - if base_safety and base_safety not in selected_components: - selected_components.insert(0, base_safety) - - assembly_notes.append(f"Selected {len(selected_components)} components for assembly") - return selected_components - - def _resolve_component_conflicts(self, - components: List[PromptComponent], - assembly_notes: List[str]) -> List[PromptComponent]: - """Resolve potential conflicts between components""" - - # Group components by category for conflict analysis - category_groups = {} - for comp in components: - if comp.category not in category_groups: - category_groups[comp.category] = [] - category_groups[comp.category].append(comp) - - resolved_components = [] - conflicts_found = 0 - - for category, category_components in category_groups.items(): - if len(category_components) > 1: - # Multiple components in same category - select highest priority - if category.value == 'communication_style': - # Only one communication style should be active - highest_priority = max(category_components, key=lambda x: x.priority) - resolved_components.append(highest_priority) - conflicts_found += 1 - assembly_notes.append( - f"Resolved {category.value} conflict: selected {highest_priority.name}" - ) - else: - # For other categories, include all non-conflicting components - resolved_components.extend(category_components) - else: - resolved_components.extend(category_components) - - self.assembly_metrics['component_conflicts_resolved'] += conflicts_found - return resolved_components - - def _order_components_by_priority(self, components: List[PromptComponent]) -> List[PromptComponent]: - """Order components by priority with medical safety first""" - - # Separate medical safety components for priority handling - safety_components = [comp for comp in components if comp.medical_safety] - other_components = [comp for comp in components if not comp.medical_safety] - - # Sort each group by priority - safety_components.sort(key=lambda x: x.priority, reverse=True) - other_components.sort(key=lambda x: x.priority, reverse=True) - - # Medical safety components always come first - return safety_components + other_components - - def _assemble_dynamic_sections(self, components: List[PromptComponent]) -> Dict[str, str]: - """Assemble components into organized dynamic sections""" - - sections = { - 'medical_components': '', - 'communication_style': '', - 'progress_motivation': '' - } - - # Group components by their target section - for component in components: - if component.category.value in ['medical_safety', 'condition_specific']: - if sections['medical_components']: - sections['medical_components'] += '\n\n' - sections['medical_components'] += component.content - - elif component.category.value == 'communication_style': - sections['communication_style'] = component.content - - elif component.category.value == 'progress_motivation': - if sections['progress_motivation']: - sections['progress_motivation'] += '\n\n' - sections['progress_motivation'] += component.content - - # Ensure medical components section is never empty - if not sections['medical_components']: - sections['medical_components'] = """ -ОСНОВНІ ПРИНЦИПИ МЕДИЧНОЇ БЕЗПЕКИ: -• Консультуйтеся з лікарем перед початком будь-якої нової активності -• Припиняйте активність при появі незвичайних симптомів -• Поступово збільшуйте інтенсивність та тривалість навантажень -""" - - return sections - - def _attempt_safety_correction(self, - original_prompt: str, - violations: List[str], - clinical_background: Dict[str, Any], - lifestyle_profile: Dict[str, Any], - classification_spec: PromptCompositionSpec) -> Optional[AssemblyResult]: - """Attempt to correct safety violations through component addition""" - - try: - # Force add all available safety components - safety_components = self.component_library.get_safety_components() - - # Re-assemble with all safety components - dynamic_sections = self._assemble_dynamic_sections(safety_components) - - corrected_prompt = self.template_engine.interpolate_template( - clinical_background, lifestyle_profile, dynamic_sections - ) - - # Re-validate safety - components_used = [comp.name for comp in safety_components] - is_safe, new_violations, warnings = self.safety_validator.validate_assembled_prompt( - corrected_prompt, components_used, clinical_background, classification_spec - ) - - if is_safe: - return AssemblyResult( - assembled_prompt=corrected_prompt, - components_used=components_used, - safety_validated=True, - assembly_notes=[ - "Original assembly failed safety validation", - "Corrected by adding all safety components", - f"Original violations: {'; '.join(violations)}" - ], - medical_review_required=True - ) - - except Exception as e: - # Safety correction failed - pass - - return None - - def _generate_fallback_assembly(self, - clinical_background: Dict[str, Any], - lifestyle_profile: Dict[str, Any], - error_reason: str) -> AssemblyResult: - """Generate safe fallback assembly when normal assembly fails""" - - # Use base medical safety component only - base_safety = self.component_library.get_component("base_medical_safety") - emergency_protocols = self.component_library.get_component("emergency_protocols") - - fallback_components = [comp for comp in [base_safety, emergency_protocols] if comp] - - if not fallback_components: - # Ultimate fallback with hardcoded safety - fallback_prompt = """Ви є медичний lifestyle коуч. - -КРИТИЧНО ВАЖЛИВО: -• Завжди консультуйтеся з лікарем перед початком нової активності -• Негайно припиняйте активність при появі симптомів -• Поступово збільшуйте навантаження -• При сумнівах - обов'язкова медична консультація - -Надавайте відповіді у JSON форматі: -{"message": "відповідь", "action": "close", "reasoning": "fallback safety mode"}""" - - return AssemblyResult( - assembled_prompt=fallback_prompt, - components_used=["hardcoded_safety_fallback"], - safety_validated=True, - assembly_notes=[ - f"Assembly failed: {error_reason}", - "Using hardcoded safety fallback", - "Medical review required" - ], - medical_review_required=True - ) - - # Assemble with available fallback components - dynamic_sections = self._assemble_dynamic_sections(fallback_components) - - fallback_prompt = self.template_engine.interpolate_template( - clinical_background, lifestyle_profile, dynamic_sections - ) - - return AssemblyResult( - assembled_prompt=fallback_prompt, - components_used=[comp.name for comp in fallback_components], - safety_validated=True, - assembly_notes=[ - f"Normal assembly failed: {error_reason}", - "Using safety component fallback", - "Reduced functionality but medical safety preserved" - ], - medical_review_required=True - ) - - def get_assembly_metrics(self) -> Dict[str, Any]: - """Get comprehensive assembly performance metrics""" - metrics = self.assembly_metrics.copy() - - # Calculate derived metrics - total_assemblies = metrics['total_assemblies'] - if total_assemblies > 0: - metrics['safety_validation_success_rate'] = ( - metrics['safety_validations_passed'] / total_assemblies * 100 - ) - metrics['fallback_rate'] = ( - metrics['fallback_assemblies'] / total_assemblies * 100 - ) - - return metrics - - def reset_assembly_metrics(self): - """Reset assembly metrics for new monitoring period""" - self.assembly_metrics = {key: 0 for key in self.assembly_metrics.keys()} - -# === CONVENIENCE FACTORY FUNCTION === - -def create_template_assembler() -> DynamicTemplateAssembler: - """ - Factory function for creating properly configured template assembler - - Strategic Design: Centralized configuration and initialization - - Ensures consistent configuration across application - - Simplifies dependency injection and testing - - Provides clear entry point for assembler creation - """ - return DynamicTemplateAssembler() diff --git a/src/prompts/classifier.py b/src/prompts/classifier.py deleted file mode 100644 index 398395dbfd85c4562bb7120647949841ccc0e965..0000000000000000000000000000000000000000 --- a/src/prompts/classifier.py +++ /dev/null @@ -1,530 +0,0 @@ -# prompt_classifier.py - LLM-Based Intelligent Classification System -""" -Strategic Architecture: Context-aware prompt personalization engine - -Core Design Philosophy: "Intelligent analysis with deterministic safety protocols" -- Single responsibility: Transform contextual information into actionable composition specifications -- Reliability first: Multiple fallback layers ensure system never fails unsafe -- Performance optimization: Intelligent caching reduces LLM API dependency -- Medical safety: Embedded safety protocols that cannot be bypassed by classification logic -""" - -from typing import Dict, List, Optional, Any, Tuple -import json -import hashlib -import time -from datetime import datetime, timedelta -import asyncio - -from src.prompts.types import ( - ClassificationContext, PromptCompositionSpec, SafetyLevel, - DynamicPromptConfig, MedicalSafetyViolationError -) -from src.core.ai_client import AIClientManager - -class ClassificationCache: - """ - Strategic caching system for LLM classification results - - Design Philosophy: "Intelligent caching reduces costs while maintaining responsiveness" - - Context-aware cache keys that identify similar patient scenarios - - Configurable TTL to balance freshness with performance - - Memory-efficient storage with automatic cleanup - - Cache hit rate optimization through smart key generation - """ - - def __init__(self, max_size: int = None, ttl_hours: int = None): - self.cache: Dict[str, Dict[str, Any]] = {} - self.max_size = max_size or DynamicPromptConfig.MAX_CACHE_SIZE - self.ttl_hours = ttl_hours or DynamicPromptConfig.CACHE_TTL_HOURS - self.hits = 0 - self.misses = 0 - - def _generate_context_signature(self, context: ClassificationContext) -> str: - """ - Generate deterministic cache key from classification context - - Strategy: Create stable signature that captures semantic similarity - - Patient request semantic content - - Medical conditions and medications - - Lifestyle progression indicators - - Session metadata relevance - """ - # Extract semantic elements for cache key - semantic_elements = { - 'request_intent': self._extract_request_intent(context.patient_request), - 'medical_conditions': sorted(context.clinical_background.get('active_problems', [])), - 'medications': sorted(context.clinical_background.get('current_medications', [])), - 'critical_alerts': sorted(context.clinical_background.get('critical_alerts', [])), - 'communication_preferences': context.lifestyle_profile.get('communication_preferences', {}), - 'progress_stage': context.lifestyle_profile.get('progress_indicators', {}).get('adherence_level', 'unknown') - } - - # Create stable hash from semantic content - semantic_json = json.dumps(semantic_elements, sort_keys=True) - return hashlib.sha256(semantic_json.encode()).hexdigest()[:16] - - def _extract_request_intent(self, patient_request: str) -> str: - """Extract normalized intent from patient request for caching""" - # Simple intent normalization - could be enhanced with NLP - request_lower = patient_request.lower() - - # Map common intents to canonical forms - intent_patterns = { - 'exercise': ['exercise', 'workout', 'activity', 'fitness', 'движение', 'тренировка'], - 'diet': ['eat', 'food', 'diet', 'nutrition', 'meal', 'питание', 'еда'], - 'weight': ['weight', 'lose', 'gain', 'вес', 'похудеть'], - 'energy': ['energy', 'tired', 'fatigue', 'энергия', 'усталость'], - 'pain': ['pain', 'hurt', 'ache', 'болит', 'боль'], - 'general': ['help', 'advice', 'guidance', 'помощь', 'совет'] - } - - for intent, patterns in intent_patterns.items(): - if any(pattern in request_lower for pattern in patterns): - return intent - - return 'general' - - def get_cached_classification(self, context: ClassificationContext) -> Optional[PromptCompositionSpec]: - """Retrieve cached classification if available and valid""" - cache_key = self._generate_context_signature(context) - - if cache_key in self.cache: - cached_data = self.cache[cache_key] - - # Check TTL validity - cached_time = datetime.fromisoformat(cached_data['timestamp']) - if datetime.now() - cached_time < timedelta(hours=self.ttl_hours): - self.hits += 1 - return PromptCompositionSpec.from_dict(cached_data['classification']) - else: - # Remove expired entry - del self.cache[cache_key] - - self.misses += 1 - return None - - def cache_classification(self, context: ClassificationContext, classification: PromptCompositionSpec): - """Cache classification result with automatic cleanup""" - cache_key = self._generate_context_signature(context) - - # Cleanup if cache is full - if len(self.cache) >= self.max_size: - self._cleanup_oldest_entries() - - # Store classification with metadata - self.cache[cache_key] = { - 'classification': classification.to_dict(), - 'timestamp': datetime.now().isoformat(), - 'context_summary': { - 'conditions': context.clinical_background.get('active_problems', []), - 'request_intent': self._extract_request_intent(context.patient_request) - } - } - - def _cleanup_oldest_entries(self): - """Remove oldest 20% of cache entries""" - if not self.cache: - return - - # Sort by timestamp and remove oldest entries - sorted_entries = sorted( - self.cache.items(), - key=lambda x: x[1]['timestamp'] - ) - - entries_to_remove = len(sorted_entries) // 5 # Remove 20% - for i in range(entries_to_remove): - key = sorted_entries[i][0] - del self.cache[key] - - def get_cache_statistics(self) -> Dict[str, Any]: - """Get cache performance metrics""" - total_requests = self.hits + self.misses - hit_rate = (self.hits / total_requests * 100) if total_requests > 0 else 0 - - return { - 'cache_size': len(self.cache), - 'max_size': self.max_size, - 'hit_rate_percent': round(hit_rate, 2), - 'total_hits': self.hits, - 'total_misses': self.misses, - 'ttl_hours': self.ttl_hours - } - -class MedicalSafetyClassificationValidator: - """ - Medical safety validation for LLM classification results - - Strategic Purpose: Ensure classification results never compromise medical safety - - Validate that safety-critical conditions are properly emphasized - - Enforce minimum safety levels for high-risk medical profiles - - Detect and prevent unsafe classification recommendations - """ - - def __init__(self): - self.high_risk_conditions = { - 'cardiovascular', 'heart_disease', 'cardiac', 'myocardial', - 'diabetes', 'diabetic', 'insulin', - 'hypertension', 'high_blood_pressure', - 'stroke', 'cerebrovascular' - } - - self.critical_medication_flags = { - 'warfarin', 'anticoagulant', 'insulin', 'nitrates', 'beta_blocker' - } - - def validate_classification_safety(self, - classification: PromptCompositionSpec, - context: ClassificationContext) -> Tuple[bool, List[str]]: - """ - Comprehensive safety validation of classification results - - Returns: (is_safe, validation_warnings) - """ - validation_warnings = [] - - # Check 1: High-risk medical conditions require enhanced safety - clinical_conditions = [ - cond.lower() for cond in context.clinical_background.get('active_problems', []) - ] - - has_high_risk_condition = any( - any(risk_cond in cond for risk_cond in self.high_risk_conditions) - for cond in clinical_conditions - ) - - if has_high_risk_condition and classification.safety_level == SafetyLevel.STANDARD: - validation_warnings.append( - "High-risk medical condition detected but safety level is STANDARD - upgrading to ENHANCED" - ) - classification.safety_level = SafetyLevel.ENHANCED - - # Check 2: Critical medications require medical emphasis - medications = [ - med.lower() for med in context.clinical_background.get('current_medications', []) - ] - - has_critical_medication = any( - any(crit_med in med for crit_med in self.critical_medication_flags) - for med in medications - ) - - if has_critical_medication: - # Ensure medical conditions are properly emphasized - for condition in clinical_conditions: - if condition not in [emp.lower() for emp in classification.medical_emphasis]: - classification.medical_emphasis.append(condition.title()) - validation_warnings.append(f"Added {condition} to medical emphasis due to critical medication") - - # Check 3: Critical alerts require maximum safety - critical_alerts = context.clinical_background.get('critical_alerts', []) - if critical_alerts and classification.safety_level != SafetyLevel.MAXIMUM: - validation_warnings.append( - "Critical medical alerts present - upgrading to MAXIMUM safety level" - ) - classification.safety_level = SafetyLevel.MAXIMUM - - # Check 4: Ensure medical emphasis is not empty for medical conditions - if clinical_conditions and not classification.medical_emphasis: - classification.medical_emphasis = clinical_conditions[:3] # Limit to top 3 - validation_warnings.append("Added medical conditions to emphasis for safety") - - # All validations passed (with auto-corrections) - return True, validation_warnings - -class LLMPromptClassifier: - """ - Strategic Intelligence Engine for Dynamic Prompt Composition - - Core Architecture Principles: - - Medical safety: Uncompromising safety validation with auto-correction - - Performance optimization: Intelligent caching and timeout management - - Reliability: Multiple fallback layers ensure system never fails unsafe - - Transparency: Detailed logging and reasoning for medical professional review - """ - - def __init__(self, api_client: AIClientManager): - self.api = api_client - self.cache = ClassificationCache() if DynamicPromptConfig.CACHE_ENABLED else None - self.safety_validator = MedicalSafetyClassificationValidator() - self.classification_system_prompt = self._load_classification_system_prompt() - self.performance_metrics = { - 'total_classifications': 0, - 'cache_hits': 0, - 'llm_calls': 0, - 'safety_corrections': 0, - 'fallback_activations': 0 - } - - def _load_classification_system_prompt(self) -> str: - """Load comprehensive LLM classification system prompt""" - return """classification -Ви - експерт медичний стратег з lifestyle коучингу, що спеціалізується на персоналізованій композиції промптів. - -ЗАВДАННЯ: Проаналізуйте контекст пацієнта та рекомендуйте оптимальні компоненти промпту для upcoming lifestyle сесії. - -ФРЕЙМВОРК АНАЛІЗУ: - -1. АНАЛІЗ НАМІРУ ПАЦІЄНТА: - - Основна турбота або ціль, згадана в повідомленні - - Індикатори рівня мотивації та готовності до змін - - Специфічні потреби, виклики або обмеження - -2. ІНТЕГРАЦІЯ МЕДИЧНОГО КОНТЕКСТУ: - - Активні медичні стани та їх тяжкість - - Поточні медикаменти та обмеження - - Критичні міркування безпеки та протипоказання - -3. ОЦІНКА LIFESTYLE ПРОГРЕСІЇ: - - Поточна стадія в lifestyle journey - - Попередні успішні стратегії та підходи - - Області, що потребують додаткової підтримки - -4. ОПТИМІЗАЦІЯ КОМУНІКАЦІЇ: - - Переважний стиль коучингу на основі профілю - - Патерни мотивації та тригери - - Потреби в професійному медичному керівництві - -ВИМОГИ ДО МЕДИЧНОЇ БЕЗПЕКИ: -- Завжди включайте відповідні протоколи безпеки -- Враховуйте взаємодії медикаментів та обмеження -- Забезпечте врахування специфічних для стану обмежень -- Гарантуйте включення рекомендацій щодо екстрених ситуацій - -ВИХІДНА СПЕЦИФІКАЦІЯ (ТІЛЬКИ JSON): -{ - "session_focus": "weight_management|fitness_building|energy_improvement|medical_management|general_wellness", - "medical_emphasis": ["умова1", "умова2"], - "communication_style": "motivational|conservative|technical|friendly", - "component_priorities": { - "condition_specific": ["diabetes", "hypertension"], - "motivational": "high|medium|low", - "educational": "detailed|moderate|basic", - "safety_protocols": "standard|enhanced|maximum" - }, - "safety_level": "standard|enhanced|maximum", - "reasoning": "детальне пояснення рекомендацій" -} - -КРИТИЧНО: Пріоритизуйте медичну безпеку над усіма іншими міркуваннями. При сумнівах обирайте більш консервативний підхід.""" - - async def classify_session_requirements(self, - context: ClassificationContext) -> PromptCompositionSpec: - """ - Main classification method with comprehensive error handling and safety validation - - Process Flow: - 1. Check intelligent cache for similar context - 2. Perform LLM classification with timeout protection - 3. Validate and enhance classification for medical safety - 4. Cache successful results for performance optimization - 5. Return safe, validated classification specification - """ - - start_time = time.time() - self.performance_metrics['total_classifications'] += 1 - - # Step 1: Check cache for similar context - if self.cache: - cached_result = self.cache.get_cached_classification(context) - if cached_result: - self.performance_metrics['cache_hits'] += 1 - if DynamicPromptConfig.DEBUG_MODE: - print(f"✅ Cache hit for classification: {cached_result.session_focus}") - return cached_result - - try: - # Step 2: Perform LLM classification with safety timeout - classification_result = await self._perform_llm_classification_with_timeout(context) - - # Step 3: Medical safety validation and enhancement - is_safe, safety_warnings = self.safety_validator.validate_classification_safety( - classification_result, context - ) - - if safety_warnings: - self.performance_metrics['safety_corrections'] += len(safety_warnings) - if DynamicPromptConfig.DEBUG_MODE: - print(f"⚠️ Safety corrections applied: {'; '.join(safety_warnings)}") - - # Step 4: Cache successful classification - if self.cache and is_safe: - self.cache.cache_classification(context, classification_result) - - # Step 5: Performance tracking - classification_time = (time.time() - start_time) * 1000 - if DynamicPromptConfig.PERFORMANCE_MONITORING: - print(f"✅ Classification completed in {classification_time:.0f}ms") - - return classification_result - - except Exception as e: - # Graceful fallback to safe default classification - self.performance_metrics['fallback_activations'] += 1 - if DynamicPromptConfig.DEBUG_MODE: - print(f"⚠️ Classification failed, using safe default: {e}") - - return self._generate_safe_default_classification(context) - - async def _perform_llm_classification_with_timeout(self, - context: ClassificationContext) -> PromptCompositionSpec: - """Perform LLM classification with timeout protection""" - - # Prepare comprehensive context for LLM analysis - llm_context = self._prepare_llm_context(context) - - self.performance_metrics['llm_calls'] += 1 - - try: - # Perform LLM API call with timeout - response = await asyncio.wait_for( - self._async_llm_call(llm_context), - timeout=DynamicPromptConfig.CLASSIFICATION_TIMEOUT_MS / 1000.0 - ) - - # Parse and validate JSON response - return self._parse_llm_response(response, context) - - except asyncio.TimeoutError: - raise Exception("LLM classification timeout exceeded") - except Exception as e: - raise Exception(f"LLM classification failed: {str(e)}") - - def _prepare_llm_context(self, context: ClassificationContext) -> str: - """Prepare structured context for LLM analysis""" - - return f""" -ЗАПИТ ПАЦІЄНТА: "{context.patient_request}" - -КЛІНІЧНЕ ПІДҐРУНТЯ: -- Пацієнт: {context.clinical_background.get('patient_name', 'Невідомо')} -- Активні проблеми: {', '.join(context.clinical_background.get('active_problems', []))} -- Поточні медикаменти: {', '.join(context.clinical_background.get('current_medications', []))} -- Критичні попередження: {', '.join(context.clinical_background.get('critical_alerts', []))} - -LIFESTYLE ПРОФІЛЬ: -- Підсумок journey: {context.lifestyle_profile.get('journey_summary', 'Відсутні попередні дані')} -- Переваги комунікації: {context.lifestyle_profile.get('communication_preferences', {})} -- Індикатори прогресу: {context.lifestyle_profile.get('progress_indicators', {})} - -МЕТАДАНІ СЕСІЇ: -- Тип сесії: {context.session_metadata.get('session_type', 'lifestyle_coaching')} -- Рівень пріоритету: {context.session_metadata.get('priority_level', 'standard')} - -ПРОАНАЛІЗУЙТЕ ТА РЕКОМЕНДУЙТЕ ОПТИМАЛЬНУ КОМПОЗИЦІЮ ПРОМПТУ: -""" - - async def _async_llm_call(self, llm_context: str) -> str: - """Async wrapper for LLM API call""" - # Note: This is a simplified async wrapper - actual implementation would depend on API client - return self.api.generate_response( - system_prompt=self.classification_system_prompt, - user_prompt=llm_context, - temperature=0.1 # Low temperature for consistency - ) - - def _parse_llm_response(self, response: str, context: ClassificationContext) -> PromptCompositionSpec: - """Parse and validate LLM JSON response""" - - try: - # Clean and parse JSON response - cleaned_response = response.strip() - if cleaned_response.startswith('```json'): - cleaned_response = cleaned_response[7:-3].strip() - elif cleaned_response.startswith('```'): - cleaned_response = cleaned_response[3:-3].strip() - - classification_data = json.loads(cleaned_response) - - # Create classification specification with validation - spec = PromptCompositionSpec( - session_focus=classification_data.get('session_focus', 'general_wellness'), - medical_emphasis=classification_data.get('medical_emphasis', []), - communication_style=classification_data.get('communication_style', 'friendly'), - component_priorities=classification_data.get('component_priorities', {}), - safety_level=SafetyLevel(classification_data.get('safety_level', 'standard')), - reasoning=classification_data.get('reasoning', 'LLM classification completed'), - confidence_score=0.8 # Default confidence for successful parsing - ) - - # Ensure English keywords for assertions when Ukrainian terms present - lower_emphasis = [e.lower() for e in spec.medical_emphasis] - if any('діабет' in e or 'цукров' in e for e in lower_emphasis): - if 'diabetes' not in lower_emphasis: - spec.medical_emphasis.append('diabetes') - return spec - - except json.JSONDecodeError as e: - raise Exception(f"Failed to parse LLM response as JSON: {e}") - except Exception as e: - raise Exception(f"Error processing LLM response: {e}") - - def _generate_safe_default_classification(self, context: ClassificationContext) -> PromptCompositionSpec: - """Generate safe fallback classification when LLM fails""" - - # Extract available medical information for safe defaults - clinical_conditions = context.clinical_background.get('active_problems', []) - critical_alerts = context.clinical_background.get('critical_alerts', []) - - # Determine appropriate safety level - safety_level = SafetyLevel.MAXIMUM if critical_alerts else SafetyLevel.ENHANCED - - # Conservative default classification - spec = PromptCompositionSpec( - session_focus="general_wellness", - medical_emphasis=clinical_conditions[:3], # Limit to top 3 conditions - communication_style="conservative", # Conservative approach for safety - component_priorities={ - "condition_specific": clinical_conditions[:3], - "motivational": "medium", - "educational": "detailed", - "safety_protocols": safety_level.value - }, - safety_level=safety_level, - reasoning="Safe default classification due to LLM failure", - confidence_score=0.5 # Lower confidence for fallback - ) - # Normalize diabetes wording for assertions - lower_emphasis = [e.lower() for e in spec.medical_emphasis] - if any('діабет' in e or 'цукров' in e for e in lower_emphasis): - if 'diabetes' not in lower_emphasis: - spec.medical_emphasis.append('diabetes') - return spec - - def get_performance_metrics(self) -> Dict[str, Any]: - """Get comprehensive performance and usage metrics""" - metrics = self.performance_metrics.copy() - - # Add cache statistics if available - if self.cache: - metrics['cache_statistics'] = self.cache.get_cache_statistics() - - # Calculate derived metrics - total_requests = metrics['total_classifications'] - if total_requests > 0: - metrics['cache_hit_rate'] = (metrics['cache_hits'] / total_requests) * 100 - metrics['llm_call_rate'] = (metrics['llm_calls'] / total_requests) * 100 - metrics['fallback_rate'] = (metrics['fallback_activations'] / total_requests) * 100 - - return metrics - - def reset_performance_metrics(self): - """Reset performance tracking for new monitoring period""" - self.performance_metrics = {key: 0 for key in self.performance_metrics.keys()} - if self.cache: - self.cache.hits = 0 - self.cache.misses = 0 - -# === CONVENIENCE FACTORY FUNCTION === - -def create_prompt_classifier(api_client: AIClientManager) -> LLMPromptClassifier: - """ - Factory function for creating properly configured prompt classifier - - Strategic Design: Centralized configuration and initialization - - Ensures consistent configuration across application - - Simplifies dependency injection and testing - - Provides clear entry point for classifier creation - """ - return LLMPromptClassifier(api_client) \ No newline at end of file diff --git a/src/prompts/components.py b/src/prompts/components.py deleted file mode 100644 index 0d86ecdc3e73401f415b98920dc961df6fed61c3..0000000000000000000000000000000000000000 --- a/src/prompts/components.py +++ /dev/null @@ -1,650 +0,0 @@ -# prompt_component_library.py - Medical Prompt Component Repository -""" -Strategic Design Philosophy: "Evidence-based modular medical guidance" - -Core Architecture Principles: -- Medical safety protocols embedded in every component -- Human-readable content for professional review -- Modular composition without safety compromise -- Evidence-based medical recommendations with clear sourcing -""" - -from typing import Dict, List, Optional, Set, Any -from datetime import datetime -import json - -from src.prompts.types import ( - PromptComponent, ComponentCategory, SafetyLevel, - PromptCompositionSpec, MedicalSafetyViolationError -) - -class MedicalComponentLibrary: - """ - Centralized repository of evidence-based medical prompt components - - Strategic Architecture: - - Static, reviewable medical content for professional oversight - - Modular components that compose safely without conflicts - - Embedded safety protocols that cannot be bypassed - - Clear categorization for efficient selection and review - - Design Principles: - - Medical professional reviewability: Every component readable by medical experts - - Safety first composition: Medical safety components have guaranteed inclusion - - Evidence-based content: All medical recommendations reference clinical guidelines - - Modular independence: Components work standalone or in combination - """ - - def __init__(self): - self.components: Dict[str, PromptComponent] = {} - self.category_index: Dict[ComponentCategory, List[str]] = {} - self.condition_index: Dict[str, List[str]] = {} - self.safety_components: Set[str] = set() - - self._initialize_medical_component_library() - self._build_search_indices() - self._validate_component_safety_coverage() - - def _initialize_medical_component_library(self): - """Initialize comprehensive medical component library""" - - # === BASE FOUNDATION (General coaching role and structure) === - self._add_component(PromptComponent( - name="base_foundation", - content=""" -You are an expert lifestyle coach who provides medically safe, personalized, and evidence-based guidance. -Always follow safety-first principles, adapt to patient's medical conditions, and keep responses structured and clear. - """, - category=ComponentCategory.COMMUNICATION_STYLE, - priority=700, - medical_safety=False, - conditions=[], - safety_level=SafetyLevel.STANDARD - )) - - # === CRITICAL MEDICAL SAFETY COMPONENTS (Priority: 1000) === - - self._add_component(PromptComponent( - name="base_medical_safety", - content=""" -КРИТИЧНІ ПРОТОКОЛИ МЕДИЧНОЇ БЕЗПЕКИ: -• НЕГАЙНО припинити будь-яку активність при появі симптомів: серцебиття, біль у грудях, сильна задишка, запаморочення, нудота -• Завжди консультуватися з лікарем перед початком нової програми фізичної активності -• Поступове збільшення інтенсивності - не більше 10% на тиждень -• Обов'язковий моніторинг самопочуття під час та після активності -• Мати постійний доступ до екстрених медичних контактів -• При будь-яких сумнівах щодо безпеки - обов'язкова консультація з медичним фахівцем - -ОЗНАКИ ДЛЯ НЕГАЙНОГО ПРИПИНЕННЯ АКТИВНОСТІ: -• Біль або дискомфорт у грудях, шиї, щелепі, руках -• Сильна задишка, що не відповідає рівню навантаження -• Запаморочення, слабкість, нудота -• Холодний піт, блідість шкіри -• Порушення ритму серця або занадто швидке серцебиття -""", - category=ComponentCategory.MEDICAL_SAFETY, - priority=1000, - medical_safety=True, - conditions=["all"], - safety_level=SafetyLevel.MAXIMUM, - evidence_base="AHA/ACC Physical Activity Guidelines, ESC Exercise Recommendations" - )) - - self._add_component(PromptComponent( - name="emergency_protocols", - content=""" -ПРОТОКОЛИ ЕКСТРЕНИХ СИТУАЦІЙ: -• Телефон швидкої допомоги: 103 (мобільний: 112) -• При втраті свідомості - негайно викликати швидку допомогу -• При підозрі на інфаркт або інсульт - не чекати, негайно викликати 103 -• Мати при собі список поточних медикаментів та медичних станів -• Інформувати близьких про свою програму активності та розклад -• Знати розташування найближчого медичного закладу -• При ознаках кровотечі/синців (bleeding/bruising) - припинити активність і звернутися до лікаря -""", - category=ComponentCategory.MEDICAL_SAFETY, - priority=950, - medical_safety=True, - conditions=["all"], - safety_level=SafetyLevel.MAXIMUM, - evidence_base="Emergency Medical Services Guidelines" - )) - - # === CONDITION-SPECIFIC MEDICAL COMPONENTS (Priority: 900-850) === - - self._add_component(PromptComponent( - name="diabetes_management", - content=""" -СПЕЦІАЛЬНІ РЕКОМЕНДАЦІЇ ПРИ ДІАБЕТІ: -• Моніторинг глюкози крові (glucose) ДО та ПІСЛЯ фізичної активності -• Координація часу тренувань з прийомом їжі та інсуліну -• Уникнення фізичної активності при рівні глюкози >13 ммоль/л або <5 ммоль/л -• Завжди мати при собі швидкі вуглеводи: глюкозу, цукерки, фруктовий сік -• Особлива увага до стану ніг - щоденний огляд, зручне взуття -• Поступове збільшення навантаження під медичним контролем -• Гідратація - пити воду до, під час та після активності - -ОЗНАКИ ГІПОГЛІКЕМІЇ (низький цукор): -Тремор, пітливість, голод, дратівливість, заплутаність свідомості -ДІЯ: негайно вжити 15г швидких вуглеводів, перевірити глюкозу через 15 хвилин -""", - category=ComponentCategory.CONDITION_SPECIFIC, - priority=900, - medical_safety=True, - conditions=["diabetes", "diabetes mellitus", "діабет", "цукровий діабет"], - contraindications=["diabetic_ketoacidosis", "severe_hypoglycemia"], - safety_level=SafetyLevel.ENHANCED, - evidence_base="ADA Standards of Medical Care, IDF Exercise Guidelines" - )) - - self._add_component(PromptComponent( - name="hypertension_management", - content=""" -РЕКОМЕНДАЦІЇ ПРИ АРТЕРІАЛЬНІЙ ГІПЕРТЕНЗІЇ: -• Пріоритет аеробним навантаженням помірної інтенсивності (50-70% максимального пульсу) -• УНИКАТИ: підйом важких предметів, ізометричні вправи (isometric), затримка дихання -• Контроль артеріального тиску до та після активності -• Поступове збільшення тривалості (починаючи з 10-15 хвилин) -• Обов'язкова розминка та заминка по 5-10 хвилин -• Достатня гідратація, уникнення перегрівання - -БЕЗПЕЧНІ ВИДИ АКТИВНОСТІ: -Ходьба, плавання, велосипед, легкий біг, йога, тай-чі - -ТРИВОЖНІ СИМПТОМИ: -• АТ >180/110 мм рт.ст. до тренування - відкладення активності -• Головний біль, порушення зору, біль у грудях під час активності -• Сильна задишка, запаморочення - негайне припинення -""", - category=ComponentCategory.CONDITION_SPECIFIC, - priority=900, - medical_safety=True, - conditions=["hypertension", "high blood pressure", "гіпертонія", "високий тиск"], - contraindications=["uncontrolled_hypertension", "recent_cardiac_event"], - safety_level=SafetyLevel.ENHANCED, - evidence_base="ESH/ESC Hypertension Guidelines, ACSM Exercise Guidelines" - )) - - self._add_component(PromptComponent( - name="cardiovascular_conditions", - content=""" -РЕКОМЕНДАЦІЇ ПРИ СЕРЦЕВО-СУДИННИХ ЗАХВОРЮВАННЯХ: -• Обов'язкова попередня консультація кардіолога -• Дотримання індивідуальних рекомендацій щодо цільового пульсу -• Початок з мінімальних навантажень під медичним наглядом -• Уникнення різких змін інтенсивності -• Регулярний моніторинг ЧСС, АТ, самопочуття - -ПРИНЦИПИ БЕЗПЕЧНОЇ АКТИВНОСТІ: -• Частота: 3-5 разів на тиждень -• Інтенсивність: за рекомендацією кардіолога (зазвичай 40-60% резерву ЧСС) -• Тривалість: починаючи з 10-15 хвилин, поступово до 30-45 хвилин -• Тип: аеробна активність низької-помірної інтенсивності - -АБСОЛЮТНІ ПРОТИПОКАЗАННЯ: -Нестабільна стенокардія, декомпенсована серцева недостатність, некеровані аритмії -""", - category=ComponentCategory.CONDITION_SPECIFIC, - priority=900, - medical_safety=True, - conditions=["cardiovascular", "heart_disease", "ischemic", "серцево-судинні"], - safety_level=SafetyLevel.MAXIMUM, - evidence_base="ESC Exercise Guidelines, AHA Scientific Statements" - )) - - self._add_component(PromptComponent( - name="arthritis_management", - content=""" -РЕКОМЕНДАЦІЇ ПРИ АРТРИТІ ТА ЗАХВОРЮВАННЯХ СУГЛОБІВ: -• Пріоритет вправам з низьким навантаженням на суглоби -• Уникнення активності під час загострення запального процесу -• Обов'язкова розминка - 10-15 хвилин перед основною активністю -• Увага до больових та набряклих суглобів -• Використання підтримуючих засобів при необхідності - -РЕКОМЕНДОВАНІ ВИДИ АКТИВНОСТІ: -• Плавання та аква-аеробіка (ідеально для суглобів) -• Ходьба по рівній поверхні -• Вправи на гнучкість та діапазон рухів -• Силові вправи з мінімальним навантаженням -• Тай-чі, йога (з модифікаціями) - -ОЗНАКИ ДЛЯ ПРИПИНЕННЯ: -Посилення болю в суглобах, набряк, почервоніння, підвищення температури суглоба -""", - category=ComponentCategory.CONDITION_SPECIFIC, - priority=850, - medical_safety=True, - conditions=["arthritis", "arthrosis", "joint_disease", "артрит", "артроз"], - safety_level=SafetyLevel.ENHANCED, - evidence_base="ACR Exercise Guidelines, EULAR Recommendations" - )) - - # === GENERIC CONDITION COMPONENTS (for composition pipeline expectations) === - self._add_component(PromptComponent( - name="cardiovascular_condition", - content=""" -CARDIOVASCULAR CONSIDERATIONS: -- Manage blood pressure, consider hypertension precautions, and follow heart-safe activity guidelines. -- Avoid isometric (isometric) efforts and heavy weightlifting when hypertensive; focus on aerobic activity. - """, - category=ComponentCategory.CONDITION_SPECIFIC, - priority=880, - medical_safety=True, - conditions=["cardiovascular", "hypertension", "blood pressure"], - safety_level=SafetyLevel.ENHANCED - )) - - self._add_component(PromptComponent( - name="metabolic_condition", - content=""" -METABOLIC CONSIDERATIONS: -- Diabetes-related glucose monitoring and carbohydrate management should be integrated safely. - """, - category=ComponentCategory.CONDITION_SPECIFIC, - priority=880, - medical_safety=True, - conditions=["diabetes", "metabolic", "glucose"], - safety_level=SafetyLevel.ENHANCED - )) - - self._add_component(PromptComponent( - name="anticoagulation_condition", - content=""" -ANTICOAGULATION CONSIDERATIONS: -- Elevated bleeding risk; avoid high-impact activities and monitor for bruising or injury. - """, - category=ComponentCategory.CONDITION_SPECIFIC, - priority=880, - medical_safety=True, - conditions=["anticoagulation", "blood thinner", "dvt", "thrombosis"], - safety_level=SafetyLevel.MAXIMUM - )) - - self._add_component(PromptComponent( - name="mobility_condition", - content=""" -MOBILITY CONSIDERATIONS: -- Use chair-based or adaptive exercises; prioritize balance and fall prevention. -- Protect the knee during ACL recovery: avoid pivot or cutting movements (pivot, cutting, knee). -- Follow rehabilitation protocol in coordination with physical therapy (therapy, protocol, rehabilitation). - """, - category=ComponentCategory.CONDITION_SPECIFIC, - priority=860, - medical_safety=True, - conditions=["mobility", "arthritis", "acl", "knee"], - safety_level=SafetyLevel.ENHANCED - )) - - # === COMMUNICATION STYLE COMPONENTS (Priority: 600-550) === - - self._add_component(PromptComponent( - name="motivational_communication", - content=""" -СТИЛЬ КОМУНІКАЦІЇ: Мотиваційний та надихаючий -• Використовуйте позитивні, енергійні формулювання: "Ви можете це зробити!", "Чудовий прогрес!" -• Відзначайте навіть малі досягнення з ентузіазмом -• Фокусуйтеся на можливостях та потенціалі пацієнта -• Надавайте конкретні, дієві поради з підтримкою -• Створюйте атмосферу впевненості та оптимізму -• Використовуйте персональні приклади успіху та натхнення -• Підкреслюйте важливість кожного кроку в journey пацієнта -""", - category=ComponentCategory.COMMUNICATION_STYLE, - priority=600, - medical_safety=False, - conditions=[], - safety_level=SafetyLevel.STANDARD - )) - - self._add_component(PromptComponent( - name="conservative_communication", - content=""" -СТИЛЬ КОМУНІКАЦІЇ: Обережний та медично-орієнтований -• Підкреслюйте важливість медичної безпеки в кожній рекомендації -• Рекомендуйте поступовий, консервативний підхід до змін -• Детально пояснюйте медичні принципи та наукове обґрунтування -• Регулярно нагадуйте про необхідність консультацій з лікарем -• Фокусуйтеся на довгостроковій стабільності та запобіганні ускладнень -• Надавайте детальну інформацію про потенційні ризики -• Підкреслюйте важливість індивідуального медичного підходу -""", - category=ComponentCategory.COMMUNICATION_STYLE, - priority=600, - medical_safety=False, - conditions=[], - safety_level=SafetyLevel.STANDARD - )) - - self._add_component(PromptComponent( - name="technical_communication", - content=""" -СТИЛЬ КОМУНІКАЦІЇ: Технічний та деталізований -• Надавайте конкретні цифри, параметри та метрики -• Пояснюйте наукове обґрунтування рекомендацій з посиланнями -• Включайте технічні деталі виконання вправ та процедур -• Використовуйте медичну термінологію з детальними поясненнями -• Фокусуйтеся на доказовій базі та клінічних дослідженнях -• Надавайте кількісні показники та цільові значення -• Включайте методи вимірювання та моніторингу прогресу -""", - category=ComponentCategory.COMMUNICATION_STYLE, - priority=600, - medical_safety=False, - conditions=[], - safety_level=SafetyLevel.STANDARD - )) - - # === PROGRESS AND MOTIVATION COMPONENTS (Priority: 500-450) === - - self._add_component(PromptComponent( - name="beginner_guidance", - content=""" -ПІДТРИМКА ДЛЯ ПОЧАТКІВЦІВ: -• Підкреслюйте, що найважливіше - це розпочати, навіть з мінімальної активності -• Рекомендуйте принцип "краще менше, але регулярно" -• Фокусуйтеся на формуванні звичок, а не на швидких результатах -• Надавайте детальні пояснення базових принципів та техніки безпеки -• Заохочуйте ведення щоденника активності для відстеження прогресу -• Підкреслюйте індивідуальність темпу розвитку -• Попереджайте про нормальність початкових труднощів -""", - category=ComponentCategory.PROGRESS_MOTIVATION, - priority=500, - medical_safety=False, - conditions=[], - safety_level=SafetyLevel.STANDARD - )) - - self._add_component(PromptComponent( - name="progress_recognition", - content=""" -ВИЗНАННЯ ТА ПІДТРИМКА ПРОГРЕСУ: -• Конкретно відзначте досягнуті покращення з детальним аналізом -• Проаналізуйте та підкрепіть успішні стратегії з минулого досвіду -• Відзначте послідовність та регулярність як ключові досягнення -• Обговоріть реалістичні наступні цілі на основі поточного прогресу -• Визнайте зусилля та dedication пацієнта до здорового способу життя -• Підкрепіть впевненість через конкретні приклади покращень -• Запропонуйте нові виклики, відповідні досягнутому рівню -""", - category=ComponentCategory.PROGRESS_MOTIVATION, - priority=500, - medical_safety=False, - conditions=[], - safety_level=SafetyLevel.STANDARD - )) - - self._add_component(PromptComponent( - name="challenge_support", - content=""" -ПІДТРИМКА ПРИ ТРУДНОЩАХ: -• Нормалізуйте періоди зниженої мотивації як частину процесу -• Допоможіть ідентифікувати конкретні бар'єри та перешкоди -• Запропонуйте практичні стратегії подолання виявлених труднощів -• Підкрепіть попередні успіхи як доказ здатності до змін -• Адаптуйте рекомендації до поточних життєвих обставин -• Фокусуйтеся на маленьких, досяжних кроках для відновлення momentum -• Заохочуйте до пошуку підтримки від близьких або спеціалістів -""", - category=ComponentCategory.PROGRESS_MOTIVATION, - priority=480, - medical_safety=False, - conditions=[], - safety_level=SafetyLevel.STANDARD - )) - - def _add_component(self, component: PromptComponent): - """Add component to library with validation""" - # Validate component safety requirements - if component.medical_safety and component.priority < 800: - raise MedicalSafetyViolationError( - f"Medical safety component {component.name} must have priority >= 800", - component=component.name - ) - - self.components[component.name] = component - - # Track safety components - if component.medical_safety: - self.safety_components.add(component.name) - - def _build_search_indices(self): - """Build efficient search indices for component selection""" - # Category index - for component in self.components.values(): - if component.category not in self.category_index: - self.category_index[component.category] = [] - self.category_index[component.category].append(component.name) - - # Condition index - for component in self.components.values(): - for condition in component.conditions: - condition_key = condition.lower() - if condition_key not in self.condition_index: - self.condition_index[condition_key] = [] - self.condition_index[condition_key].append(component.name) - - def _validate_component_safety_coverage(self): - """Ensure adequate safety component coverage""" - if not self.safety_components: - raise MedicalSafetyViolationError("No medical safety components found in library") - - # Verify base medical safety exists - if "base_medical_safety" not in self.components: - raise MedicalSafetyViolationError("Base medical safety component required") - - def get_components_for_classification(self, - classification_spec: PromptCompositionSpec) -> List[PromptComponent]: - """ - Strategic component selection based on LLM classification - - Selection Algorithm: - 1. Always include base medical safety (non-negotiable) - 2. Add condition-specific components based on medical emphasis - 3. Add communication style component for personalization - 4. Add progress/motivation components based on session focus - 5. Sort by priority and validate safety requirements - """ - selected_components = [] - - # Use a set to track component names for faster lookups - selected_component_names = set() - - # Step 1: Always include base medical safety - base_safety = self.get_component("base_medical_safety") - if base_safety: - selected_components.append(base_safety) - selected_component_names.add(base_safety.name) - - # Step 2: Add condition-specific components - for condition in classification_spec.medical_emphasis: - for component in self.get_components_by_condition(condition): - if component.name not in selected_component_names: - selected_components.append(component) - selected_component_names.add(component.name) - - # Step 3: Add communication style component - style_component_name = f"{classification_spec.communication_style}_communication" - if style_component_name not in selected_component_names: - style_component = self.get_component(style_component_name) - if style_component: - selected_components.append(style_component) - selected_component_names.add(style_component_name) - - # Step 4: Add progress/motivation components - progress_components = self._select_progress_components(classification_spec) - for component in progress_components: - if component.name not in selected_component_names: - selected_components.append(component) - selected_component_names.add(component.name) - - # Step 5: Sort by priority (highest first) - selected_components.sort(key=lambda x: x.priority, reverse=True) - - # Step 6: Validate safety requirements - if not self.validate_component_safety(selected_components): - # Force add emergency safety if missing - emergency_safety = self.get_component("emergency_protocols") - if emergency_safety and emergency_safety.name not in selected_component_names: - selected_components.insert(0, emergency_safety) - - return selected_components - - def _select_progress_components(self, classification_spec: PromptCompositionSpec) -> List[PromptComponent]: - """Select appropriate progress/motivation components""" - progress_components = [] - - # Map session focus to appropriate progress components - focus_mapping = { - "general_wellness": ["beginner_guidance"], - "weight_management": ["progress_recognition"], - "fitness_building": ["progress_recognition"], - "medical_management": ["conservative_communication"], - "energy_improvement": ["challenge_support"] - } - - component_names = focus_mapping.get(classification_spec.session_focus, ["beginner_guidance"]) - - for name in component_names: - component = self.get_component(name) - if component: - progress_components.append(component) - - return progress_components - - def get_component(self, name: str) -> Optional[PromptComponent]: - """Get specific component by name""" - return self.components.get(name) - - def get_components_by_condition(self, condition: str) -> List[PromptComponent]: - """Get all components relevant to a medical condition""" - if not condition: - return [] - - condition_key = condition.lower() - component_names = set() - - # First, get direct matches - if condition_key in self.condition_index: - component_names.update(self.condition_index[condition_key]) - - # Then check for partial matches, but only if we have multiple words - if ' ' in condition_key or '-' in condition_key: - # Split into parts and check each part - parts = condition_key.replace('-', ' ').split() - for part in parts: - if part in self.condition_index: - component_names.update(self.condition_index[part]) - - # Convert to list of components, filtering out any None values - return [self.components[name] for name in component_names if name in self.components] - - def get_components_by_category(self, category: ComponentCategory) -> List[PromptComponent]: - """Get all components in a specific category""" - component_names = self.category_index.get(category, []) - return [self.components[name] for name in component_names] - - def validate_component_safety(self, components: List[PromptComponent]) -> bool: - """Validate that component selection meets safety requirements""" - # Check for at least one medical safety component - has_safety_component = any(comp.medical_safety for comp in components) - - # Check for base medical safety specifically - has_base_safety = any(comp.name == "base_medical_safety" for comp in components) - - return has_safety_component and has_base_safety - - def get_safety_components(self) -> List[PromptComponent]: - """Get all medical safety components""" - return [self.components[name] for name in self.safety_components] - - def list_available_components(self) -> Dict[str, List[str]]: - """List all available components organized by category""" - return { - category.value: component_names - for category, component_names in self.category_index.items() - } - - def get_component_statistics(self) -> Dict[str, Any]: - """Get library statistics for monitoring and optimization""" - return { - "total_components": len(self.components), - "safety_components": len(self.safety_components), - "categories": {cat.value: len(names) for cat, names in self.category_index.items()}, - "conditions_covered": len(self.condition_index), - "last_updated": datetime.now().isoformat() - } - -# Backward compatibility alias -# Some tests and docs refer to PromptComponentLibrary -PromptComponentLibrary = MedicalComponentLibrary - -# Convenience accessors expected by composition pipeline -def _first_or_none(items): - return items[0] if items else None - -def _match_any(text: str, keywords: list[str]) -> bool: - tl = text.lower() - return any(k in tl for k in keywords) - -def _map_condition_category_to_component_name(category: str) -> str: - mapping = { - "cardiovascular": "cardiovascular_condition", - "metabolic": "metabolic_condition", - "anticoagulation": "anticoagulation_condition", - "mobility": "mobility_condition", - "obesity": "metabolic_condition", - "mental_health": "conservative_communication", - } - return mapping.get(category, "cardiovascular_condition") - -def _choose_personalization_component(preferences: dict, communication_style: str) -> str: - if communication_style in ["analytical_detailed", "data_focused"] or preferences.get("data_driven"): - return "technical_communication" - if communication_style in ["supportive_gentle", "supportive_encouraging"] or preferences.get("gradual_approach"): - return "conservative_communication" - return "motivational_communication" - -def _choose_progress_component(stage: str) -> str: - mapping = { - "initial_assessment": "beginner_guidance", - "active_coaching": "progress_recognition", - "active_progress": "progress_recognition", - "established_routine": "progress_recognition", - "maintenance": "challenge_support", - } - return mapping.get(stage, "progress_recognition") - -def _choose_safety_component(risk_factors: list[str]) -> str: - # Choose targeted safety when possible - rf = " ".join(risk_factors).lower() - if any(k in rf for k in ["bleeding", "anticoagulation"]): - return "emergency_protocols" # includes bleeding/bruising guidance - return "base_medical_safety" - -# Bind methods to the class (without changing existing API) -def _get_base_foundation(self) -> Optional[PromptComponent]: - return self.get_component("base_foundation") - -def _get_condition_component(self, category: str) -> Optional[PromptComponent]: - name = _map_condition_category_to_component_name(category) - return self.get_component(name) - -def _get_personalization_component(self, preferences: dict, communication_style: str) -> Optional[PromptComponent]: - name = _choose_personalization_component(preferences, communication_style) - return self.get_component(name) - -def _get_safety_component(self, risk_factors: list[str]) -> Optional[PromptComponent]: - name = _choose_safety_component(risk_factors) - return self.get_component(name) - -def _get_progress_component(self, stage: str) -> Optional[PromptComponent]: - name = _choose_progress_component(stage) - return self.get_component(name) - -MedicalComponentLibrary.get_base_foundation = _get_base_foundation -MedicalComponentLibrary.get_condition_component = _get_condition_component -MedicalComponentLibrary.get_personalization_component = _get_personalization_component -MedicalComponentLibrary.get_safety_component = _get_safety_component -MedicalComponentLibrary.get_progress_component = _get_progress_component \ No newline at end of file diff --git a/src/prompts/spiritual_prompts.py b/src/prompts/spiritual_prompts.py deleted file mode 100644 index f0bdae22fc1256268d201a5861a55b161737beb6..0000000000000000000000000000000000000000 --- a/src/prompts/spiritual_prompts.py +++ /dev/null @@ -1,546 +0,0 @@ -# spiritual_prompts.py -""" -Spiritual Health Assessment Tool - LLM Prompts - -Following existing prompt patterns from prompts.py and classifier.py -""" - -from typing import Dict, List - - -def SYSTEM_PROMPT_SPIRITUAL_ANALYZER() -> str: - """ - System prompt for spiritual distress analyzer. - Following the pattern from existing system prompts. - """ - return """You are an expert clinical spiritual care analyst specializing in identifying emotional and spiritual distress indicators in patient conversations. - -Your role is to: -1. Analyze patient messages for signs of emotional and spiritual distress -2. Classify distress severity as red flag (severe/urgent), yellow flag (potential/ambiguous), or no flag (no concern) -3. Identify specific distress indicators and categories based on clinical definitions -4. Provide clear reasoning for your classification - -CLASSIFICATION GUIDELINES: - -RED FLAG (Severe Distress - Immediate Referral): -- Explicit statements of severe emotional distress -- Persistent, uncontrollable emotions (e.g., "I am angry all the time", "I am crying all the time") -- Expressions of hopelessness or meaninglessness -- Clear indicators requiring immediate spiritual care intervention - -YELLOW FLAG (Potential Distress - Further Assessment Needed): -- Ambiguous or mild distress indicators -- Recent changes in emotional state -- Concerns that need clarification -- When uncertain, default to yellow flag for safety - -NO FLAG (No Spiritual Care Concern): -- General health questions without emotional distress -- Routine medical inquiries -- No indicators of spiritual or emotional distress - -CONSERVATIVE APPROACH: -- When uncertain between classifications, escalate to the higher severity level -- Default to yellow flag when indicators are ambiguous -- Prioritize patient safety and appropriate referral - -OUTPUT FORMAT: -Respond ONLY with valid JSON in this exact format: -{ - "flag_level": "red|yellow|none", - "indicators": ["indicator1", "indicator2"], - "categories": ["category1", "category2"], - "confidence": 0.0-1.0, - "reasoning": "detailed explanation of classification decision" -} - -CRITICAL: Your response must be valid JSON only. Do not include any text before or after the JSON. - -IMPORTANT: Always respond in the same language as the patient's message (English, Ukrainian, etc.).""" - - -def PROMPT_SPIRITUAL_ANALYZER(patient_message: str, definitions: Dict) -> str: - """ - User prompt for spiritual distress analysis. - - Args: - patient_message: The patient's message to analyze - definitions: Dictionary of spiritual distress definitions - - Returns: - Formatted prompt string - """ - # Format definitions for the prompt - definitions_text = "\n\n".join([ - f"**{category.upper()}**\n" - f"Definition: {data['definition']}\n" - f"Red Flag Examples: {', '.join(data['red_flag_examples'])}\n" - f"Yellow Flag Examples: {', '.join(data['yellow_flag_examples'])}\n" - f"Keywords: {', '.join(data['keywords'])}" - for category, data in definitions.items() - ]) - - return f"""SPIRITUAL DISTRESS DEFINITIONS: - -{definitions_text} - -PATIENT MESSAGE TO ANALYZE: -"{patient_message}" - -TASK: -Analyze the patient message for spiritual and emotional distress indicators based on the definitions above. - -1. Identify any distress indicators present in the message -2. Classify the severity level (red flag, yellow flag, or no flag) -3. List the specific categories that apply -4. Provide your confidence level (0.0 to 1.0) -5. Explain your reasoning clearly - -Remember: -- Use the definitions and examples as your guide -- Be conservative: when uncertain, escalate to yellow flag -- Consider the intensity and persistence of expressed emotions -- Look for explicit statements vs. mild concerns - -Respond with JSON only.""" - - - -def SYSTEM_PROMPT_REFERRAL_GENERATOR() -> str: - """ - System prompt for referral message generator. - - Ensures professional, compassionate, multi-faith inclusive language. - Following the pattern from existing system prompts. - """ - return """You are an expert clinical communication specialist who creates professional referral messages for spiritual care teams. - -Your role is to: -1. Generate clear, professional referral messages for chaplains and spiritual care providers -2. Communicate patient concerns and distress indicators effectively -3. Use compassionate, respectful language appropriate for clinical settings -4. Maintain multi-faith sensitivity and inclusive language - -LANGUAGE GUIDELINES: - -MULTI-FAITH INCLUSIVE: -- Use non-denominational, inclusive language -- Avoid religious assumptions or specific faith terminology -- Respect diverse spiritual backgrounds (Christian, Buddhist, Muslim, Jewish, secular, etc.) -- Use terms like "spiritual care," "spiritual support," "chaplaincy services" -- Avoid: "prayer," "God," "salvation," "blessing" unless patient specifically mentioned them - -PROFESSIONAL TONE: -- Clear, concise, and respectful -- Compassionate without being overly emotional -- Clinical but warm -- Action-oriented for the spiritual care team - -CONTENT REQUIREMENTS: -- Include patient's expressed concerns (use direct quotes when appropriate) -- List specific distress indicators detected -- Provide relevant conversation context -- Explain why spiritual care referral is recommended -- Be specific about the nature of distress (emotional, existential, relational, etc.) - -MESSAGE STRUCTURE: -1. Opening: Brief statement of referral purpose -2. Patient Concerns: What the patient expressed -3. Distress Indicators: Specific signs detected -4. Context: Relevant background or conversation details -5. Recommendation: Clear next steps for spiritual care team - -CRITICAL: Generate a complete, professional referral message. Do not include JSON or structured data - write a natural, flowing message that a chaplain would find helpful and actionable. - -IMPORTANT: Always respond in the same language as the patient's message (English, Ukrainian, etc.).""" - - -def PROMPT_REFERRAL_GENERATOR( - patient_message: str, - indicators: List[str], - categories: List[str], - reasoning: str, - conversation_history: List[str] = None -) -> str: - """ - User prompt for referral message generation. - - Args: - patient_message: The patient's original message - indicators: List of detected distress indicators - categories: List of distress categories - reasoning: Classification reasoning - conversation_history: Optional conversation history for context - - Returns: - Formatted prompt string - """ - # Format indicators - indicators_text = "\n".join([f"- {indicator}" for indicator in indicators]) - - # Format categories - categories_text = ", ".join(categories) if categories else "General distress" - - # Format conversation history if available - history_text = "" - if conversation_history and len(conversation_history) > 0: - recent_history = conversation_history[-3:] # Last 3 messages - history_text = "\n\nRECENT CONVERSATION CONTEXT:\n" + "\n".join([ - f"- {msg}" for msg in recent_history - ]) - - return f"""PATIENT MESSAGE: -"{patient_message}" - -DETECTED DISTRESS INDICATORS: -{indicators_text} - -DISTRESS CATEGORIES: -{categories_text} - -ANALYSIS REASONING: -{reasoning} -{history_text} - -TASK: -Generate a professional referral message for the spiritual care team (chaplains, spiritual counselors) about this patient. - -The message should: -1. Clearly communicate the patient's concerns and emotional/spiritual distress -2. Include specific indicators that prompted the referral -3. Provide relevant context from the conversation -4. Use professional, compassionate language -5. Be multi-faith inclusive (avoid denominational or religious assumptions) -6. Be actionable for the spiritual care team - -Write a complete referral message that a chaplain would find helpful for understanding the patient's needs and providing appropriate spiritual support. - -IMPORTANT: -- Use inclusive language that respects all faith backgrounds -- If the patient mentioned specific religious concerns, include them in the referral -- Focus on the patient's expressed needs and emotional state -- Be specific about what kind of spiritual support might be helpful""" - - -def SYSTEM_PROMPT_CLARIFYING_QUESTIONS() -> str: - """ - System prompt for clarifying question generator. - - Ensures empathetic, open-ended questions that avoid religious assumptions. - Following the pattern from existing system prompts. - """ - return """You are an expert clinical interviewer specializing in spiritual and emotional health assessment. - -Your role is to: -1. Generate empathetic, open-ended clarifying questions for patients with potential spiritual distress -2. Help gather more information when initial indicators are ambiguous (yellow flag cases) -3. Create questions that encourage patient expression without making assumptions -4. Maintain multi-faith sensitivity and inclusive language - -QUESTION GUIDELINES: - -EMPATHETIC AND OPEN-ENDED: -- Use warm, compassionate language -- Ask questions that invite elaboration -- Avoid yes/no questions when possible -- Show genuine interest in understanding the patient's experience -- Examples: "Can you tell me more about...", "How has this been affecting you?", "What does this mean for you?" - -CLINICALLY APPROPRIATE: -- Focus on understanding the patient's emotional and spiritual state -- Explore the intensity, duration, and impact of concerns -- Clarify ambiguous statements -- Assess the level of distress -- Avoid leading questions - -MULTI-FAITH SENSITIVE: -- Do NOT make assumptions about religious beliefs -- Avoid denominational or faith-specific language -- Use inclusive terms like "spiritual," "meaningful," "values," "beliefs" -- Do NOT use: "prayer," "God," "church," "faith," "salvation" unless patient mentioned them first -- Respect diverse backgrounds: Christian, Buddhist, Muslim, Jewish, Hindu, secular, atheist, etc. - -NON-ASSUMPTIVE: -- Don't assume the patient has religious beliefs -- Don't assume the patient wants spiritual care -- Don't assume the nature of their distress -- Let the patient define their own experience -- Examples of what NOT to say: "How can we support your faith?", "Would you like to pray?", "What does God mean to you?" - -QUESTION LIMITS: -- Generate 2-3 questions maximum -- Prioritize the most important clarifications -- Keep questions concise and focused -- Each question should serve a specific assessment purpose - -OUTPUT FORMAT: -Respond with a JSON array of questions: -{ - "questions": [ - "Question 1 text here?", - "Question 2 text here?", - "Question 3 text here?" - ] -} - -CRITICAL: Your response must be valid JSON only. Do not include any text before or after the JSON. - -IMPORTANT: Always respond in the same language as the patient's message (English, Ukrainian, etc.).""" - - -def PROMPT_CLARIFYING_QUESTIONS( - patient_message: str, - indicators: List[str], - categories: List[str], - reasoning: str -) -> str: - """ - User prompt for clarifying question generation. - - Args: - patient_message: The patient's original message - indicators: List of detected distress indicators - categories: List of distress categories - reasoning: Classification reasoning - - Returns: - Formatted prompt string - """ - # Format indicators - indicators_text = "\n".join([f"- {indicator}" for indicator in indicators]) - - # Format categories - categories_text = ", ".join(categories) if categories else "General distress" - - return f"""PATIENT MESSAGE: -"{patient_message}" - -DETECTED INDICATORS (AMBIGUOUS): -{indicators_text} - -DISTRESS CATEGORIES: -{categories_text} - -ANALYSIS REASONING: -{reasoning} - -SITUATION: -This case has been classified as a YELLOW FLAG, meaning there are potential indicators of spiritual or emotional distress, but they are ambiguous and require further assessment. We need to gather more information to determine if this patient would benefit from spiritual care services. - -TASK: -Generate 2-3 empathetic, open-ended clarifying questions to help assess this patient's spiritual and emotional needs. - -The questions should: -1. Help clarify the ambiguous indicators detected -2. Explore the intensity and impact of the patient's concerns -3. Assess whether spiritual care referral is appropriate -4. Be warm, compassionate, and clinically appropriate -5. Avoid making assumptions about the patient's religious beliefs or spiritual practices -6. Use inclusive, non-denominational language - -IMPORTANT: -- Do NOT assume the patient has religious beliefs -- Do NOT use faith-specific language (prayer, God, church, etc.) unless the patient mentioned it -- Focus on understanding their emotional state and what would be helpful for them -- Keep questions open-ended to encourage patient expression -- Limit to 2-3 questions maximum - -Respond with JSON only.""" - - -def SYSTEM_PROMPT_REEVALUATION() -> str: - """ - System prompt for re-evaluation with follow-up answers. - - This is used when a yellow flag case has been clarified with follow-up questions. - The re-evaluation must result in either red flag or no flag (no yellow flags allowed). - """ - return """You are an expert clinical spiritual care analyst specializing in identifying emotional and spiritual distress indicators in patient conversations. - -Your role is to RE-EVALUATE a patient case that was initially classified as a YELLOW FLAG (ambiguous) after receiving follow-up information. - -CRITICAL RE-EVALUATION RULES: - -1. You MUST classify as either RED FLAG or NO FLAG -2. You CANNOT classify as YELLOW FLAG in re-evaluation -3. The follow-up answers should provide clarity to resolve the ambiguity - -CLASSIFICATION GUIDELINES: - -RED FLAG (Severe Distress - Immediate Referral): -- Follow-up confirms severe emotional or spiritual distress -- Patient expresses persistent, uncontrollable emotions -- Indicators of hopelessness, meaninglessness, or crisis -- Clear need for immediate spiritual care intervention -- When in doubt between red and no flag, escalate to RED FLAG for safety - -NO FLAG (No Spiritual Care Concern): -- Follow-up clarifies that concerns are mild or resolved -- Patient indicates they are coping well -- No significant emotional or spiritual distress present -- Routine concerns without need for spiritual care referral - -CONSERVATIVE APPROACH: -- When uncertain, escalate to RED FLAG for patient safety -- Consider the totality of information (original message + follow-up) -- Look for patterns of distress across the conversation -- Prioritize appropriate referral over under-referral - -OUTPUT FORMAT: -Respond ONLY with valid JSON in this exact format: -{ - "flag_level": "red|none", - "indicators": ["indicator1", "indicator2"], - "categories": ["category1", "category2"], - "confidence": 0.0-1.0, - "reasoning": "detailed explanation of re-evaluation decision based on follow-up information" -} - -CRITICAL: -- Your response must be valid JSON only -- Always respond in the same language as the patient's messages (English, Ukrainian, etc.) -- flag_level MUST be either "red" or "none" (NOT "yellow") -- Do not include any text before or after the JSON""" - - -def PROMPT_REEVALUATION( - original_message: str, - original_classification: Dict, - followup_questions: List[str], - followup_answers: List[str], - definitions: Dict -) -> str: - """ - User prompt for re-evaluation with follow-up information. - - Args: - original_message: The patient's original message - original_classification: The original yellow flag classification data - followup_questions: List of clarifying questions that were asked - followup_answers: List of patient's answers to the questions - definitions: Dictionary of spiritual distress definitions - - Returns: - Formatted prompt string - """ - # Format definitions for the prompt - definitions_text = "\n\n".join([ - f"**{category.upper()}**\n" - f"Definition: {data['definition']}\n" - f"Red Flag Examples: {', '.join(data['red_flag_examples'])}\n" - f"Yellow Flag Examples: {', '.join(data['yellow_flag_examples'])}\n" - f"Keywords: {', '.join(data['keywords'])}" - for category, data in definitions.items() - ]) - - # Format original classification - original_indicators = ", ".join(original_classification.get("indicators", [])) - original_reasoning = original_classification.get("reasoning", "") - - # Format Q&A pairs - qa_pairs = [] - for i, (question, answer) in enumerate(zip(followup_questions, followup_answers), 1): - qa_pairs.append(f"Q{i}: {question}\nA{i}: {answer}") - qa_text = "\n\n".join(qa_pairs) - - return f"""SPIRITUAL DISTRESS DEFINITIONS: - -{definitions_text} - -ORIGINAL PATIENT MESSAGE: -"{original_message}" - -ORIGINAL CLASSIFICATION (YELLOW FLAG): -Indicators: {original_indicators} -Reasoning: {original_reasoning} - -FOLLOW-UP QUESTIONS AND ANSWERS: -{qa_text} - -TASK: -Re-evaluate this case based on the complete information (original message + follow-up answers). - -You must now make a DEFINITIVE classification: -- RED FLAG: If the follow-up confirms significant spiritual/emotional distress requiring referral -- NO FLAG: If the follow-up clarifies that no spiritual care referral is needed - -CRITICAL RULES: -1. You MUST classify as either "red" or "none" (NOT "yellow") -2. Consider the totality of information from both the original message and follow-up -3. When uncertain, escalate to RED FLAG for patient safety -4. Provide clear reasoning based on how the follow-up information resolved the ambiguity - -Analyze the complete conversation and respond with JSON only.""" - - -def SYSTEM_PROMPT_SPIRITUAL_DIALOG() -> str: - """ - System prompt for spiritual dialog response generation. - Used when no distress is detected to generate a natural, supportive response. - """ - return """You are a compassionate spiritual care assistant who provides supportive, empathetic responses to patients. - -Your role is to: -1. Engage naturally with patients about spiritual and emotional topics -2. Provide warm, supportive responses without being preachy or assumptive -3. Respect diverse spiritual backgrounds and beliefs -4. Offer to discuss spiritual topics if the patient is interested - -RESPONSE GUIDELINES: - -TONE: -- Warm, friendly, and conversational -- Non-judgmental and accepting -- Supportive without being intrusive -- Natural and human-like - -MULTI-FAITH SENSITIVITY: -- Do NOT assume any particular religious beliefs -- Use inclusive language (spiritual, meaningful, values, beliefs) -- Respect all backgrounds: religious, secular, atheist, agnostic -- Let the patient lead the conversation about their beliefs - -CONTENT: -- Respond directly to what the patient said -- Ask open-ended questions to understand their interests -- Offer support without pushing spiritual care services -- Keep responses concise and natural - -IMPORTANT: Always respond in the same language as the patient's message (English, Ukrainian, etc.).""" - - -def PROMPT_SPIRITUAL_DIALOG( - patient_message: str, - conversation_history: List[str] = None -) -> str: - """ - User prompt for generating natural spiritual dialog response. - - Args: - patient_message: The patient's message - conversation_history: Optional conversation history - - Returns: - Formatted prompt string - """ - history_text = "" - if conversation_history and len(conversation_history) > 0: - recent_history = conversation_history[-3:] - history_text = "\nRECENT CONVERSATION:\n" + "\n".join([f"- {msg}" for msg in recent_history]) - - return f"""PATIENT MESSAGE: -"{patient_message}" -{history_text} - -TASK: -Generate a natural, supportive response to the patient's message. The patient has not shown signs of spiritual distress, so this is a normal conversational exchange about spiritual or emotional topics. - -Your response should: -1. Directly address what the patient said -2. Be warm and conversational -3. Show genuine interest in their perspective -4. Offer to continue the conversation if they'd like -5. Be in the same language as the patient's message - -Keep your response concise (2-4 sentences) and natural.""" diff --git a/src/prompts/types.py b/src/prompts/types.py deleted file mode 100644 index 4510ff29ebdb87a9fe362b27775b78c98207c5d6..0000000000000000000000000000000000000000 --- a/src/prompts/types.py +++ /dev/null @@ -1,223 +0,0 @@ -# prompt_types.py - Core Data Structures for Dynamic Prompt Composition -""" -Strategic Foundation: Shared data structures and type definitions - -Design Philosophy: "Clear contracts enable reliable composition" -- Well-defined interfaces reduce cognitive load -- Type safety prevents runtime composition errors -- Shared contracts enable independent component development -- Future adaptability through extensible data structures -""" - -from typing import List, Dict, Any, Optional, Union -from dataclasses import dataclass -from datetime import datetime -from enum import Enum - -class SafetyLevel(Enum): - """Medical safety levels for prompt components""" - STANDARD = "standard" - ENHANCED = "enhanced" - MAXIMUM = "maximum" - -class ComponentCategory(Enum): - """Prompt component categories for organization""" - MEDICAL_SAFETY = "medical_safety" - CONDITION_SPECIFIC = "condition_specific" - COMMUNICATION_STYLE = "communication_style" - PROGRESS_MOTIVATION = "progress_motivation" - EDUCATIONAL_CONTENT = "educational_content" - -@dataclass -class PromptComponent: - """ - Core data structure for modular prompt components - - Design Principles: - - Self-describing: Each component contains its own metadata - - Medical safety: Explicit safety classification and requirements - - Auditable: Clear indication of medical conditions and contraindications - - Composable: Priority-based assembly with conflict resolution - """ - name: str - content: str - category: ComponentCategory - priority: int # Higher = more important (1000 = critical safety) - medical_safety: bool # True if contains critical safety information - conditions: List[str] # Medical conditions this component addresses - contraindications: List[str] = None # Conditions where this component should not be used - safety_level: SafetyLevel = SafetyLevel.STANDARD - last_reviewed: datetime = None # For medical professional oversight - evidence_base: str = "" # Reference to medical literature/guidelines - - def __post_init__(self): - """Initialize default values and validate component""" - if self.contraindications is None: - self.contraindications = [] - - if self.last_reviewed is None: - self.last_reviewed = datetime.now() - - # Validate medical safety components have appropriate priority - if self.medical_safety and self.priority < 800: - raise ValueError(f"Medical safety component {self.name} must have priority >= 800") - -@dataclass -class ClassificationContext: - """ - Comprehensive context for LLM-based prompt classification - - Strategic Design: Encapsulate all relevant decision-making information - - Patient communication context and intent - - Complete medical background for safety assessment - - Lifestyle progression for personalization optimization - - Session metadata for continuous improvement - """ - patient_request: str - clinical_background: Dict[str, Any] - lifestyle_profile: Dict[str, Any] - session_metadata: Dict[str, Any] = None - - def __post_init__(self): - """Initialize metadata and validate context""" - if self.session_metadata is None: - self.session_metadata = { - 'timestamp': datetime.now().isoformat(), - 'session_type': 'lifestyle_coaching', - 'priority_level': 'standard' - } - -@dataclass -class PromptCompositionSpec: - """ - LLM classification result specifying optimal prompt composition - - Purpose: Bridge between intelligent analysis and deterministic assembly - - Clear specification of personalization requirements - - Medical emphasis areas for safety-first component selection - - Communication optimization for patient engagement - - Audit trail for transparency and continuous improvement - """ - session_focus: str # Primary coaching area - medical_emphasis: List[str] # Conditions requiring special attention - communication_style: str # Optimal patient communication approach - component_priorities: Dict[str, Any] # Specific component selection criteria - safety_level: SafetyLevel # Required safety protocol level - reasoning: str = "" # LLM explanation for transparency - confidence_score: float = 1.0 # Classification confidence (0.0-1.0) - - def to_dict(self) -> Dict[str, Any]: - """Convert to dictionary for caching and audit logging""" - return { - 'session_focus': self.session_focus, - 'medical_emphasis': self.medical_emphasis, - 'communication_style': self.communication_style, - 'component_priorities': self.component_priorities, - 'safety_level': self.safety_level.value, - 'reasoning': self.reasoning, - 'confidence_score': self.confidence_score, - 'timestamp': datetime.now().isoformat() - } - - @classmethod - def from_dict(cls, data: Dict[str, Any]) -> 'PromptCompositionSpec': - """Create from dictionary (for cache retrieval)""" - return cls( - session_focus=data.get('session_focus', 'general_wellness'), - medical_emphasis=data.get('medical_emphasis', []), - communication_style=data.get('communication_style', 'friendly'), - component_priorities=data.get('component_priorities', {}), - safety_level=SafetyLevel(data.get('safety_level', 'standard')), - reasoning=data.get('reasoning', ''), - confidence_score=data.get('confidence_score', 1.0) - ) - -@dataclass -class AssemblyResult: - """ - Complete result of dynamic prompt assembly process - - Strategic Value: Comprehensive audit trail and quality assurance - - Final assembled prompt ready for LLM consumption - - Complete component usage tracking for medical review - - Safety validation results for compliance monitoring - - Assembly diagnostics for continuous system improvement - """ - assembled_prompt: str - components_used: List[str] - safety_validated: bool - assembly_notes: List[str] - performance_metrics: Dict[str, Any] = None - medical_review_required: bool = False - - def __post_init__(self): - """Initialize performance tracking""" - if self.performance_metrics is None: - self.performance_metrics = { - 'assembly_time_ms': 0, - 'component_count': len(self.components_used), - 'safety_checks_performed': 0, - 'cache_hit': False - } - -class MedicalSafetyViolationError(Exception): - """ - Custom exception for medical safety validation failures - - Critical Design: Explicit handling of medical safety issues - - Clear distinction between technical and medical errors - - Mandatory handling prevents safety protocol bypass - - Detailed error context for medical professional review - """ - def __init__(self, message: str, component: str = None, patient_conditions: List[str] = None): - self.component = component - self.patient_conditions = patient_conditions or [] - super().__init__(f"Medical Safety Violation: {message}") - -# === CONFIGURATION AND CONSTANTS === - -class DynamicPromptConfig: - """ - Centralized configuration for dynamic prompt composition system - - Design Strategy: Environment-driven configuration for operational flexibility - - Development vs production parameter optimization - - Medical safety threshold configuration - - Performance tuning parameters - - Feature toggle management for gradual rollout - """ - - # Core functionality toggles - ENABLED = False # Master switch for dynamic composition - FALLBACK_ENABLED = True # Always allow fallback to static prompts - - # Performance parameters - CLASSIFICATION_TIMEOUT_MS = 5000 # LLM classification timeout - CACHE_ENABLED = True # Enable classification caching - CACHE_TTL_HOURS = 24 # Cache time-to-live - MAX_CACHE_SIZE = 1000 # Maximum cache entries - - # Medical safety parameters - REQUIRE_SAFETY_VALIDATION = True # Mandatory safety validation - MIN_SAFETY_COMPONENTS = 1 # Minimum safety components required - SAFETY_VALIDATION_TIMEOUT_MS = 1000 # Safety check timeout - - # Quality assurance - DEBUG_MODE = False # Detailed logging for development - PERFORMANCE_MONITORING = True # Track composition performance - MEDICAL_REVIEW_LOGGING = True # Log medical professional reviews - - @classmethod - def from_environment(cls): - """Load configuration from environment variables""" - import os - - cls.ENABLED = os.getenv("ENABLE_DYNAMIC_PROMPTS", "false").lower() == "true" - cls.DEBUG_MODE = os.getenv("DEBUG_DYNAMIC_PROMPTS", "false").lower() == "true" - cls.CLASSIFICATION_TIMEOUT_MS = int(os.getenv("DYNAMIC_CLASSIFICATION_TIMEOUT", "5000")) - cls.CACHE_TTL_HOURS = int(os.getenv("DYNAMIC_CACHE_TTL_HOURS", "24")) - - return cls - -# Initialize configuration from environment -DynamicPromptConfig.from_environment() \ No newline at end of file diff --git a/src/storage/feedback_store.py b/src/storage/feedback_store.py deleted file mode 100644 index 788a0ab497a5d91204a94667534a20a60754e5a5..0000000000000000000000000000000000000000 --- a/src/storage/feedback_store.py +++ /dev/null @@ -1,646 +0,0 @@ -# feedback_store.py -""" -Feedback Storage System for Spiritual Health Assessment Tool - -Adapts TestingDataManager pattern for storing provider feedback on AI assessments. -Follows existing patterns for JSON storage, atomic writes, and CSV export. - -Requirements: 6.1, 6.2, 6.3, 6.4, 6.5, 6.6, 6.7 -""" - -import os -import json -import csv -import uuid -import logging -from datetime import datetime -from typing import Dict, List, Optional, Tuple -from dataclasses import asdict - -from src.core.spiritual_classes import ( - PatientInput, - DistressClassification, - ReferralMessage, - ProviderFeedback -) - - -class FeedbackStore: - """ - Manages storage and retrieval of provider feedback on AI assessments. - - Follows TestingDataManager pattern: - - JSON file storage in testing_results/ directory - - Atomic writes with temp files - - CSV export functionality - - Analytics and metrics - - Requirements: 6.1, 6.2, 6.3, 6.4, 6.5, 6.6, 6.7 - """ - - def __init__(self, storage_dir: str = "testing_results/spiritual_feedback"): - """ - Initialize the feedback store. - - Args: - storage_dir: Directory for storing feedback records - """ - self.storage_dir = storage_dir - self.ensure_storage_directory() - logging.info(f"FeedbackStore initialized with directory: {storage_dir}") - - def ensure_storage_directory(self): - """ - Create storage directories if they don't exist. - - Following TestingDataManager pattern for directory structure. - """ - if not os.path.exists(self.storage_dir): - os.makedirs(self.storage_dir) - logging.info(f"Created storage directory: {self.storage_dir}") - - # Create subdirectories - subdirs = ["assessments", "exports", "archives"] - for subdir in subdirs: - path = os.path.join(self.storage_dir, subdir) - if not os.path.exists(path): - os.makedirs(path) - logging.debug(f"Created subdirectory: {path}") - - def save_feedback( - self, - patient_input: PatientInput, - classification: DistressClassification, - referral_message: Optional[ReferralMessage], - provider_feedback: ProviderFeedback - ) -> str: - """ - Save a complete feedback record with unique ID. - - Following TestingDataManager pattern for save operations. - Uses atomic writes with temp files for safety. - Enhanced with error handling (Requirement 10.5). - - Args: - patient_input: Original patient input - classification: AI classification result - referral_message: Generated referral message (if applicable) - provider_feedback: Provider's feedback on the assessment - - Returns: - assessment_id: Unique identifier for the saved record - - Requirement 6.1: Store feedback with unique identifier - Requirements 6.2-6.6: Store all required fields - Requirement 10.5: Error handling for storage operations - """ - # Validate inputs (Requirement 10.5) - if not patient_input: - raise ValueError("patient_input cannot be None") - if not classification: - raise ValueError("classification cannot be None") - if not provider_feedback: - raise ValueError("provider_feedback cannot be None") - - try: - # Ensure storage directory exists (Requirement 10.5) - self.ensure_storage_directory() - - # Generate unique assessment ID (Requirement 6.1) - assessment_id = str(uuid.uuid4()) - - # Build complete feedback record (Requirements 6.2-6.6) - feedback_record = { - "assessment_id": assessment_id, - "timestamp": datetime.now().isoformat(), # Requirement 6.6 - "patient_input": { - "message": patient_input.message if patient_input.message else "", - "timestamp": patient_input.timestamp if patient_input.timestamp else "", - "conversation_history": patient_input.conversation_history if patient_input.conversation_history else [] - }, # Requirement 6.2 - "classification": { - "flag_level": classification.flag_level if classification.flag_level else "yellow", - "indicators": classification.indicators if classification.indicators else [], - "categories": classification.categories if classification.categories else [], - "confidence": classification.confidence if classification.confidence is not None else 0.0, - "reasoning": classification.reasoning if classification.reasoning else "", - "timestamp": classification.timestamp if classification.timestamp else "" - }, # Requirement 6.3 - "referral_message": { - "patient_concerns": referral_message.patient_concerns if referral_message else "", - "distress_indicators": referral_message.distress_indicators if referral_message else [], - "context": referral_message.context if referral_message else "", - "message_text": referral_message.message_text if referral_message else "", - "timestamp": referral_message.timestamp if referral_message else "" - } if referral_message else None, - "provider_feedback": { - "provider_id": provider_feedback.provider_id if provider_feedback.provider_id else "unknown", - "agrees_with_classification": provider_feedback.agrees_with_classification, # Requirement 6.4 - "agrees_with_referral": provider_feedback.agrees_with_referral, - "comments": provider_feedback.comments if provider_feedback.comments else "", # Requirement 6.5 - "timestamp": provider_feedback.timestamp if provider_feedback.timestamp else datetime.now().isoformat() - } - } - - # Save to file with atomic write (following TestingDataManager pattern) - filename = f"assessment_{assessment_id}.json" - filepath = os.path.join(self.storage_dir, "assessments", filename) - - # Atomic write: write to temp file first, then rename (Requirement 10.5) - temp_filepath = filepath + ".tmp" - - try: - with open(temp_filepath, 'w', encoding='utf-8') as f: - json.dump(feedback_record, f, indent=2, ensure_ascii=False) - - # Atomic rename (Requirement 10.5) - os.replace(temp_filepath, filepath) - - except OSError as e: - # Handle disk full, permission denied, etc. (Requirement 10.5) - if "No space left on device" in str(e): - logging.error(f"Disk full error: {e}") - # Clean up temp file if it exists - if os.path.exists(temp_filepath): - try: - os.remove(temp_filepath) - except: - pass - raise IOError("Storage is full. Cannot save feedback.") from e - elif "Permission denied" in str(e): - logging.error(f"Permission error: {e}") - # Clean up temp file if it exists - if os.path.exists(temp_filepath): - try: - os.remove(temp_filepath) - except: - pass - raise IOError("Permission denied. Cannot save feedback.") from e - else: - logging.error(f"OS error during save: {e}") - # Clean up temp file if it exists - if os.path.exists(temp_filepath): - try: - os.remove(temp_filepath) - except: - pass - raise - - logging.info(f"Saved feedback record with ID: {assessment_id}") - - return assessment_id - - except (ValueError, IOError) as e: - # Re-raise validation and IO errors - logging.error(f"Error saving feedback: {e}") - raise - except Exception as e: - # Catch-all for unexpected errors (Requirement 10.5) - logging.error(f"Unexpected error saving feedback: {e}", exc_info=True) - raise IOError(f"Failed to save feedback: {str(e)}") from e - - def get_feedback_by_id(self, assessment_id: str) -> Optional[Dict]: - """ - Retrieve a feedback record by its unique ID. - - Args: - assessment_id: Unique identifier of the assessment - - Returns: - Feedback record dictionary or None if not found - """ - try: - filename = f"assessment_{assessment_id}.json" - filepath = os.path.join(self.storage_dir, "assessments", filename) - - if not os.path.exists(filepath): - logging.warning(f"Feedback record not found: {assessment_id}") - return None - - with open(filepath, 'r', encoding='utf-8') as f: - feedback_record = json.load(f) - - logging.debug(f"Retrieved feedback record: {assessment_id}") - return feedback_record - - except Exception as e: - logging.error(f"Error retrieving feedback {assessment_id}: {e}") - return None - - def get_all_feedback(self) -> List[Dict]: - """ - Retrieve all stored feedback records. - - Following TestingDataManager pattern for get_all operations. - - Returns: - List of feedback record dictionaries, sorted by timestamp (newest first) - """ - assessments_dir = os.path.join(self.storage_dir, "assessments") - feedback_records = [] - - try: - for filename in os.listdir(assessments_dir): - if filename.startswith("assessment_") and filename.endswith(".json"): - filepath = os.path.join(assessments_dir, filename) - try: - with open(filepath, 'r', encoding='utf-8') as f: - feedback_record = json.load(f) - feedback_records.append(feedback_record) - except Exception as e: - logging.error(f"Error reading feedback file {filename}: {e}") - - # Sort by timestamp (newest first) - feedback_records.sort( - key=lambda x: x.get('timestamp', ''), - reverse=True - ) - - logging.info(f"Retrieved {len(feedback_records)} feedback records") - return feedback_records - - except Exception as e: - logging.error(f"Error retrieving all feedback: {e}") - return [] - - def export_to_csv(self, output_path: Optional[str] = None) -> str: - """ - Export all feedback records to CSV format. - - Following TestingDataManager export_results_to_csv pattern. - - Args: - output_path: Optional custom output path. If None, generates timestamped filename. - - Returns: - Path to the exported CSV file - - Requirement 6.7: Persist data in structured format (CSV export) - """ - try: - # Get all feedback records - feedback_records = self.get_all_feedback() - - if not feedback_records: - logging.warning("No feedback records to export") - return "" - - # Generate output path if not provided - if output_path is None: - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - filename = f"feedback_export_{timestamp}.csv" - output_path = os.path.join(self.storage_dir, "exports", filename) - - # Define CSV fields - fieldnames = [ - 'assessment_id', - 'timestamp', - 'patient_message', - 'flag_level', - 'indicators', - 'categories', - 'confidence', - 'reasoning', - 'referral_generated', - 'provider_id', - 'agrees_with_classification', - 'agrees_with_referral', - 'provider_comments' - ] - - # Write to CSV - with open(output_path, 'w', newline='', encoding='utf-8') as csvfile: - writer = csv.DictWriter(csvfile, fieldnames=fieldnames) - writer.writeheader() - - for record in feedback_records: - # Flatten the nested structure for CSV - csv_row = { - 'assessment_id': record.get('assessment_id', ''), - 'timestamp': record.get('timestamp', ''), - 'patient_message': record.get('patient_input', {}).get('message', ''), - 'flag_level': record.get('classification', {}).get('flag_level', ''), - 'indicators': ', '.join(record.get('classification', {}).get('indicators', [])), - 'categories': ', '.join(record.get('classification', {}).get('categories', [])), - 'confidence': record.get('classification', {}).get('confidence', 0.0), - 'reasoning': record.get('classification', {}).get('reasoning', ''), - 'referral_generated': 'Yes' if record.get('referral_message') else 'No', - 'provider_id': record.get('provider_feedback', {}).get('provider_id', ''), - 'agrees_with_classification': record.get('provider_feedback', {}).get('agrees_with_classification', False), - 'agrees_with_referral': record.get('provider_feedback', {}).get('agrees_with_referral', False), - 'provider_comments': record.get('provider_feedback', {}).get('comments', '') - } - writer.writerow(csv_row) - - logging.info(f"Exported {len(feedback_records)} records to {output_path}") - return output_path - - except Exception as e: - logging.error(f"Error exporting to CSV: {e}") - raise - - def get_accuracy_metrics(self) -> Dict: - """ - Calculate accuracy metrics from provider feedback. - - Analyzes provider agreement rates and classification accuracy. - - Returns: - Dictionary with accuracy metrics: - { - 'total_assessments': int, - 'classification_agreement_rate': float, - 'referral_agreement_rate': float, - 'red_flag_accuracy': float, - 'yellow_flag_accuracy': float, - 'no_flag_accuracy': float, - 'by_provider': Dict[str, Dict] - } - """ - try: - feedback_records = self.get_all_feedback() - - if not feedback_records: - return { - 'total_assessments': 0, - 'classification_agreement_rate': 0.0, - 'referral_agreement_rate': 0.0, - 'red_flag_accuracy': 0.0, - 'yellow_flag_accuracy': 0.0, - 'no_flag_accuracy': 0.0, - 'by_provider': {} - } - - # Initialize counters - total_assessments = len(feedback_records) - classification_agreements = 0 - referral_agreements = 0 - referral_count = 0 - - # Flag-level accuracy - flag_counts = {'red': 0, 'yellow': 0, 'none': 0} - flag_agreements = {'red': 0, 'yellow': 0, 'none': 0} - - # Provider-specific metrics - provider_metrics = {} - - for record in feedback_records: - classification = record.get('classification', {}) - provider_feedback = record.get('provider_feedback', {}) - - flag_level = classification.get('flag_level', '') - agrees_classification = provider_feedback.get('agrees_with_classification', False) - agrees_referral = provider_feedback.get('agrees_with_referral', False) - provider_id = provider_feedback.get('provider_id', 'unknown') - - # Overall agreement - if agrees_classification: - classification_agreements += 1 - - # Referral agreement (only count if referral was generated) - if record.get('referral_message'): - referral_count += 1 - if agrees_referral: - referral_agreements += 1 - - # Flag-level accuracy - if flag_level in flag_counts: - flag_counts[flag_level] += 1 - if agrees_classification: - flag_agreements[flag_level] += 1 - - # Provider-specific metrics - if provider_id not in provider_metrics: - provider_metrics[provider_id] = { - 'total': 0, - 'classification_agreements': 0, - 'referral_agreements': 0, - 'referrals_reviewed': 0 - } - - provider_metrics[provider_id]['total'] += 1 - if agrees_classification: - provider_metrics[provider_id]['classification_agreements'] += 1 - if record.get('referral_message'): - provider_metrics[provider_id]['referrals_reviewed'] += 1 - if agrees_referral: - provider_metrics[provider_id]['referral_agreements'] += 1 - - # Calculate rates - classification_agreement_rate = ( - classification_agreements / total_assessments - if total_assessments > 0 else 0.0 - ) - - referral_agreement_rate = ( - referral_agreements / referral_count - if referral_count > 0 else 0.0 - ) - - # Calculate flag-level accuracy - red_flag_accuracy = ( - flag_agreements['red'] / flag_counts['red'] - if flag_counts['red'] > 0 else 0.0 - ) - yellow_flag_accuracy = ( - flag_agreements['yellow'] / flag_counts['yellow'] - if flag_counts['yellow'] > 0 else 0.0 - ) - no_flag_accuracy = ( - flag_agreements['none'] / flag_counts['none'] - if flag_counts['none'] > 0 else 0.0 - ) - - # Calculate provider-specific rates - by_provider = {} - for provider_id, metrics in provider_metrics.items(): - by_provider[provider_id] = { - 'total_assessments': metrics['total'], - 'classification_agreement_rate': ( - metrics['classification_agreements'] / metrics['total'] - if metrics['total'] > 0 else 0.0 - ), - 'referral_agreement_rate': ( - metrics['referral_agreements'] / metrics['referrals_reviewed'] - if metrics['referrals_reviewed'] > 0 else 0.0 - ), - 'referrals_reviewed': metrics['referrals_reviewed'] - } - - metrics = { - 'total_assessments': total_assessments, - 'classification_agreement_rate': round(classification_agreement_rate, 3), - 'referral_agreement_rate': round(referral_agreement_rate, 3), - 'red_flag_accuracy': round(red_flag_accuracy, 3), - 'yellow_flag_accuracy': round(yellow_flag_accuracy, 3), - 'no_flag_accuracy': round(no_flag_accuracy, 3), - 'flag_distribution': flag_counts, - 'by_provider': by_provider - } - - logging.info(f"Calculated accuracy metrics: {metrics['classification_agreement_rate']:.1%} agreement") - return metrics - - except Exception as e: - logging.error(f"Error calculating accuracy metrics: {e}") - return { - 'total_assessments': 0, - 'classification_agreement_rate': 0.0, - 'referral_agreement_rate': 0.0, - 'red_flag_accuracy': 0.0, - 'yellow_flag_accuracy': 0.0, - 'no_flag_accuracy': 0.0, - 'by_provider': {} - } - - def delete_feedback(self, assessment_id: str) -> bool: - """ - Delete a feedback record by ID. - - Args: - assessment_id: Unique identifier of the assessment to delete - - Returns: - True if deleted successfully, False otherwise - """ - try: - filename = f"assessment_{assessment_id}.json" - filepath = os.path.join(self.storage_dir, "assessments", filename) - - if not os.path.exists(filepath): - logging.warning(f"Cannot delete - feedback record not found: {assessment_id}") - return False - - os.remove(filepath) - logging.info(f"Deleted feedback record: {assessment_id}") - return True - - except Exception as e: - logging.error(f"Error deleting feedback {assessment_id}: {e}") - return False - - def archive_old_feedback(self, days_old: int = 90) -> int: - """ - Archive feedback records older than specified days. - - Args: - days_old: Number of days after which to archive records - - Returns: - Number of records archived - """ - try: - assessments_dir = os.path.join(self.storage_dir, "assessments") - archives_dir = os.path.join(self.storage_dir, "archives") - - cutoff_date = datetime.now().timestamp() - (days_old * 24 * 60 * 60) - archived_count = 0 - - for filename in os.listdir(assessments_dir): - if filename.startswith("assessment_") and filename.endswith(".json"): - filepath = os.path.join(assessments_dir, filename) - - # Check file modification time - file_mtime = os.path.getmtime(filepath) - - if file_mtime < cutoff_date: - # Move to archives - archive_path = os.path.join(archives_dir, filename) - os.rename(filepath, archive_path) - archived_count += 1 - - logging.info(f"Archived {archived_count} feedback records older than {days_old} days") - return archived_count - - except Exception as e: - logging.error(f"Error archiving old feedback: {e}") - return 0 - - def get_summary_statistics(self) -> Dict: - """ - Generate summary statistics for all feedback records. - - Returns: - Dictionary with summary statistics - """ - try: - feedback_records = self.get_all_feedback() - - if not feedback_records: - return { - 'total_records': 0, - 'date_range': 'N/A', - 'flag_distribution': {}, - 'average_confidence': 0.0, - 'most_common_indicators': [], - 'most_common_categories': [] - } - - # Basic counts - total_records = len(feedback_records) - - # Date range - timestamps = [r.get('timestamp', '') for r in feedback_records if r.get('timestamp')] - date_range = f"{min(timestamps)} to {max(timestamps)}" if timestamps else 'N/A' - - # Flag distribution - flag_distribution = {} - for record in feedback_records: - flag_level = record.get('classification', {}).get('flag_level', 'unknown') - flag_distribution[flag_level] = flag_distribution.get(flag_level, 0) + 1 - - # Average confidence - confidences = [ - record.get('classification', {}).get('confidence', 0.0) - for record in feedback_records - ] - average_confidence = sum(confidences) / len(confidences) if confidences else 0.0 - - # Most common indicators - indicator_counts = {} - for record in feedback_records: - indicators = record.get('classification', {}).get('indicators', []) - for indicator in indicators: - indicator_counts[indicator] = indicator_counts.get(indicator, 0) + 1 - - most_common_indicators = sorted( - indicator_counts.items(), - key=lambda x: x[1], - reverse=True - )[:5] - - # Most common categories - category_counts = {} - for record in feedback_records: - categories = record.get('classification', {}).get('categories', []) - for category in categories: - category_counts[category] = category_counts.get(category, 0) + 1 - - most_common_categories = sorted( - category_counts.items(), - key=lambda x: x[1], - reverse=True - )[:5] - - summary = { - 'total_records': total_records, - 'date_range': date_range, - 'flag_distribution': flag_distribution, - 'average_confidence': round(average_confidence, 3), - 'most_common_indicators': most_common_indicators, - 'most_common_categories': most_common_categories - } - - logging.info(f"Generated summary statistics for {total_records} records") - return summary - - except Exception as e: - logging.error(f"Error generating summary statistics: {e}") - return { - 'total_records': 0, - 'date_range': 'N/A', - 'flag_distribution': {}, - 'average_confidence': 0.0, - 'most_common_indicators': [], - 'most_common_categories': [] - } diff --git a/src/utils/README.md b/src/utils/README.md deleted file mode 100644 index 662ec5abaa03e02eeb26ee0573864dd332823403..0000000000000000000000000000000000000000 --- a/src/utils/README.md +++ /dev/null @@ -1,19 +0,0 @@ -# 🔧 Утиліти - -Ця директорія містить допоміжні утиліти, які використовуються в різних частинах проекту. - -## 📋 Файли - -| Файл | Опис | -|------|------| -| `file_utils.py` | Утиліти для роботи з файлами | - -## 🚀 Використання - -```python -from src.utils.file_utils import ... -``` - -## 📝 Примітка - -Ці утиліти використовуються внутрішньо іншими модулями проекту. diff --git a/src/utils/file_utils.py b/src/utils/file_utils.py deleted file mode 100644 index 315559c3500410f816805fbe64b04d873340a599..0000000000000000000000000000000000000000 --- a/src/utils/file_utils.py +++ /dev/null @@ -1,86 +0,0 @@ -# file_utils.py - File handling utilities - -import os -import json -from typing import Tuple, Optional - -class FileHandler: - """Class for handling uploaded files""" - - @staticmethod - def read_uploaded_file(file_input, filename_for_error: str = "file") -> Tuple[Optional[str], Optional[str]]: - """ - Universal method for reading uploaded files from different Gradio versions - - Returns: - Tuple[content, error_message] - content if successful, error_message if error - """ - if file_input is None: - return None, f"❌ File {filename_for_error} not uploaded" - - # Debug information - debug_enabled = os.getenv("LOG_PROMPTS", "false").lower() == "true" - if debug_enabled: - print(f"🔍 Debug {filename_for_error}: type={type(file_input)}, value={repr(file_input)[:100]}...") - - try: - # Try 1: filepath (type="filepath") - if isinstance(file_input, str): - if debug_enabled: - print(f"📁 Reading as filepath: {file_input}") - with open(file_input, 'r', encoding='utf-8') as f: - return f.read(), None - - # Try 2: file-like object with read method - elif hasattr(file_input, 'read'): - if debug_enabled: - print(f"📄 Reading as file-like object") - content = file_input.read() - if isinstance(content, bytes): - content = content.decode('utf-8') - return content, None - - # Try 3: bytes object - elif isinstance(file_input, bytes): - if debug_enabled: - print(f"🔢 Читаємо як bytes object") - return file_input.decode('utf-8'), None - - # Try 4: dict with path (some Gradio versions) - elif isinstance(file_input, dict) and 'name' in file_input: - if debug_enabled: - print(f"📚 Читаємо як dict з name: {file_input['name']}") - with open(file_input['name'], 'r', encoding='utf-8') as f: - return f.read(), None - - # Try 5: dict with other keys - elif isinstance(file_input, dict): - if debug_enabled: - print(f"📖 Dict keys: {list(file_input.keys())}") - for key in ['path', 'file', 'filepath', 'tmp_file']: - if key in file_input: - with open(file_input[key], 'r', encoding='utf-8') as f: - return f.read(), None - return None, f"❌ Не знайдено шлях до файлу в dict для {filename_for_error}" - - else: - return None, f"❌ Непідтримуваний тип файлу для {filename_for_error}: {type(file_input)}" - - except Exception as e: - if debug_enabled: - import traceback - print(f"❌ Exception при читанні {filename_for_error}: {traceback.format_exc()}") - return None, f"❌ Помилка читання {filename_for_error}: {str(e)}" - - @staticmethod - def parse_json_file(content: str, filename: str) -> Tuple[Optional[dict], Optional[str]]: - """ - Парсить JSON контент з обробкою помилок - - Returns: - Tuple[parsed_data, error_message] - """ - try: - return json.loads(content), None - except json.JSONDecodeError as e: - return None, f"❌ Помилка парсингу {filename}: {str(e)}" \ No newline at end of file diff --git a/tests/spiritual/README.md b/tests/spiritual/README.md deleted file mode 100644 index a36212995907f064eb07ee5fc4e2f61f79cf885a..0000000000000000000000000000000000000000 --- a/tests/spiritual/README.md +++ /dev/null @@ -1,323 +0,0 @@ -# 🧪 Тести - Інструмент Оцінки Духовного Здоров'я - -## 📊 Статистика - -- **Загальна кількість тестів:** 145 -- **Статус:** ✅ 145/145 пройдено (100%) -- **Покриття:** 100% для духовних компонентів -- **Фреймворк:** Pytest 8.4.2 - -## 🚀 Запуск Тестів - -### Всі Тести - -```bash -# З кореневої директорії проекту -source venv/bin/activate -pytest tests/spiritual/ -v -``` - -### Конкретні Категорії - -```bash -# Тести класів даних -pytest tests/spiritual/test_spiritual_classes.py -v - -# Тести аналізатора -pytest tests/spiritual/test_spiritual_analyzer*.py -v - -# Тести інтерфейсу -pytest tests/spiritual/test_spiritual_interface*.py -v - -# Тести мультиконфесійної чутливості -pytest tests/spiritual/test_multi_faith*.py -v - -# Тести зворотного зв'язку -pytest tests/spiritual/test_feedback_store.py -v - -# Тести обробки помилок -pytest tests/spiritual/test_error_handling.py -v -``` - -### З Покриттям - -```bash -pytest tests/spiritual/ --cov=src/core --cov=src/interface --cov-report=html -``` - -## 📁 Структура Тестів - -``` -tests/spiritual/ -├── test_spiritual_analyzer.py # Тести аналізатора (12 тестів) -├── test_spiritual_analyzer_structure.py # Тести структури (7 тестів) -├── test_spiritual_app.py # Тести додатку (6 тестів) -├── test_spiritual_classes.py # Тести класів даних (6 тестів) -├── test_spiritual_interface.py # Тести інтерфейсу (3 тести) -├── test_spiritual_interface_integration.py # Інтеграційні тести (3 тести) -├── test_spiritual_interface_task9.py # Тести Task 9 (8 тестів) -├── test_spiritual_interface_integration_task9.py # Інтеграція Task 9 (8 тестів) -├── test_multi_faith_sensitivity.py # Тести чутливості (26 тестів) -├── test_multi_faith_integration.py # Інтеграція чутливості (14 тестів) -├── test_clarifying_questions.py # Тести питань (2 тести) -├── test_clarifying_questions_integration.py # Інтеграція питань (4 тести) -├── test_clarifying_questions_live.py # Live тести (1 тест) -├── test_referral_requirements.py # Тести вимог (7 тестів) -├── test_referral_generator.py # Тести генератора (2 тести) -├── test_feedback_store.py # Тести зберігання (26 тестів) -├── test_error_handling.py # Тести помилок (12 тестів) -└── test_ui_error_messages.py # Тести UI помилок (5 тестів) -``` - -## 🎯 Категорії Тестів - -### 1. Тести Класів Даних (6 тестів) -**Файл:** `test_spiritual_classes.py` - -Тестують: -- PatientInput -- DistressClassification -- ReferralMessage -- ProviderFeedback -- SpiritualDistressDefinitions -- AIClientManager availability - -### 2. Тести Аналізатора (19 тестів) -**Файли:** `test_spiritual_analyzer*.py` - -Тестують: -- Структуру класу -- Ініціалізацію -- Аналіз повідомлень -- Виявлення червоних прапорів -- Виявлення жовтих прапорів -- Виявлення відсутності прапорів -- Мультикатегорійне виявлення -- Консервативну логіку -- Парсинг JSON -- Обробку помилок - -### 3. Тести Додатку (6 тестів) -**Файл:** `test_spiritual_app.py` - -Тестують: -- Ініціалізацію додатку -- Обробку оцінок -- Подання зворотного зв'язку -- Метрики та експорт -- Управління сесіями -- Повторну оцінку - -### 4. Тести Інтерфейсу (22 тести) -**Файли:** `test_spiritual_interface*.py` - -Тестують: -- Створення інтерфейсу -- Ізоляцію сесій -- Методи сесій -- Структуру компонентів -- Панелі введення/виведення -- Панель зворотного зв'язку -- Панель історії -- Обробники подій -- Покриття вимог - -### 5. Тести Мультиконфесійної Чутливості (40 тестів) -**Файли:** `test_multi_faith*.py` - -Тестують: -- Виявлення релігійних термінів -- Інклюзивну мову -- Збереження релігійного контексту -- Неприпускаючі питання -- Релігійно-агностичне виявлення -- Інтеграцію з різними релігіями -- End-to-end workflows - -### 6. Тести Уточнюючих Питань (7 тестів) -**Файли:** `test_clarifying_questions*.py` - -Тестують: -- Генерацію питань для жовтих прапорів -- Емпатичні та відкриті питання -- Неприпускаючу релігійну мову -- Обмеження кількості питань -- Live генерацію - -### 7. Тести Вимог до Направлень (9 тестів) -**Файли:** `test_referral*.py` - -Тестують: -- Включення турбот пацієнта (Req 4.2) -- Включення індикаторів дистресу (Req 4.3) -- Включення контексту розмови (Req 4.4) -- Професійну мову (Req 4.5) -- Інклюзивну мову (Req 7.2) -- Збереження релігійного контексту (Req 7.3) - -### 8. Тести Зберігання Зворотного Зв'язку (26 тестів) -**Файл:** `test_feedback_store.py` - -Тестують: -- Генерацію унікальних ID -- Збереження всіх полів -- Персистентність даних -- Round-trip операції -- Експорт у CSV -- Обчислення метрик точності -- Видалення записів -- Статистику - -### 9. Тести Обробки Помилок (17 тестів) -**Файли:** `test_error_handling.py`, `test_ui_error_messages.py` - -Тестують: -- Timeout та retry логіку LLM API -- Обробку rate limiting -- Валідацію порожнього введення -- Обробку невалідного JSON -- Помилки зберігання -- Консервативну класифікацію -- User-friendly повідомлення про помилки - -## 🔍 Приклади Використання - -### Запуск Одного Тесту - -```bash -pytest tests/spiritual/test_spiritual_analyzer.py::test_red_flag_detection -v -``` - -### Запуск з Детальним Виводом - -```bash -pytest tests/spiritual/ -v -s -``` - -### Запуск з Фільтром - -```bash -# Тільки тести, що містять "red_flag" -pytest tests/spiritual/ -k "red_flag" -v - -# Тільки тести мультиконфесійності -pytest tests/spiritual/ -k "multi_faith" -v -``` - -### Запуск з Маркерами - -```bash -# Тільки швидкі тести (якщо є маркери) -pytest tests/spiritual/ -m "not slow" -v -``` - -## 📊 Очікувані Результати - -``` -============================= test session starts ============================== -platform darwin -- Python 3.11.9, pytest-8.4.2, pluggy-1.6.0 -collected 145 items - -test_spiritual_analyzer_structure.py::test_class_structure PASSED [ 0%] -test_spiritual_analyzer_structure.py::test_prompt_functions PASSED [ 1%] -... -test_ui_error_messages.py::test_error_context_information PASSED [100%] - -======================= 145 passed, 37 warnings in 15.04s ====================== -``` - -## ⚠️ Важливі Примітки - -### Залежності від API - -Деякі тести використовують реальні API виклики: -- `test_clarifying_questions_live.py` -- `test_spiritual_live.py` (якщо існує) - -Для їх запуску потрібен валідний `GEMINI_API_KEY` в `.env` - -### Моки та Фікстури - -Більшість тестів використовують моки для: -- LLM API відповідей -- Файлової системи -- Часових міток - -Це забезпечує: -- ✅ Швидкість виконання -- ✅ Детермінованість -- ✅ Незалежність від зовнішніх сервісів - -## 🐛 Усунення Несправностей - -### Тести Не Запускаються - -```bash -# Перевірте, що venv активовано -source venv/bin/activate - -# Перевірте, що pytest встановлено -pip install pytest - -# Перевірте, що ви в кореневій директорії -pwd -``` - -### Тести Падають - -```bash -# Перевірте залежності -pip install -r requirements.txt - -# Перевірте .env файл -cat .env - -# Запустіть з детальним виводом -pytest tests/spiritual/ -v -s --tb=short -``` - -### Повільні Тести - -```bash -# Пропустіть live тести -pytest tests/spiritual/ -v --ignore=tests/spiritual/test_clarifying_questions_live.py -``` - -## 📈 Покриття Коду - -Для генерації звіту про покриття: - -```bash -pytest tests/spiritual/ --cov=src/core --cov=src/interface --cov-report=html - -# Відкрити звіт -open htmlcov/index.html -``` - -## 🎯 Найкращі Практики - -1. **Запускайте тести перед commit:** -```bash -pytest tests/spiritual/ -v -``` - -2. **Пишіть тести для нового функціоналу** - -3. **Підтримуйте покриття 100%** - -4. **Використовуйте описові назви тестів** - -5. **Документуйте складні тести** - -## 📞 Підтримка - -Якщо тести не проходять: -1. Перевірте логи: `pytest tests/spiritual/ -v -s` -2. Перегляньте документацію: `docs/spiritual/` -3. Перевірте вихідний код: `src/` - ---- - -**Версія:** 1.0 -**Дата:** 5 грудня 2025 -**Статус:** ✅ 145/145 тестів пройдено diff --git a/tests/spiritual/test_clarifying_questions.py b/tests/spiritual/test_clarifying_questions.py deleted file mode 100644 index 4250190cc7ef781b8242d3fbf24a0f8a4da364c7..0000000000000000000000000000000000000000 --- a/tests/spiritual/test_clarifying_questions.py +++ /dev/null @@ -1,126 +0,0 @@ -""" -Test for ClarifyingQuestionGenerator implementation. - -Tests the basic functionality of generating clarifying questions for yellow flag cases. -""" - -import sys -import os - -# Add src to path -sys.path.insert(0, os.path.abspath('.')) - -from src.core.spiritual_analyzer import ClarifyingQuestionGenerator -from src.core.spiritual_classes import PatientInput, DistressClassification -from src.core.ai_client import AIClientManager - - -def test_clarifying_question_generation(): - """Test that clarifying questions are generated for yellow flag cases.""" - - # Initialize AI client - api = AIClientManager() - - # Create question generator - generator = ClarifyingQuestionGenerator(api) - - # Create a yellow flag classification - classification = DistressClassification( - flag_level="yellow", - indicators=["mild frustration", "recent emotional changes"], - categories=["emotional_distress"], - confidence=0.6, - reasoning="Patient mentions feeling frustrated lately, but severity is unclear" - ) - - # Create patient input - patient_input = PatientInput( - message="I've been feeling frustrated lately and things are bothering me more than usual", - timestamp="2025-12-04T10:00:00Z" - ) - - # Generate questions - print("Generating clarifying questions...") - questions = generator.generate_questions(classification, patient_input) - - # Verify results - print(f"\nGenerated {len(questions)} questions:") - for i, question in enumerate(questions, 1): - print(f"{i}. {question}") - - # Basic validation - assert len(questions) >= 1, "Should generate at least 1 question" - assert len(questions) <= 3, "Should generate at most 3 questions" - - for question in questions: - assert isinstance(question, str), "Each question should be a string" - assert len(question) > 10, "Questions should be substantive" - assert question.strip() == question, "Questions should be trimmed" - - print("\n✓ All basic validations passed!") - - # Check for non-assumptive language (should not contain religious terms) - religious_terms = ["god", "pray", "prayer", "church", "faith", "salvation", "blessing"] - for question in questions: - question_lower = question.lower() - for term in religious_terms: - if term in question_lower: - print(f"\n⚠ Warning: Question contains potentially assumptive religious term '{term}': {question}") - - print("\n✓ Test completed successfully!") - return questions - - -def test_fallback_questions(): - """Test that fallback questions work when LLM fails.""" - - # Initialize AI client - api = AIClientManager() - - # Create question generator - generator = ClarifyingQuestionGenerator(api) - - # Create a classification - classification = DistressClassification( - flag_level="yellow", - indicators=["anger"], - categories=["anger"], - confidence=0.5, - reasoning="Test" - ) - - # Test fallback directly - print("\nTesting fallback questions...") - fallback_questions = generator._create_fallback_questions(classification) - - print(f"Generated {len(fallback_questions)} fallback questions:") - for i, question in enumerate(fallback_questions, 1): - print(f"{i}. {question}") - - assert len(fallback_questions) >= 1, "Should generate at least 1 fallback question" - assert len(fallback_questions) <= 3, "Should generate at most 3 fallback questions" - - print("\n✓ Fallback questions test passed!") - - -if __name__ == "__main__": - print("=" * 80) - print("Testing ClarifyingQuestionGenerator Implementation") - print("=" * 80) - - try: - # Test main functionality - questions = test_clarifying_question_generation() - - # Test fallback - test_fallback_questions() - - print("\n" + "=" * 80) - print("ALL TESTS PASSED!") - print("=" * 80) - - except Exception as e: - print(f"\n❌ Test failed with error: {e}") - import traceback - traceback.print_exc() - sys.exit(1) diff --git a/tests/spiritual/test_clarifying_questions_integration.py b/tests/spiritual/test_clarifying_questions_integration.py deleted file mode 100644 index f39a9bfed6b74b800b4f1a3d2bcf0cc12f5801b2..0000000000000000000000000000000000000000 --- a/tests/spiritual/test_clarifying_questions_integration.py +++ /dev/null @@ -1,327 +0,0 @@ -#!/usr/bin/env python3 -""" -Integration test for ClarifyingQuestionGenerator - -Tests the clarifying question generation for yellow flag cases. -Validates Requirements 3.2, 3.5, 7.4 -""" - -import sys -import os - -# Add src to path -sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src')) - -from src.core.ai_client import AIClientManager -from src.core.spiritual_analyzer import ClarifyingQuestionGenerator -from src.core.spiritual_classes import PatientInput, DistressClassification - - -def test_question_generation_for_yellow_flag(): - """ - Test that clarifying questions are generated for yellow flag cases. - Validates Requirement 3.2 - """ - print("\n=== Test 1: Question Generation for Yellow Flag ===") - - try: - api = AIClientManager() - generator = ClarifyingQuestionGenerator(api) - - # Create a yellow flag classification - classification = DistressClassification( - flag_level="yellow", - indicators=["mild frustration", "recent emotional changes"], - categories=["emotional_distress"], - confidence=0.6, - reasoning="Patient mentions feeling frustrated lately, but severity is unclear" - ) - - # Create patient input - patient_input = PatientInput( - message="I've been feeling frustrated lately and things are bothering me more than usual", - timestamp="" - ) - - print(f"Patient message: '{patient_input.message}'") - print(f"Classification: {classification.flag_level}") - - # Generate questions - questions = generator.generate_questions(classification, patient_input) - - print(f"\n✓ Generated {len(questions)} questions:") - for i, question in enumerate(questions, 1): - print(f" {i}. {question}") - - # Validate - assert len(questions) >= 1, "Should generate at least 1 question" - assert len(questions) <= 3, "Should generate at most 3 questions (Requirement 3.5)" - - for question in questions: - assert isinstance(question, str), "Each question should be a string" - assert len(question) > 10, "Questions should be substantive" - - print("\n✓ Test passed: Questions generated for yellow flag") - return True - - except Exception as e: - print(f"✗ Test failed: {e}") - import traceback - traceback.print_exc() - return False - - -def test_empathetic_open_ended_questions(): - """ - Test that questions are empathetic and open-ended. - Validates Requirement 3.5 - """ - print("\n=== Test 2: Empathetic and Open-Ended Questions ===") - - try: - api = AIClientManager() - generator = ClarifyingQuestionGenerator(api) - - # Create a yellow flag classification with sadness indicators - classification = DistressClassification( - flag_level="yellow", - indicators=["sadness", "emotional changes"], - categories=["persistent_sadness"], - confidence=0.55, - reasoning="Patient mentions feeling down but severity unclear" - ) - - patient_input = PatientInput( - message="I've been feeling down and I cry more than I used to", - timestamp="" - ) - - print(f"Patient message: '{patient_input.message}'") - - # Generate questions - questions = generator.generate_questions(classification, patient_input) - - print(f"\n✓ Generated {len(questions)} questions:") - for i, question in enumerate(questions, 1): - print(f" {i}. {question}") - - # Check for empathetic language patterns - empathetic_patterns = ["can you tell me", "how", "what", "would you", "could you"] - has_empathetic = False - - for question in questions: - question_lower = question.lower() - if any(pattern in question_lower for pattern in empathetic_patterns): - has_empathetic = True - break - - if has_empathetic: - print("\n✓ Questions use empathetic language patterns") - else: - print("\n⚠ Questions may lack empathetic language") - - # Check that questions are open-ended (not yes/no) - # Open-ended questions typically don't start with "do", "is", "are", "can", "will" - closed_starters = ["do you", "is it", "are you", "will you", "have you"] - open_ended_count = 0 - - for question in questions: - question_lower = question.lower() - is_closed = any(question_lower.startswith(starter) for starter in closed_starters) - if not is_closed: - open_ended_count += 1 - - print(f"✓ {open_ended_count}/{len(questions)} questions are open-ended") - - print("\n✓ Test passed: Questions are empathetic and open-ended") - return True - - except Exception as e: - print(f"✗ Test failed: {e}") - import traceback - traceback.print_exc() - return False - - -def test_non_assumptive_religious_language(): - """ - Test that questions avoid religious assumptions. - Validates Requirement 7.4 - """ - print("\n=== Test 3: Non-Assumptive Religious Language ===") - - try: - api = AIClientManager() - generator = ClarifyingQuestionGenerator(api) - - # Test with various yellow flag scenarios - test_cases = [ - { - "message": "I've been feeling lost and searching for meaning", - "indicators": ["existential concerns", "meaning"], - "categories": ["meaning_purpose"] - }, - { - "message": "I'm struggling with anger and resentment", - "indicators": ["anger", "resentment"], - "categories": ["anger"] - }, - { - "message": "I feel disconnected from everything", - "indicators": ["disconnection", "isolation"], - "categories": ["isolation"] - } - ] - - all_questions = [] - - for i, test_case in enumerate(test_cases, 1): - print(f"\nTest case {i}: '{test_case['message']}'") - - classification = DistressClassification( - flag_level="yellow", - indicators=test_case["indicators"], - categories=test_case["categories"], - confidence=0.6, - reasoning="Ambiguous indicators requiring clarification" - ) - - patient_input = PatientInput( - message=test_case["message"], - timestamp="" - ) - - questions = generator.generate_questions(classification, patient_input) - all_questions.extend(questions) - - print(f" Generated {len(questions)} questions:") - for j, question in enumerate(questions, 1): - print(f" {j}. {question}") - - # Check for religious/denominational terms that should be avoided - # (unless patient mentioned them first, which they didn't in our test cases) - religious_terms = [ - "god", "pray", "prayer", "church", "faith", "salvation", - "blessing", "sin", "heaven", "hell", "bible", "scripture", - "worship", "congregation", "ministry", "divine" - ] - - violations = [] - for question in all_questions: - question_lower = question.lower() - for term in religious_terms: - if term in question_lower: - violations.append((question, term)) - - if violations: - print(f"\n⚠ Found {len(violations)} potential religious assumption(s):") - for question, term in violations: - print(f" - Term '{term}' in: {question}") - print("\n⚠ Test warning: Questions should avoid religious assumptions (Requirement 7.4)") - # Don't fail the test, but warn - return True - else: - print("\n✓ No religious assumptions detected in questions") - print("✓ Test passed: Questions avoid religious assumptions") - return True - - except Exception as e: - print(f"✗ Test failed: {e}") - import traceback - traceback.print_exc() - return False - - -def test_question_limit(): - """ - Test that questions are limited to 2-3 maximum. - Validates Requirement 3.5 - """ - print("\n=== Test 4: Question Limit (2-3 Maximum) ===") - - try: - api = AIClientManager() - generator = ClarifyingQuestionGenerator(api) - - # Create a complex classification with many indicators - classification = DistressClassification( - flag_level="yellow", - indicators=["anger", "sadness", "frustration", "isolation", "meaning"], - categories=["anger", "persistent_sadness", "meaning_purpose"], - confidence=0.5, - reasoning="Multiple ambiguous indicators detected" - ) - - patient_input = PatientInput( - message="I'm feeling angry, sad, frustrated, alone, and like nothing matters anymore", - timestamp="" - ) - - print(f"Patient message: '{patient_input.message}'") - print(f"Indicators: {len(classification.indicators)}") - - # Generate questions - questions = generator.generate_questions(classification, patient_input) - - print(f"\n✓ Generated {len(questions)} questions:") - for i, question in enumerate(questions, 1): - print(f" {i}. {question}") - - # Validate limit - if len(questions) <= 3: - print(f"\n✓ Question count ({len(questions)}) is within limit (2-3 maximum)") - print("✓ Test passed: Question limit enforced") - return True - else: - print(f"\n✗ Question count ({len(questions)}) exceeds limit of 3") - return False - - except Exception as e: - print(f"✗ Test failed: {e}") - import traceback - traceback.print_exc() - return False - - -def main(): - """Run all tests""" - print("=" * 70) - print("CLARIFYING QUESTION GENERATOR - INTEGRATION TESTS") - print("=" * 70) - - results = [] - - # Run tests - results.append(("Question Generation for Yellow Flag (Req 3.2)", test_question_generation_for_yellow_flag())) - results.append(("Empathetic and Open-Ended Questions (Req 3.5)", test_empathetic_open_ended_questions())) - results.append(("Non-Assumptive Religious Language (Req 7.4)", test_non_assumptive_religious_language())) - results.append(("Question Limit 2-3 Maximum (Req 3.5)", test_question_limit())) - - # Summary - print("\n" + "=" * 70) - print("TEST SUMMARY") - print("=" * 70) - - passed = sum(1 for _, result in results if result) - total = len(results) - - for test_name, result in results: - status = "✓ PASS" if result else "✗ FAIL" - print(f"{status}: {test_name}") - - print(f"\nTotal: {passed}/{total} tests passed") - - if passed == total: - print("\n✓ All tests passed!") - print("\nValidated Requirements:") - print(" - 3.2: Clarifying questions generated for yellow flags") - print(" - 3.5: Questions are empathetic, open-ended, limited to 2-3") - print(" - 7.4: Questions avoid religious assumptions") - return 0 - else: - print(f"\n⚠ {total - passed} test(s) failed") - return 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/tests/spiritual/test_clarifying_questions_live.py b/tests/spiritual/test_clarifying_questions_live.py deleted file mode 100644 index d1a55ad22d280a7cfaab48c6e596c579498769aa..0000000000000000000000000000000000000000 --- a/tests/spiritual/test_clarifying_questions_live.py +++ /dev/null @@ -1,89 +0,0 @@ -#!/usr/bin/env python3 -""" -Live test for ClarifyingQuestionGenerator with actual API - -Quick test to verify the implementation works with real LLM calls. -""" - -import sys -import os - -# Add src to path -sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src')) - -from src.core.ai_client import AIClientManager -from src.core.spiritual_analyzer import ClarifyingQuestionGenerator -from src.core.spiritual_classes import PatientInput, DistressClassification - - -def test_live_question_generation(): - """Test with actual API call""" - print("=" * 70) - print("LIVE TEST: ClarifyingQuestionGenerator with Real API") - print("=" * 70) - - try: - # Initialize AI client - api = AIClientManager() - generator = ClarifyingQuestionGenerator(api) - - # Create a yellow flag classification - classification = DistressClassification( - flag_level="yellow", - indicators=["mild frustration", "recent emotional changes"], - categories=["emotional_distress"], - confidence=0.6, - reasoning="Patient mentions feeling frustrated lately, but severity is unclear" - ) - - # Create patient input - patient_input = PatientInput( - message="I've been feeling frustrated lately and things are bothering me more than usual", - timestamp="" - ) - - print(f"\nPatient message: '{patient_input.message}'") - print(f"Classification: {classification.flag_level}") - print(f"Indicators: {classification.indicators}") - print("\nGenerating clarifying questions with LLM...") - - # Generate questions - questions = generator.generate_questions(classification, patient_input) - - print(f"\n✓ Generated {len(questions)} questions:") - for i, question in enumerate(questions, 1): - print(f" {i}. {question}") - - # Validate - assert len(questions) >= 1, "Should generate at least 1 question" - assert len(questions) <= 3, "Should generate at most 3 questions" - - # Check for religious terms - religious_terms = ["god", "pray", "prayer", "church", "faith", "salvation"] - violations = [] - for question in questions: - question_lower = question.lower() - for term in religious_terms: - if term in question_lower: - violations.append((question, term)) - - if violations: - print(f"\n⚠ Warning: Found religious terms:") - for question, term in violations: - print(f" - '{term}' in: {question}") - else: - print("\n✓ No religious assumptions detected") - - print("\n✓ Live test passed!") - return True - - except Exception as e: - print(f"\n✗ Test failed: {e}") - import traceback - traceback.print_exc() - return False - - -if __name__ == "__main__": - success = test_live_question_generation() - sys.exit(0 if success else 1) diff --git a/tests/spiritual/test_error_handling.py b/tests/spiritual/test_error_handling.py deleted file mode 100644 index 2db7941a14dfa8602403e54c2b5ea004148085a6..0000000000000000000000000000000000000000 --- a/tests/spiritual/test_error_handling.py +++ /dev/null @@ -1,554 +0,0 @@ -#!/usr/bin/env python3 -""" -Comprehensive Error Handling Tests for Spiritual Health Assessment Tool - -Tests all error handling scenarios as specified in Task 11: -- LLM API error handling with retry logic -- Data validation error handling -- Classification edge cases (ambiguous, empty input) -- Storage error handling -- User-friendly error messages in UI - -Requirement: 10.5 -""" - -import os -import sys -import json -import time -import tempfile -import shutil -from unittest.mock import Mock, patch, MagicMock -from datetime import datetime - -# Add src to path -sys.path.insert(0, os.path.abspath('.')) - -from src.core.ai_client import AIClientManager -from src.core.spiritual_analyzer import ( - SpiritualDistressAnalyzer, - ReferralMessageGenerator, - ClarifyingQuestionGenerator -) -from src.core.spiritual_classes import ( - PatientInput, - DistressClassification, - ReferralMessage, - ProviderFeedback -) -from src.storage.feedback_store import FeedbackStore - - -def test_llm_api_timeout_retry(): - """Test LLM API timeout with retry logic""" - print("\n=== Test: LLM API Timeout with Retry Logic ===") - - # Create mock API that fails twice then succeeds - mock_api = Mock(spec=AIClientManager) - call_count = [0] - - def mock_generate_response(*args, **kwargs): - call_count[0] += 1 - if call_count[0] < 3: - raise RuntimeError("Connection timeout") - return json.dumps({ - "flag_level": "yellow", - "indicators": ["test"], - "categories": [], - "confidence": 0.5, - "reasoning": "Test" - }) - - mock_api.generate_response = mock_generate_response - - analyzer = SpiritualDistressAnalyzer(mock_api) - patient_input = PatientInput( - message="I'm feeling frustrated", - timestamp=datetime.now().isoformat() - ) - - start_time = time.time() - result = analyzer.analyze_message(patient_input) - elapsed_time = time.time() - start_time - - print(f" Retry attempts: {call_count[0]}") - print(f" Elapsed time: {elapsed_time:.2f}s") - print(f" Result flag: {result.flag_level}") - - assert call_count[0] == 3, "Should retry twice before succeeding" - assert elapsed_time >= 3.0, "Should have exponential backoff delays (1s + 2s)" - assert result.flag_level in ["red", "yellow", "none"], "Should return valid classification" - - print(" ✅ Retry logic works with exponential backoff") - return True - - -def test_llm_api_max_retries_exceeded(): - """Test LLM API failure after max retries""" - print("\n=== Test: LLM API Max Retries Exceeded ===") - - # Create mock API that always fails - mock_api = Mock(spec=AIClientManager) - call_count = [0] - - def mock_generate_response(*args, **kwargs): - call_count[0] += 1 - raise RuntimeError("Connection timeout") - - mock_api.generate_response = mock_generate_response - - analyzer = SpiritualDistressAnalyzer(mock_api) - patient_input = PatientInput( - message="I'm feeling frustrated", - timestamp=datetime.now().isoformat() - ) - - result = analyzer.analyze_message(patient_input) - - print(f" Retry attempts: {call_count[0]}") - print(f" Result flag: {result.flag_level}") - print(f" Result reasoning: {result.reasoning[:100]}...") - - assert call_count[0] == 3, "Should attempt max 3 times" - assert result.flag_level == "yellow", "Should return safe default (yellow flag)" - assert "analysis_error" in result.indicators, "Should indicate error" - assert "timeout" in result.reasoning.lower() or "error" in result.reasoning.lower() - - print(" ✅ Returns safe default after max retries") - return True - - -def test_llm_api_rate_limiting(): - """Test LLM API rate limiting error""" - print("\n=== Test: LLM API Rate Limiting ===") - - mock_api = Mock(spec=AIClientManager) - mock_api.generate_response = Mock(side_effect=RuntimeError("Rate limit exceeded")) - - analyzer = SpiritualDistressAnalyzer(mock_api) - patient_input = PatientInput( - message="I'm feeling frustrated", - timestamp=datetime.now().isoformat() - ) - - result = analyzer.analyze_message(patient_input) - - print(f" Result flag: {result.flag_level}") - print(f" Result reasoning: {result.reasoning[:100]}...") - - assert result.flag_level == "yellow", "Should return safe default" - assert "rate" in result.reasoning.lower() or "error" in result.reasoning.lower() - - print(" ✅ Handles rate limiting gracefully") - return True - - -def test_empty_input_validation(): - """Test empty input validation""" - print("\n=== Test: Empty Input Validation ===") - - mock_api = Mock(spec=AIClientManager) - analyzer = SpiritualDistressAnalyzer(mock_api) - - # Test completely empty input - empty_input = PatientInput(message="", timestamp=datetime.now().isoformat()) - result1 = analyzer.analyze_message(empty_input) - - print(f" Empty string result: {result1.flag_level}") - assert result1.flag_level == "yellow", "Should return safe default for empty input" - assert "Empty" in result1.reasoning or "empty" in result1.reasoning - - # Test whitespace-only input - whitespace_input = PatientInput(message=" \n\t ", timestamp=datetime.now().isoformat()) - result2 = analyzer.analyze_message(whitespace_input) - - print(f" Whitespace result: {result2.flag_level}") - assert result2.flag_level == "yellow", "Should return safe default for whitespace" - assert "whitespace" in result2.reasoning.lower() or "empty" in result2.reasoning.lower() - - # Test None input - result3 = analyzer.analyze_message(None) - - print(f" None input result: {result3.flag_level}") - assert result3.flag_level == "yellow", "Should return safe default for None" - - print(" ✅ Empty input validation works correctly") - return True - - -def test_invalid_json_response(): - """Test handling of invalid JSON from LLM""" - print("\n=== Test: Invalid JSON Response ===") - - mock_api = Mock(spec=AIClientManager) - - # Test with invalid JSON - mock_api.generate_response = Mock(return_value="This is not JSON at all") - - analyzer = SpiritualDistressAnalyzer(mock_api) - patient_input = PatientInput( - message="I'm feeling frustrated", - timestamp=datetime.now().isoformat() - ) - - result = analyzer.analyze_message(patient_input) - - print(f" Result flag: {result.flag_level}") - print(f" Result reasoning: {result.reasoning[:100]}...") - - assert result.flag_level == "yellow", "Should return safe default for invalid JSON" - assert "parsing" in result.reasoning.lower() or "error" in result.reasoning.lower() - - print(" ✅ Handles invalid JSON gracefully") - return True - - -def test_malformed_classification_data(): - """Test handling of malformed classification data""" - print("\n=== Test: Malformed Classification Data ===") - - mock_api = Mock(spec=AIClientManager) - - # Test with missing required fields - mock_api.generate_response = Mock(return_value=json.dumps({ - "indicators": ["test"], - # Missing flag_level - })) - - analyzer = SpiritualDistressAnalyzer(mock_api) - patient_input = PatientInput( - message="I'm feeling frustrated", - timestamp=datetime.now().isoformat() - ) - - result = analyzer.analyze_message(patient_input) - - print(f" Result flag: {result.flag_level}") - assert result.flag_level == "yellow", "Should return safe default for malformed data" - - # Test with invalid flag_level - mock_api.generate_response = Mock(return_value=json.dumps({ - "flag_level": "invalid_flag", - "indicators": [], - "categories": [], - "confidence": 0.5, - "reasoning": "Test" - })) - - result2 = analyzer.analyze_message(patient_input) - - print(f" Invalid flag result: {result2.flag_level}") - assert result2.flag_level == "yellow", "Should return safe default for invalid flag" - - print(" ✅ Handles malformed data gracefully") - return True - - -def test_storage_disk_full_error(): - """Test storage error handling for disk full""" - print("\n=== Test: Storage Disk Full Error ===") - - # Create temporary storage directory - temp_dir = tempfile.mkdtemp() - - try: - feedback_store = FeedbackStore(storage_dir=temp_dir) - - # Mock os.replace to simulate disk full error - with patch('os.replace', side_effect=OSError("[Errno 28] No space left on device")): - patient_input = PatientInput( - message="Test message", - timestamp=datetime.now().isoformat() - ) - classification = DistressClassification( - flag_level="yellow", - indicators=["test"], - categories=[], - confidence=0.5, - reasoning="Test" - ) - feedback = ProviderFeedback( - assessment_id="test", - provider_id="test_provider", - agrees_with_classification=True, - agrees_with_referral=False, - comments="Test" - ) - - try: - feedback_store.save_feedback( - patient_input=patient_input, - classification=classification, - referral_message=None, - provider_feedback=feedback - ) - print(" ❌ Should have raised IOError") - return False - except IOError as e: - print(f" Caught expected error: {str(e)[:50]}...") - assert "full" in str(e).lower() or "space" in str(e).lower() - print(" ✅ Disk full error handled correctly") - return True - - finally: - # Cleanup - shutil.rmtree(temp_dir, ignore_errors=True) - - -def test_storage_permission_denied(): - """Test storage error handling for permission denied""" - print("\n=== Test: Storage Permission Denied ===") - - # Create temporary storage directory - temp_dir = tempfile.mkdtemp() - - try: - feedback_store = FeedbackStore(storage_dir=temp_dir) - - # Mock os.replace to simulate permission error - with patch('os.replace', side_effect=OSError("[Errno 13] Permission denied")): - patient_input = PatientInput( - message="Test message", - timestamp=datetime.now().isoformat() - ) - classification = DistressClassification( - flag_level="yellow", - indicators=["test"], - categories=[], - confidence=0.5, - reasoning="Test" - ) - feedback = ProviderFeedback( - assessment_id="test", - provider_id="test_provider", - agrees_with_classification=True, - agrees_with_referral=False, - comments="Test" - ) - - try: - feedback_store.save_feedback( - patient_input=patient_input, - classification=classification, - referral_message=None, - provider_feedback=feedback - ) - print(" ❌ Should have raised IOError") - return False - except IOError as e: - print(f" Caught expected error: {str(e)[:50]}...") - assert "permission" in str(e).lower() - print(" ✅ Permission error handled correctly") - return True - - finally: - # Cleanup - shutil.rmtree(temp_dir, ignore_errors=True) - - -def test_conservative_classification_logic(): - """Test conservative classification logic for edge cases""" - print("\n=== Test: Conservative Classification Logic ===") - - mock_api = Mock(spec=AIClientManager) - analyzer = SpiritualDistressAnalyzer(mock_api) - - # Test: Low confidence with "none" flag should escalate to yellow - classification1 = DistressClassification( - flag_level="none", - indicators=[], - categories=[], - confidence=0.3, # Low confidence - reasoning="Test" - ) - - result1 = analyzer._apply_conservative_logic(classification1) - print(f" Low confidence 'none' -> {result1.flag_level}") - assert result1.flag_level == "yellow", "Should escalate low confidence 'none' to yellow" - - # Test: Indicators present but "none" flag should escalate to yellow - classification2 = DistressClassification( - flag_level="none", - indicators=["frustration", "sadness"], # Has indicators - categories=[], - confidence=0.8, - reasoning="Test" - ) - - result2 = analyzer._apply_conservative_logic(classification2) - print(f" Indicators with 'none' -> {result2.flag_level}") - assert result2.flag_level == "yellow", "Should escalate 'none' with indicators to yellow" - - # Test: High confidence "none" with no indicators should stay "none" - classification3 = DistressClassification( - flag_level="none", - indicators=[], - categories=[], - confidence=0.9, - reasoning="Test" - ) - - result3 = analyzer._apply_conservative_logic(classification3) - print(f" High confidence 'none' no indicators -> {result3.flag_level}") - assert result3.flag_level == "none", "Should keep 'none' when appropriate" - - print(" ✅ Conservative logic works correctly") - return True - - -def test_referral_generator_error_handling(): - """Test referral generator error handling""" - print("\n=== Test: Referral Generator Error Handling ===") - - mock_api = Mock(spec=AIClientManager) - mock_api.generate_response = Mock(side_effect=RuntimeError("Connection timeout")) - - generator = ReferralMessageGenerator(mock_api) - - classification = DistressClassification( - flag_level="red", - indicators=["anger", "sadness"], - categories=["emotional_distress"], - confidence=0.9, - reasoning="Test" - ) - - patient_input = PatientInput( - message="I am angry all the time", - timestamp=datetime.now().isoformat() - ) - - result = generator.generate_referral(classification, patient_input) - - print(f" Fallback referral generated: {len(result.message_text)} chars") - assert isinstance(result, ReferralMessage), "Should return ReferralMessage" - assert result.message_text, "Should have message text" - assert "SPIRITUAL CARE REFERRAL" in result.message_text or result.message_text - - print(" ✅ Referral generator handles errors with fallback") - return True - - -def test_question_generator_error_handling(): - """Test question generator error handling""" - print("\n=== Test: Question Generator Error Handling ===") - - mock_api = Mock(spec=AIClientManager) - mock_api.generate_response = Mock(side_effect=RuntimeError("Connection timeout")) - - generator = ClarifyingQuestionGenerator(mock_api) - - classification = DistressClassification( - flag_level="yellow", - indicators=["frustration"], - categories=[], - confidence=0.6, - reasoning="Test" - ) - - patient_input = PatientInput( - message="I've been feeling frustrated", - timestamp=datetime.now().isoformat() - ) - - result = generator.generate_questions(classification, patient_input) - - print(f" Fallback questions generated: {len(result)}") - assert isinstance(result, list), "Should return list" - assert len(result) >= 1, "Should have at least one question" - assert all(isinstance(q, str) for q in result), "All questions should be strings" - - print(" ✅ Question generator handles errors with fallback") - return True - - -def test_validation_error_messages(): - """Test that validation errors have user-friendly messages""" - print("\n=== Test: User-Friendly Validation Error Messages ===") - - mock_api = Mock(spec=AIClientManager) - analyzer = SpiritualDistressAnalyzer(mock_api) - - # Test empty input - empty_result = analyzer.analyze_message(PatientInput(message="", timestamp="")) - assert "Empty" in empty_result.reasoning or "empty" in empty_result.reasoning - print(" ✅ Empty input has clear message") - - # Test whitespace input - whitespace_result = analyzer.analyze_message(PatientInput(message=" ", timestamp="")) - assert "whitespace" in whitespace_result.reasoning.lower() or "empty" in whitespace_result.reasoning.lower() - print(" ✅ Whitespace input has clear message") - - # Test None input - none_result = analyzer.analyze_message(None) - assert "Invalid" in none_result.reasoning or "invalid" in none_result.reasoning - print(" ✅ None input has clear message") - - print(" ✅ All validation errors have user-friendly messages") - return True - - -def run_all_tests(): - """Run all error handling tests""" - print("="*70) - print("COMPREHENSIVE ERROR HANDLING TESTS") - print("Testing Task 11: Error Handling and Edge Cases") - print("="*70) - - tests = [ - ("LLM API Timeout with Retry", test_llm_api_timeout_retry), - ("LLM API Max Retries Exceeded", test_llm_api_max_retries_exceeded), - ("LLM API Rate Limiting", test_llm_api_rate_limiting), - ("Empty Input Validation", test_empty_input_validation), - ("Invalid JSON Response", test_invalid_json_response), - ("Malformed Classification Data", test_malformed_classification_data), - ("Storage Disk Full Error", test_storage_disk_full_error), - ("Storage Permission Denied", test_storage_permission_denied), - ("Conservative Classification Logic", test_conservative_classification_logic), - ("Referral Generator Error Handling", test_referral_generator_error_handling), - ("Question Generator Error Handling", test_question_generator_error_handling), - ("User-Friendly Validation Messages", test_validation_error_messages), - ] - - results = [] - for test_name, test_func in tests: - try: - result = test_func() - results.append((test_name, result)) - except Exception as e: - print(f" ❌ Test failed with exception: {e}") - import traceback - traceback.print_exc() - results.append((test_name, False)) - - # Summary - print("\n" + "="*70) - print("TEST SUMMARY") - print("="*70) - - passed = sum(1 for _, result in results if result) - total = len(results) - - for test_name, result in results: - status = "✅ PASS" if result else "❌ FAIL" - print(f"{status}: {test_name}") - - print(f"\nTotal: {passed}/{total} tests passed") - - if passed == total: - print("\n🎉 All error handling tests passed!") - print("\nVerified error handling for:") - print(" ✅ LLM API errors with retry logic") - print(" ✅ Data validation errors") - print(" ✅ Classification edge cases") - print(" ✅ Storage errors") - print(" ✅ User-friendly error messages") - return True - else: - print(f"\n⚠️ {total - passed} test(s) failed") - return False - - -if __name__ == "__main__": - success = run_all_tests() - sys.exit(0 if success else 1) diff --git a/tests/spiritual/test_feedback_store.py b/tests/spiritual/test_feedback_store.py deleted file mode 100644 index 4872eda6afd4e121de316c669a2bf11ad1fa1cfc..0000000000000000000000000000000000000000 --- a/tests/spiritual/test_feedback_store.py +++ /dev/null @@ -1,515 +0,0 @@ -#!/usr/bin/env python3 -""" -Tests for Feedback Storage System - -Tests Requirements 6.1, 6.2, 6.3, 6.4, 6.5, 6.6, 6.7: -- Unique ID generation -- Complete data storage -- Retrieval operations -- CSV export -- Accuracy metrics -""" - -import pytest -import os -import json -import tempfile -import shutil -from datetime import datetime - -from src.storage.feedback_store import FeedbackStore -from src.core.spiritual_classes import ( - PatientInput, - DistressClassification, - ReferralMessage, - ProviderFeedback -) - - -class TestFeedbackStore: - """Test the FeedbackStore class""" - - def setup_method(self): - """Set up test fixtures with temporary directory""" - # Create temporary directory for testing - self.temp_dir = tempfile.mkdtemp() - self.store = FeedbackStore(storage_dir=self.temp_dir) - - # Create sample data - self.patient_input = PatientInput( - message="I am angry all the time", - timestamp=datetime.now().isoformat() - ) - - self.classification = DistressClassification( - flag_level="red", - indicators=["persistent anger", "emotional distress"], - categories=["anger"], - confidence=0.9, - reasoning="Patient expresses persistent anger" - ) - - self.referral_message = ReferralMessage( - patient_concerns="Persistent anger affecting daily life", - distress_indicators=["anger", "emotional distress"], - context="Patient reports feeling angry all the time", - message_text="Referral for spiritual care: Patient expressing persistent anger..." - ) - - self.provider_feedback = ProviderFeedback( - assessment_id="test_id", - provider_id="provider_001", - agrees_with_classification=True, - agrees_with_referral=True, - comments="Accurate assessment" - ) - - def teardown_method(self): - """Clean up temporary directory""" - if os.path.exists(self.temp_dir): - shutil.rmtree(self.temp_dir) - - # Requirement 6.1: Store feedback with unique identifier - - def test_save_feedback_generates_unique_id(self): - """Should generate unique ID for each feedback record""" - id1 = self.store.save_feedback( - self.patient_input, - self.classification, - self.referral_message, - self.provider_feedback - ) - - id2 = self.store.save_feedback( - self.patient_input, - self.classification, - self.referral_message, - self.provider_feedback - ) - - assert id1 != id2 - assert len(id1) > 0 - assert len(id2) > 0 - - def test_save_feedback_returns_valid_uuid(self): - """Should return valid UUID as assessment ID""" - assessment_id = self.store.save_feedback( - self.patient_input, - self.classification, - self.referral_message, - self.provider_feedback - ) - - # UUID should be 36 characters (with hyphens) - assert len(assessment_id) == 36 - assert assessment_id.count('-') == 4 - - # Requirements 6.2-6.6: Store all required fields - - def test_save_feedback_stores_patient_input(self): - """Should store original patient input (Requirement 6.2)""" - assessment_id = self.store.save_feedback( - self.patient_input, - self.classification, - self.referral_message, - self.provider_feedback - ) - - record = self.store.get_feedback_by_id(assessment_id) - - assert record is not None - assert 'patient_input' in record - assert record['patient_input']['message'] == self.patient_input.message - assert record['patient_input']['timestamp'] == self.patient_input.timestamp - - def test_save_feedback_stores_classification(self): - """Should store AI classification and reasoning (Requirement 6.3)""" - assessment_id = self.store.save_feedback( - self.patient_input, - self.classification, - self.referral_message, - self.provider_feedback - ) - - record = self.store.get_feedback_by_id(assessment_id) - - assert record is not None - assert 'classification' in record - assert record['classification']['flag_level'] == self.classification.flag_level - assert record['classification']['indicators'] == self.classification.indicators - assert record['classification']['reasoning'] == self.classification.reasoning - assert record['classification']['confidence'] == self.classification.confidence - - def test_save_feedback_stores_provider_agreement(self): - """Should store provider agreement/disagreement (Requirement 6.4)""" - assessment_id = self.store.save_feedback( - self.patient_input, - self.classification, - self.referral_message, - self.provider_feedback - ) - - record = self.store.get_feedback_by_id(assessment_id) - - assert record is not None - assert 'provider_feedback' in record - assert record['provider_feedback']['agrees_with_classification'] == True - assert record['provider_feedback']['agrees_with_referral'] == True - - def test_save_feedback_stores_provider_comments(self): - """Should store provider comments (Requirement 6.5)""" - assessment_id = self.store.save_feedback( - self.patient_input, - self.classification, - self.referral_message, - self.provider_feedback - ) - - record = self.store.get_feedback_by_id(assessment_id) - - assert record is not None - assert 'provider_feedback' in record - assert record['provider_feedback']['comments'] == self.provider_feedback.comments - - def test_save_feedback_stores_timestamp(self): - """Should store timestamp (Requirement 6.6)""" - assessment_id = self.store.save_feedback( - self.patient_input, - self.classification, - self.referral_message, - self.provider_feedback - ) - - record = self.store.get_feedback_by_id(assessment_id) - - assert record is not None - assert 'timestamp' in record - assert len(record['timestamp']) > 0 - # Verify it's a valid ISO format timestamp - datetime.fromisoformat(record['timestamp']) - - def test_save_feedback_stores_referral_message(self): - """Should store referral message when present""" - assessment_id = self.store.save_feedback( - self.patient_input, - self.classification, - self.referral_message, - self.provider_feedback - ) - - record = self.store.get_feedback_by_id(assessment_id) - - assert record is not None - assert 'referral_message' in record - assert record['referral_message'] is not None - assert record['referral_message']['message_text'] == self.referral_message.message_text - - def test_save_feedback_handles_no_referral(self): - """Should handle cases with no referral message""" - assessment_id = self.store.save_feedback( - self.patient_input, - self.classification, - None, # No referral message - self.provider_feedback - ) - - record = self.store.get_feedback_by_id(assessment_id) - - assert record is not None - assert record['referral_message'] is None - - # Requirement 6.7: Persist data in structured format - - def test_feedback_persists_to_disk(self): - """Should persist feedback to disk (Requirement 6.7)""" - assessment_id = self.store.save_feedback( - self.patient_input, - self.classification, - self.referral_message, - self.provider_feedback - ) - - # Check that file exists - filename = f"assessment_{assessment_id}.json" - filepath = os.path.join(self.temp_dir, "assessments", filename) - - assert os.path.exists(filepath) - - # Verify file contains valid JSON - with open(filepath, 'r') as f: - data = json.load(f) - assert data['assessment_id'] == assessment_id - - def test_feedback_round_trip(self): - """Should retrieve same data that was saved (Requirement 6.7)""" - assessment_id = self.store.save_feedback( - self.patient_input, - self.classification, - self.referral_message, - self.provider_feedback - ) - - record = self.store.get_feedback_by_id(assessment_id) - - assert record is not None - assert record['assessment_id'] == assessment_id - assert record['patient_input']['message'] == self.patient_input.message - assert record['classification']['flag_level'] == self.classification.flag_level - assert record['provider_feedback']['agrees_with_classification'] == True - - # Retrieval operations - - def test_get_feedback_by_id_returns_none_for_nonexistent(self): - """Should return None for non-existent ID""" - record = self.store.get_feedback_by_id("nonexistent_id") - assert record is None - - def test_get_all_feedback_returns_empty_list_initially(self): - """Should return empty list when no feedback stored""" - records = self.store.get_all_feedback() - assert records == [] - - def test_get_all_feedback_returns_all_records(self): - """Should return all stored feedback records""" - # Save multiple records - id1 = self.store.save_feedback( - self.patient_input, - self.classification, - self.referral_message, - self.provider_feedback - ) - - id2 = self.store.save_feedback( - self.patient_input, - self.classification, - None, - self.provider_feedback - ) - - records = self.store.get_all_feedback() - - assert len(records) == 2 - ids = [r['assessment_id'] for r in records] - assert id1 in ids - assert id2 in ids - - def test_get_all_feedback_sorts_by_timestamp(self): - """Should return records sorted by timestamp (newest first)""" - # Save multiple records with slight delay - import time - - id1 = self.store.save_feedback( - self.patient_input, - self.classification, - self.referral_message, - self.provider_feedback - ) - - time.sleep(0.01) # Small delay to ensure different timestamps - - id2 = self.store.save_feedback( - self.patient_input, - self.classification, - None, - self.provider_feedback - ) - - records = self.store.get_all_feedback() - - # Newest should be first - assert records[0]['assessment_id'] == id2 - assert records[1]['assessment_id'] == id1 - - # CSV export - - def test_export_to_csv_creates_file(self): - """Should create CSV file with feedback data""" - # Save some feedback - self.store.save_feedback( - self.patient_input, - self.classification, - self.referral_message, - self.provider_feedback - ) - - csv_path = self.store.export_to_csv() - - assert csv_path != "" - assert os.path.exists(csv_path) - assert csv_path.endswith('.csv') - - def test_export_to_csv_contains_headers(self): - """Should include proper CSV headers""" - self.store.save_feedback( - self.patient_input, - self.classification, - self.referral_message, - self.provider_feedback - ) - - csv_path = self.store.export_to_csv() - - with open(csv_path, 'r') as f: - header = f.readline().strip() - assert 'assessment_id' in header - assert 'flag_level' in header - assert 'agrees_with_classification' in header - - def test_export_to_csv_contains_data(self): - """Should include feedback data in CSV""" - self.store.save_feedback( - self.patient_input, - self.classification, - self.referral_message, - self.provider_feedback - ) - - csv_path = self.store.export_to_csv() - - with open(csv_path, 'r') as f: - lines = f.readlines() - assert len(lines) >= 2 # Header + at least one data row - assert 'red' in lines[1] # Flag level - assert 'True' in lines[1] # Agreement - - def test_export_to_csv_returns_empty_for_no_data(self): - """Should return empty string when no data to export""" - csv_path = self.store.export_to_csv() - assert csv_path == "" - - # Accuracy metrics - - def test_get_accuracy_metrics_calculates_agreement_rate(self): - """Should calculate classification agreement rate""" - # Save feedback with agreement - feedback_agree = ProviderFeedback( - assessment_id="test", - agrees_with_classification=True, - agrees_with_referral=True - ) - - self.store.save_feedback( - self.patient_input, - self.classification, - self.referral_message, - feedback_agree - ) - - # Save feedback with disagreement - feedback_disagree = ProviderFeedback( - assessment_id="test", - agrees_with_classification=False, - agrees_with_referral=False - ) - - self.store.save_feedback( - self.patient_input, - self.classification, - self.referral_message, - feedback_disagree - ) - - metrics = self.store.get_accuracy_metrics() - - assert metrics['total_assessments'] == 2 - assert metrics['classification_agreement_rate'] == 0.5 # 1 out of 2 - - def test_get_accuracy_metrics_calculates_referral_agreement(self): - """Should calculate referral agreement rate""" - feedback = ProviderFeedback( - assessment_id="test", - agrees_with_classification=True, - agrees_with_referral=True - ) - - self.store.save_feedback( - self.patient_input, - self.classification, - self.referral_message, - feedback - ) - - metrics = self.store.get_accuracy_metrics() - - assert metrics['referral_agreement_rate'] == 1.0 - - def test_get_accuracy_metrics_calculates_flag_accuracy(self): - """Should calculate accuracy by flag level""" - # Red flag with agreement - red_classification = DistressClassification( - flag_level="red", - indicators=["anger"], - categories=["anger"], - confidence=0.9, - reasoning="Test" - ) - - feedback_agree = ProviderFeedback( - assessment_id="test", - agrees_with_classification=True - ) - - self.store.save_feedback( - self.patient_input, - red_classification, - self.referral_message, - feedback_agree - ) - - metrics = self.store.get_accuracy_metrics() - - assert 'red_flag_accuracy' in metrics - assert metrics['red_flag_accuracy'] == 1.0 - - def test_get_accuracy_metrics_returns_zero_for_no_data(self): - """Should return zero metrics when no data""" - metrics = self.store.get_accuracy_metrics() - - assert metrics['total_assessments'] == 0 - assert metrics['classification_agreement_rate'] == 0.0 - assert metrics['referral_agreement_rate'] == 0.0 - - # Additional operations - - def test_delete_feedback_removes_record(self): - """Should delete feedback record""" - assessment_id = self.store.save_feedback( - self.patient_input, - self.classification, - self.referral_message, - self.provider_feedback - ) - - # Verify it exists - assert self.store.get_feedback_by_id(assessment_id) is not None - - # Delete it - result = self.store.delete_feedback(assessment_id) - - assert result is True - assert self.store.get_feedback_by_id(assessment_id) is None - - def test_delete_feedback_returns_false_for_nonexistent(self): - """Should return False when deleting non-existent record""" - result = self.store.delete_feedback("nonexistent_id") - assert result is False - - def test_get_summary_statistics_returns_stats(self): - """Should return summary statistics""" - self.store.save_feedback( - self.patient_input, - self.classification, - self.referral_message, - self.provider_feedback - ) - - stats = self.store.get_summary_statistics() - - assert stats['total_records'] == 1 - assert 'flag_distribution' in stats - assert 'average_confidence' in stats - assert stats['flag_distribution']['red'] == 1 - - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) diff --git a/tests/spiritual/test_multi_faith_integration.py b/tests/spiritual/test_multi_faith_integration.py deleted file mode 100644 index 2db462c77666094d7ed094fa73e4f8719fac02bd..0000000000000000000000000000000000000000 --- a/tests/spiritual/test_multi_faith_integration.py +++ /dev/null @@ -1,425 +0,0 @@ -#!/usr/bin/env python3 -""" -Integration Tests for Multi-Faith Sensitivity with Spiritual Analyzer - -Tests that multi-faith sensitivity features are properly integrated into: -- SpiritualDistressAnalyzer -- ReferralMessageGenerator -- ClarifyingQuestionGenerator - -Requirements: 7.1, 7.2, 7.3, 7.4 -""" - -import pytest -import os -from unittest.mock import Mock, MagicMock -from src.core.spiritual_analyzer import ( - SpiritualDistressAnalyzer, - ReferralMessageGenerator, - ClarifyingQuestionGenerator -) -from src.core.spiritual_classes import ( - PatientInput, - DistressClassification -) -from src.core.ai_client import AIClientManager - - -class TestSpiritualDistressAnalyzerMultiFaith: - """Test multi-faith sensitivity in SpiritualDistressAnalyzer""" - - def setup_method(self): - """Set up test fixtures""" - # Mock AIClientManager - self.mock_api = Mock(spec=AIClientManager) - - # Create analyzer with test definitions - self.analyzer = SpiritualDistressAnalyzer( - api=self.mock_api, - definitions_path="data/spiritual_distress_definitions.json" - ) - - def test_analyzer_has_sensitivity_checker(self): - """Analyzer should have sensitivity checker initialized""" - assert hasattr(self.analyzer, 'sensitivity_checker') - assert self.analyzer.sensitivity_checker is not None - - def test_religion_agnostic_detection_christian(self): - """Should detect distress agnostically for Christian patient""" - # Mock LLM response - self.mock_api.generate_response.return_value = '''{ - "flag_level": "red", - "indicators": ["persistent anger", "emotional distress"], - "categories": ["anger"], - "confidence": 0.9, - "reasoning": "Patient expresses persistent anger" - }''' - - patient_input = PatientInput( - message="I am a Christian and I am angry all the time", - timestamp="2025-12-05T10:00:00Z" - ) - - classification = self.analyzer.analyze_message(patient_input) - - # Should classify based on emotional state, not religious identity - assert classification.flag_level == "red" - assert any("anger" in ind.lower() for ind in classification.indicators) - - # Verify religion-agnostic detection - is_agnostic = self.analyzer.sensitivity_checker.is_religion_agnostic_detection( - patient_input.message, - classification.indicators - ) - assert is_agnostic is True - - def test_religion_agnostic_detection_muslim(self): - """Should detect distress agnostically for Muslim patient""" - self.mock_api.generate_response.return_value = '''{ - "flag_level": "red", - "indicators": ["persistent sadness", "crying"], - "categories": ["persistent_sadness"], - "confidence": 0.85, - "reasoning": "Patient expresses persistent sadness" - }''' - - patient_input = PatientInput( - message="I am Muslim and I am crying all the time", - timestamp="2025-12-05T10:00:00Z" - ) - - classification = self.analyzer.analyze_message(patient_input) - - assert classification.flag_level == "red" - is_agnostic = self.analyzer.sensitivity_checker.is_religion_agnostic_detection( - patient_input.message, - classification.indicators - ) - assert is_agnostic is True - - def test_religion_agnostic_detection_atheist(self): - """Should detect distress agnostically for atheist patient""" - self.mock_api.generate_response.return_value = '''{ - "flag_level": "red", - "indicators": ["meaninglessness", "existential distress"], - "categories": ["meaning"], - "confidence": 0.8, - "reasoning": "Patient expresses lack of meaning" - }''' - - patient_input = PatientInput( - message="I am an atheist and life has no meaning", - timestamp="2025-12-05T10:00:00Z" - ) - - classification = self.analyzer.analyze_message(patient_input) - - assert classification.flag_level == "red" - is_agnostic = self.analyzer.sensitivity_checker.is_religion_agnostic_detection( - patient_input.message, - classification.indicators - ) - assert is_agnostic is True - - -class TestReferralMessageGeneratorMultiFaith: - """Test multi-faith sensitivity in ReferralMessageGenerator""" - - def setup_method(self): - """Set up test fixtures""" - self.mock_api = Mock(spec=AIClientManager) - self.generator = ReferralMessageGenerator(api=self.mock_api) - - def test_generator_has_sensitivity_components(self): - """Generator should have sensitivity checker and context preserver""" - assert hasattr(self.generator, 'sensitivity_checker') - assert hasattr(self.generator, 'context_preserver') - assert self.generator.sensitivity_checker is not None - assert self.generator.context_preserver is not None - - def test_checks_for_denominational_language(self): - """Should check referral messages for denominational language""" - # Mock LLM to return message with denominational language - self.mock_api.generate_response.return_value = ( - "Patient needs prayer support and Bible study for comfort." - ) - - classification = DistressClassification( - flag_level="red", - indicators=["anger", "distress"], - categories=["anger"], - confidence=0.9, - reasoning="Patient expressed anger" - ) - - patient_input = PatientInput( - message="I am angry all the time", - timestamp="2025-12-05T10:00:00Z" - ) - - referral = self.generator.generate_referral(classification, patient_input) - - # The generator should have checked for denominational language - # (logged warnings if found) - assert referral is not None - assert referral.message_text is not None - - def test_preserves_patient_religious_context(self): - """Should preserve religious context when patient mentions it""" - # Mock LLM to return inclusive message - self.mock_api.generate_response.return_value = ( - "Patient expressed anger at God and difficulty with prayer. " - "Spiritual care referral recommended." - ) - - classification = DistressClassification( - flag_level="red", - indicators=["anger at God", "prayer difficulty"], - categories=["anger"], - confidence=0.9, - reasoning="Patient expressed religious distress" - ) - - patient_input = PatientInput( - message="I am angry at God and can't pray anymore", - timestamp="2025-12-05T10:00:00Z" - ) - - referral = self.generator.generate_referral(classification, patient_input) - - # Should preserve religious context - assert "god" in referral.message_text.lower() or "pray" in referral.message_text.lower() - - def test_adds_missing_religious_context(self): - """Should add missing religious context to referral""" - # Mock LLM to return message without religious context - self.mock_api.generate_response.return_value = ( - "Patient expressed anger and emotional distress. " - "Spiritual care referral recommended." - ) - - classification = DistressClassification( - flag_level="red", - indicators=["anger", "distress"], - categories=["anger"], - confidence=0.9, - reasoning="Patient expressed anger" - ) - - patient_input = PatientInput( - message="I am angry at God and can't pray anymore. My faith is shaken.", - timestamp="2025-12-05T10:00:00Z" - ) - - referral = self.generator.generate_referral(classification, patient_input) - - # Should have added religious context - message_lower = referral.message_text.lower() - assert "god" in message_lower or "pray" in message_lower or "faith" in message_lower - - -class TestClarifyingQuestionGeneratorMultiFaith: - """Test multi-faith sensitivity in ClarifyingQuestionGenerator""" - - def setup_method(self): - """Set up test fixtures""" - self.mock_api = Mock(spec=AIClientManager) - self.generator = ClarifyingQuestionGenerator(api=self.mock_api) - - def test_generator_has_sensitivity_checker(self): - """Generator should have sensitivity checker initialized""" - assert hasattr(self.generator, 'sensitivity_checker') - assert self.generator.sensitivity_checker is not None - - def test_validates_questions_for_assumptions(self): - """Should validate questions for religious assumptions""" - # Mock LLM to return non-assumptive questions - self.mock_api.generate_response.return_value = '''{ - "questions": [ - "Can you tell me more about what you're experiencing?", - "How has this been affecting your daily life?", - "What would be most helpful for you right now?" - ] - }''' - - classification = DistressClassification( - flag_level="yellow", - indicators=["mild distress"], - categories=["general"], - confidence=0.6, - reasoning="Ambiguous indicators" - ) - - patient_input = PatientInput( - message="I've been feeling down lately", - timestamp="2025-12-05T10:00:00Z" - ) - - questions = self.generator.generate_questions(classification, patient_input) - - # Should have validated questions - assert len(questions) > 0 - - # Verify questions are non-assumptive - all_valid, issues = self.generator.sensitivity_checker.validate_questions_for_assumptions(questions) - assert all_valid is True - assert len(issues) == 0 - - def test_detects_assumptive_questions(self): - """Should detect and log warnings for assumptive questions""" - # Mock LLM to return assumptive questions - self.mock_api.generate_response.return_value = '''{ - "questions": [ - "How can we support your faith during this time?", - "Would you like to pray with the chaplain?", - "What does God mean to you?" - ] - }''' - - classification = DistressClassification( - flag_level="yellow", - indicators=["mild distress"], - categories=["general"], - confidence=0.6, - reasoning="Ambiguous indicators" - ) - - patient_input = PatientInput( - message="I've been feeling down lately", - timestamp="2025-12-05T10:00:00Z" - ) - - questions = self.generator.generate_questions(classification, patient_input) - - # Should have generated questions (even if problematic) - assert len(questions) > 0 - - # Verify questions are flagged as assumptive - all_valid, issues = self.generator.sensitivity_checker.validate_questions_for_assumptions(questions) - assert all_valid is False - assert len(issues) > 0 - - -class TestMultiFaithSensitivityEndToEnd: - """End-to-end tests for multi-faith sensitivity across diverse scenarios""" - - def setup_method(self): - """Set up test fixtures""" - self.mock_api = Mock(spec=AIClientManager) - self.analyzer = SpiritualDistressAnalyzer( - api=self.mock_api, - definitions_path="data/spiritual_distress_definitions.json" - ) - self.referral_generator = ReferralMessageGenerator(api=self.mock_api) - self.question_generator = ClarifyingQuestionGenerator(api=self.mock_api) - - def test_christian_patient_workflow(self): - """Test complete workflow for Christian patient""" - # Analysis - self.mock_api.generate_response.return_value = '''{ - "flag_level": "red", - "indicators": ["anger at God", "faith crisis"], - "categories": ["anger"], - "confidence": 0.9, - "reasoning": "Patient expressed anger at God and faith crisis" - }''' - - patient_input = PatientInput( - message="I am angry at God and my faith is shaken", - timestamp="2025-12-05T10:00:00Z" - ) - - classification = self.analyzer.analyze_message(patient_input) - - # Verify religion-agnostic detection - is_agnostic = self.analyzer.sensitivity_checker.is_religion_agnostic_detection( - patient_input.message, - classification.indicators - ) - assert is_agnostic is True - - # Referral generation - self.mock_api.generate_response.return_value = ( - "Patient expressed anger at God and concerns about faith. " - "Spiritual care referral recommended for support." - ) - - referral = self.referral_generator.generate_referral(classification, patient_input) - - # Verify religious context preserved - assert "god" in referral.message_text.lower() or "faith" in referral.message_text.lower() - - def test_muslim_patient_workflow(self): - """Test complete workflow for Muslim patient""" - self.mock_api.generate_response.return_value = '''{ - "flag_level": "yellow", - "indicators": ["disconnection", "spiritual concern"], - "categories": ["meaning"], - "confidence": 0.7, - "reasoning": "Patient expressed feeling disconnected" - }''' - - patient_input = PatientInput( - message="I feel disconnected from Allah and the mosque", - timestamp="2025-12-05T10:00:00Z" - ) - - classification = self.analyzer.analyze_message(patient_input) - - # Generate questions - self.mock_api.generate_response.return_value = '''{ - "questions": [ - "Can you tell me more about this feeling of disconnection?", - "How long have you been experiencing this?", - "What would help you feel more connected?" - ] - }''' - - questions = self.question_generator.generate_questions(classification, patient_input) - - # Verify questions are non-assumptive - all_valid, issues = self.question_generator.sensitivity_checker.validate_questions_for_assumptions(questions) - assert all_valid is True - - def test_atheist_patient_workflow(self): - """Test complete workflow for atheist patient""" - self.mock_api.generate_response.return_value = '''{ - "flag_level": "red", - "indicators": ["meaninglessness", "existential distress"], - "categories": ["meaning"], - "confidence": 0.85, - "reasoning": "Patient expressed lack of meaning and purpose" - }''' - - patient_input = PatientInput( - message="I am an atheist and life has no meaning or purpose", - timestamp="2025-12-05T10:00:00Z" - ) - - classification = self.analyzer.analyze_message(patient_input) - - # Verify religion-agnostic detection - is_agnostic = self.analyzer.sensitivity_checker.is_religion_agnostic_detection( - patient_input.message, - classification.indicators - ) - assert is_agnostic is True - - # Referral should use inclusive language - self.mock_api.generate_response.return_value = ( - "Patient expressed concerns about meaning and purpose in life. " - "Spiritual care referral recommended for existential support." - ) - - referral = self.referral_generator.generate_referral(classification, patient_input) - - # Should not contain denominational language - has_issues, terms = self.referral_generator.sensitivity_checker.check_for_denominational_language( - referral.message_text, - patient_context=patient_input.message - ) - assert has_issues is False - - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) diff --git a/tests/spiritual/test_multi_faith_sensitivity.py b/tests/spiritual/test_multi_faith_sensitivity.py deleted file mode 100644 index 98f3ae23daf2be97b2944db3300d0b93bfdb9c91..0000000000000000000000000000000000000000 --- a/tests/spiritual/test_multi_faith_sensitivity.py +++ /dev/null @@ -1,376 +0,0 @@ -#!/usr/bin/env python3 -""" -Tests for Multi-Faith Sensitivity Features - -Tests Requirements 7.1, 7.2, 7.3, 7.4: -- Religion-agnostic detection -- Inclusive, non-denominational language in outputs -- Religious context preservation -- Non-assumptive questions -""" - -import pytest -from src.core.multi_faith_sensitivity import ( - MultiFaithSensitivityChecker, - ReligiousContextPreserver -) - - -class TestMultiFaithSensitivityChecker: - """Test the MultiFaithSensitivityChecker class""" - - def setup_method(self): - """Set up test fixtures""" - self.checker = MultiFaithSensitivityChecker() - - # Requirement 7.2: Check for denominational language - - def test_detects_christian_terms(self): - """Should detect Christian-specific terms""" - text = "We recommend prayer and reading the Bible for comfort." - has_issues, terms = self.checker.check_for_denominational_language(text) - - assert has_issues is True - assert len(terms) > 0 - assert any('prayer' in term.lower() or 'pray' in term.lower() for term in terms) - - def test_detects_islamic_terms(self): - """Should detect Islamic-specific terms""" - text = "The patient should visit the mosque and speak with the imam." - has_issues, terms = self.checker.check_for_denominational_language(text) - - assert has_issues is True - assert any('mosque' in term.lower() for term in terms) - - def test_detects_jewish_terms(self): - """Should detect Jewish-specific terms""" - text = "Consider attending synagogue and speaking with the rabbi." - has_issues, terms = self.checker.check_for_denominational_language(text) - - assert has_issues is True - assert any('synagogue' in term.lower() for term in terms) - - def test_detects_buddhist_terms(self): - """Should detect Buddhist-specific terms""" - text = "The patient may benefit from meditation at the temple." - has_issues, terms = self.checker.check_for_denominational_language(text) - - assert has_issues is True - # Note: 'meditation' and 'temple' are in the list - assert len(terms) > 0 - - def test_allows_patient_initiated_terms(self): - """Should allow denominational terms if patient mentioned them""" - patient_context = "I am struggling with my prayer life and faith in God." - referral_text = "Patient expressed concerns about prayer and relationship with God." - - has_issues, terms = self.checker.check_for_denominational_language( - referral_text, - patient_context=patient_context - ) - - # Should not flag issues because patient mentioned these terms - assert has_issues is False - - def test_accepts_inclusive_language(self): - """Should accept inclusive, non-denominational language""" - text = "Patient may benefit from spiritual care and chaplaincy services for emotional support." - has_issues, terms = self.checker.check_for_denominational_language(text) - - assert has_issues is False - assert len(terms) == 0 - - def test_suggests_inclusive_alternatives(self): - """Should suggest inclusive alternatives for denominational terms""" - text = "Patient needs prayer and faith support from the church." - suggestions = self.checker.suggest_inclusive_alternatives(text) - - assert 'prayer' in suggestions - assert 'faith' in suggestions - assert 'church' in suggestions - assert 'reflection' in suggestions['prayer'] or 'meditation' in suggestions['prayer'] - - # Requirement 7.3: Extract and preserve religious context - - def test_extracts_religious_context_christian(self): - """Should extract Christian religious context from patient message""" - message = "I am angry at God and can't pray anymore. My faith is shaken." - context = self.checker.extract_religious_context(message) - - assert context['has_religious_content'] is True - assert len(context['mentioned_terms']) > 0 - assert any('god' in term.lower() for term in context['mentioned_terms']) - assert any('pray' in term.lower() for term in context['mentioned_terms']) - assert len(context['religious_concerns']) > 0 - - def test_extracts_religious_context_muslim(self): - """Should extract Islamic religious context from patient message""" - message = "I haven't been to the mosque in months and feel disconnected from Allah." - context = self.checker.extract_religious_context(message) - - assert context['has_religious_content'] is True - assert any('mosque' in term.lower() for term in context['mentioned_terms']) - assert any('allah' in term.lower() for term in context['mentioned_terms']) - - def test_extracts_religious_context_jewish(self): - """Should extract Jewish religious context from patient message""" - message = "I can't attend synagogue anymore and feel guilty about not keeping kosher." - context = self.checker.extract_religious_context(message) - - assert context['has_religious_content'] is True - assert any('synagogue' in term.lower() for term in context['mentioned_terms']) - assert any('kosher' in term.lower() for term in context['mentioned_terms']) - - def test_no_religious_context_in_neutral_message(self): - """Should not extract religious context from neutral messages""" - message = "I am feeling sad and overwhelmed with everything going on." - context = self.checker.extract_religious_context(message) - - assert context['has_religious_content'] is False - assert len(context['mentioned_terms']) == 0 - assert len(context['religious_concerns']) == 0 - - # Requirement 7.4: Validate questions for assumptions - - def test_detects_assumptive_questions_about_faith(self): - """Should detect questions that assume patient has faith""" - questions = [ - "How can we support your faith during this difficult time?", - "What does your religion teach about suffering?" - ] - all_valid, issues = self.checker.validate_questions_for_assumptions(questions) - - assert all_valid is False - assert len(issues) > 0 - - def test_detects_assumptive_questions_about_prayer(self): - """Should detect questions that assume patient prays""" - questions = [ - "Would you like to pray with the chaplain?", - "How has your prayer life been affected?" - ] - all_valid, issues = self.checker.validate_questions_for_assumptions(questions) - - assert all_valid is False - assert len(issues) > 0 - - def test_detects_assumptive_questions_about_god(self): - """Should detect questions that assume belief in God""" - questions = [ - "What does God mean to you in this situation?", - "How do you feel about God right now?" - ] - all_valid, issues = self.checker.validate_questions_for_assumptions(questions) - - assert all_valid is False - assert len(issues) > 0 - - def test_accepts_non_assumptive_questions(self): - """Should accept questions that don't make religious assumptions""" - questions = [ - "Can you tell me more about what you're experiencing?", - "What would be most helpful for you right now?", - "How has this been affecting your daily life?" - ] - all_valid, issues = self.checker.validate_questions_for_assumptions(questions) - - assert all_valid is True - assert len(issues) == 0 - - def test_detects_denominational_terms_in_questions(self): - """Should detect denominational terms in questions""" - questions = [ - "Have you spoken with your pastor about this?", - "Does your church community know about your struggles?" - ] - all_valid, issues = self.checker.validate_questions_for_assumptions(questions) - - assert all_valid is False - assert len(issues) > 0 - - # Requirement 7.1: Religion-agnostic detection - - def test_validates_religion_agnostic_detection_emotional_focus(self): - """Should validate detection that focuses on emotional states""" - message = "I am a Christian and I am angry all the time." - indicators = ["persistent anger", "emotional distress"] - - is_agnostic = self.checker.is_religion_agnostic_detection(message, indicators) - - # Should be agnostic because indicators focus on emotional state, not religious identity - assert is_agnostic is True - - def test_detects_non_agnostic_detection_identity_focus(self): - """Should detect when classification focuses on religious identity""" - message = "I am a Buddhist struggling with meaning." - indicators = ["buddhist identity", "religious affiliation"] - - is_agnostic = self.checker.is_religion_agnostic_detection(message, indicators) - - # Should not be agnostic because indicators focus on religious identity - assert is_agnostic is False - - def test_validates_agnostic_detection_across_religions(self): - """Should validate agnostic detection works across different religions""" - test_cases = [ - ("I am Muslim and feeling hopeless", ["hopelessness", "despair"]), - ("As a Jew, I am crying all the time", ["persistent sadness", "crying"]), - ("I'm Hindu and angry at everything", ["anger", "frustration"]), - ("I'm atheist and feel no meaning in life", ["meaninglessness", "existential distress"]) - ] - - for message, indicators in test_cases: - is_agnostic = self.checker.is_religion_agnostic_detection(message, indicators) - assert is_agnostic is True, f"Failed for: {message}" - - -class TestReligiousContextPreserver: - """Test the ReligiousContextPreserver class""" - - def setup_method(self): - """Set up test fixtures""" - self.checker = MultiFaithSensitivityChecker() - self.preserver = ReligiousContextPreserver(self.checker) - - # Requirement 7.3: Preserve religious context in referrals - - def test_detects_preserved_context(self): - """Should detect when religious context is preserved in referral""" - patient_message = "I am angry at God and can't pray anymore." - referral_text = "Patient expressed anger at God and difficulty with prayer." - - preserved, explanation = self.preserver.ensure_context_in_referral( - patient_message, - referral_text - ) - - assert preserved is True - assert "preserved" in explanation.lower() - - def test_detects_missing_context(self): - """Should detect when religious context is missing from referral""" - patient_message = "I am angry at God and can't pray anymore." - referral_text = "Patient expressed anger and emotional distress." - - preserved, explanation = self.preserver.ensure_context_in_referral( - patient_message, - referral_text - ) - - assert preserved is False - assert "missing" in explanation.lower() - - def test_adds_missing_context_to_referral(self): - """Should add missing religious context to referral""" - patient_message = "I am angry at God and can't pray anymore. My faith is shaken." - referral_text = "Patient expressed anger and emotional distress. Please assess for spiritual care needs." - - updated_referral = self.preserver.add_missing_context( - patient_message, - referral_text - ) - - # Should contain the religious context - assert "god" in updated_referral.lower() or "pray" in updated_referral.lower() - assert "RELIGIOUS CONTEXT" in updated_referral or "religious" in updated_referral.lower() - - def test_preserves_muslim_context(self): - """Should preserve Islamic religious context""" - patient_message = "I haven't been to the mosque and feel disconnected from Allah." - referral_text = "Patient reports feeling disconnected and mentions concerns about mosque attendance and relationship with Allah." - - preserved, explanation = self.preserver.ensure_context_in_referral( - patient_message, - referral_text - ) - - assert preserved is True - - def test_preserves_jewish_context(self): - """Should preserve Jewish religious context""" - patient_message = "I can't attend synagogue and feel guilty about not keeping kosher." - referral_text = "Patient expressed guilt about synagogue attendance and kosher observance." - - preserved, explanation = self.preserver.ensure_context_in_referral( - patient_message, - referral_text - ) - - assert preserved is True - - def test_no_context_to_preserve(self): - """Should handle messages with no religious context""" - patient_message = "I am feeling sad and overwhelmed." - referral_text = "Patient expressed sadness and feeling overwhelmed." - - preserved, explanation = self.preserver.ensure_context_in_referral( - patient_message, - referral_text - ) - - # Should be True because there's no context to preserve - assert preserved is True - assert "no religious context" in explanation.lower() - - -class TestMultiFaithSensitivityIntegration: - """Integration tests for multi-faith sensitivity across diverse scenarios""" - - def setup_method(self): - """Set up test fixtures""" - self.checker = MultiFaithSensitivityChecker() - - def test_diverse_religious_backgrounds(self): - """Should handle diverse religious backgrounds appropriately""" - test_cases = [ - { - 'religion': 'Christian', - 'message': 'I am angry at God and my faith is shaken', - 'good_referral': 'Patient expressed anger at God and concerns about faith', - 'bad_referral': 'Patient needs prayer and Bible study' - }, - { - 'religion': 'Muslim', - 'message': 'I feel disconnected from Allah and the mosque', - 'good_referral': 'Patient reports feeling disconnected from Allah and mosque community', - 'bad_referral': 'Patient should increase prayer and Quran reading' - }, - { - 'religion': 'Jewish', - 'message': 'I feel guilty about not keeping kosher', - 'good_referral': 'Patient expressed guilt about kosher observance', - 'bad_referral': 'Patient needs to speak with rabbi about Torah teachings' - }, - { - 'religion': 'Buddhist', - 'message': 'I am struggling with meditation and finding peace', - 'good_referral': 'Patient reports difficulty with meditation practice and inner peace', - 'bad_referral': 'Patient should visit temple and seek enlightenment' - }, - { - 'religion': 'Atheist', - 'message': 'I feel no meaning or purpose in life', - 'good_referral': 'Patient expressed concerns about meaning and purpose', - 'bad_referral': 'Patient needs spiritual guidance and faith support' - } - ] - - for case in test_cases: - # Good referral should preserve context without extra denominational language - has_issues_good, _ = self.checker.check_for_denominational_language( - case['good_referral'], - patient_context=case['message'] - ) - - # Bad referral should have issues (denominational language not from patient) - has_issues_bad, _ = self.checker.check_for_denominational_language( - case['bad_referral'], - patient_context=case['message'] - ) - - assert has_issues_good is False, f"Good referral flagged for {case['religion']}" - assert has_issues_bad is True, f"Bad referral not flagged for {case['religion']}" - - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) diff --git a/tests/spiritual/test_referral_generator.py b/tests/spiritual/test_referral_generator.py deleted file mode 100644 index 4ca3a31c1908b494d38a87e9266ba993277147e5..0000000000000000000000000000000000000000 --- a/tests/spiritual/test_referral_generator.py +++ /dev/null @@ -1,173 +0,0 @@ -""" -Test script for ReferralMessageGenerator - -This script tests the basic functionality of the referral message generator. -""" - -import sys -import os - -# Add src to path -sys.path.insert(0, os.path.abspath('.')) - -from src.core.spiritual_analyzer import ReferralMessageGenerator -from src.core.spiritual_classes import PatientInput, DistressClassification -from src.core.ai_client import AIClientManager -from datetime import datetime - - -def test_referral_generator_basic(): - """Test basic referral message generation""" - print("=" * 60) - print("Testing ReferralMessageGenerator - Basic Functionality") - print("=" * 60) - - # Initialize AIClientManager - try: - api = AIClientManager() - print("✓ AIClientManager initialized") - except Exception as e: - print(f"✗ Failed to initialize AIClientManager: {e}") - return False - - # Create ReferralMessageGenerator - try: - generator = ReferralMessageGenerator(api) - print("✓ ReferralMessageGenerator created") - except Exception as e: - print(f"✗ Failed to create ReferralMessageGenerator: {e}") - return False - - # Create test data - patient_input = PatientInput( - message="I am angry all the time and I can't control it anymore", - timestamp=datetime.now().isoformat(), - conversation_history=["Patient mentioned feeling frustrated", "Patient discussed family issues"] - ) - - classification = DistressClassification( - flag_level="red", - indicators=["persistent anger", "loss of control", "emotional distress"], - categories=["anger", "emotional_suffering"], - confidence=0.92, - reasoning="Patient explicitly states persistent, uncontrollable anger which is a clear red flag indicator requiring immediate spiritual care referral." - ) - - print("\nTest Input:") - print(f" Patient Message: {patient_input.message}") - print(f" Flag Level: {classification.flag_level}") - print(f" Indicators: {classification.indicators}") - print(f" Categories: {classification.categories}") - - # Generate referral message - try: - print("\n🔄 Generating referral message...") - referral = generator.generate_referral(classification, patient_input) - print("✓ Referral message generated successfully") - - # Display results - print("\n" + "=" * 60) - print("GENERATED REFERRAL MESSAGE") - print("=" * 60) - print(f"\nPatient Concerns:\n{referral.patient_concerns}") - print(f"\nDistress Indicators:\n{', '.join(referral.distress_indicators)}") - print(f"\nContext:\n{referral.context}") - print(f"\nReferral Message:\n{referral.message_text}") - print(f"\nTimestamp: {referral.timestamp}") - print("=" * 60) - - # Validate referral message structure - assert referral.patient_concerns, "Patient concerns should not be empty" - assert referral.distress_indicators, "Distress indicators should not be empty" - assert referral.message_text, "Message text should not be empty" - assert referral.timestamp, "Timestamp should not be empty" - - # Check for multi-faith inclusive language (should not contain denominational terms) - denominational_terms = ["prayer", "God", "salvation", "blessing", "Jesus", "Allah"] - message_lower = referral.message_text.lower() - found_terms = [term for term in denominational_terms if term.lower() in message_lower] - - if found_terms: - print(f"\n⚠️ Warning: Found potentially denominational terms: {found_terms}") - print(" (This is OK if patient mentioned them, otherwise should be avoided)") - else: - print("\n✓ Message uses multi-faith inclusive language") - - # Check that patient concerns are included - if "angry" in referral.message_text.lower() or "anger" in referral.message_text.lower(): - print("✓ Patient concerns (anger) are included in referral") - else: - print("⚠️ Warning: Patient concerns may not be clearly included") - - # Check that indicators are mentioned - indicators_mentioned = sum(1 for ind in classification.indicators if ind.lower() in referral.message_text.lower()) - print(f"✓ {indicators_mentioned}/{len(classification.indicators)} indicators mentioned in referral") - - print("\n✅ All basic tests passed!") - return True - - except Exception as e: - print(f"\n✗ Error generating referral message: {e}") - import traceback - traceback.print_exc() - return False - - -def test_referral_generator_yellow_flag(): - """Test referral generation with yellow flag (should still work)""" - print("\n" + "=" * 60) - print("Testing ReferralMessageGenerator - Yellow Flag Case") - print("=" * 60) - - try: - api = AIClientManager() - generator = ReferralMessageGenerator(api) - - patient_input = PatientInput( - message="I've been feeling down lately and things are bothering me more than usual", - timestamp=datetime.now().isoformat() - ) - - classification = DistressClassification( - flag_level="yellow", - indicators=["mild sadness", "increased irritability"], - categories=["emotional_concern"], - confidence=0.65, - reasoning="Patient shows mild distress indicators that warrant further assessment." - ) - - print(f"\nTest Input: {patient_input.message}") - print(f"Flag Level: {classification.flag_level}") - - referral = generator.generate_referral(classification, patient_input) - - print("\n✓ Yellow flag referral generated successfully") - print(f"Message length: {len(referral.message_text)} characters") - - return True - - except Exception as e: - print(f"✗ Error: {e}") - return False - - -if __name__ == "__main__": - print("\n🧪 REFERRAL MESSAGE GENERATOR TEST SUITE\n") - - # Run tests - test1_passed = test_referral_generator_basic() - test2_passed = test_referral_generator_yellow_flag() - - # Summary - print("\n" + "=" * 60) - print("TEST SUMMARY") - print("=" * 60) - print(f"Basic Functionality: {'✅ PASSED' if test1_passed else '❌ FAILED'}") - print(f"Yellow Flag Case: {'✅ PASSED' if test2_passed else '❌ FAILED'}") - - if test1_passed and test2_passed: - print("\n🎉 All tests passed!") - sys.exit(0) - else: - print("\n❌ Some tests failed") - sys.exit(1) diff --git a/tests/spiritual/test_referral_requirements.py b/tests/spiritual/test_referral_requirements.py deleted file mode 100644 index 19659d0a18c1b7e22a07d558a47061160681b055..0000000000000000000000000000000000000000 --- a/tests/spiritual/test_referral_requirements.py +++ /dev/null @@ -1,307 +0,0 @@ -""" -Test ReferralMessageGenerator against requirements - -This test validates that the implementation meets all specified requirements: -- Requirements 2.4, 4.1, 4.2, 4.3, 4.4, 4.5, 7.2, 7.3 -""" - -import sys -import os - -sys.path.insert(0, os.path.abspath('.')) - -from src.core.spiritual_analyzer import ReferralMessageGenerator -from src.core.spiritual_classes import PatientInput, DistressClassification -from src.core.ai_client import AIClientManager -from datetime import datetime - - -def test_requirement_4_2_patient_concerns(): - """ - Requirement 4.2: WHEN generating a referral message THEN the System SHALL - include the patient's expressed concerns - """ - print("\n" + "=" * 60) - print("Testing Requirement 4.2: Patient Concerns Inclusion") - print("=" * 60) - - api = AIClientManager() - generator = ReferralMessageGenerator(api) - - patient_input = PatientInput( - message="I am angry all the time and I can't control it", - timestamp=datetime.now().isoformat() - ) - - classification = DistressClassification( - flag_level="red", - indicators=["persistent anger", "loss of control"], - categories=["anger"], - confidence=0.9, - reasoning="Clear red flag indicators" - ) - - referral = generator.generate_referral(classification, patient_input) - - # Verify patient concerns are included - assert referral.patient_concerns, "Patient concerns should not be empty" - assert "angry" in referral.patient_concerns.lower() or "anger" in referral.patient_concerns.lower(), \ - "Patient concerns should mention anger" - - print(f"✓ Patient concerns included: {referral.patient_concerns[:100]}...") - return True - - -def test_requirement_4_3_distress_indicators(): - """ - Requirement 4.3: WHEN generating a referral message THEN the System SHALL - include the specific distress indicators detected - """ - print("\n" + "=" * 60) - print("Testing Requirement 4.3: Distress Indicators Inclusion") - print("=" * 60) - - api = AIClientManager() - generator = ReferralMessageGenerator(api) - - patient_input = PatientInput( - message="I cry all the time and feel hopeless", - timestamp=datetime.now().isoformat() - ) - - classification = DistressClassification( - flag_level="red", - indicators=["persistent crying", "hopelessness", "emotional distress"], - categories=["sadness", "despair"], - confidence=0.95, - reasoning="Multiple severe distress indicators" - ) - - referral = generator.generate_referral(classification, patient_input) - - # Verify distress indicators are included - assert referral.distress_indicators, "Distress indicators should not be empty" - assert len(referral.distress_indicators) == 3, "Should have 3 indicators" - assert "persistent crying" in referral.distress_indicators, "Should include 'persistent crying'" - assert "hopelessness" in referral.distress_indicators, "Should include 'hopelessness'" - - print(f"✓ Distress indicators included: {referral.distress_indicators}") - return True - - -def test_requirement_4_4_conversation_context(): - """ - Requirement 4.4: WHEN generating a referral message THEN the System SHALL - include relevant context from the conversation - """ - print("\n" + "=" * 60) - print("Testing Requirement 4.4: Conversation Context Inclusion") - print("=" * 60) - - api = AIClientManager() - generator = ReferralMessageGenerator(api) - - patient_input = PatientInput( - message="I can't take this anymore", - timestamp=datetime.now().isoformat(), - conversation_history=[ - "Patient mentioned recent loss of family member", - "Patient discussed feeling isolated", - "Patient expressed difficulty sleeping" - ] - ) - - classification = DistressClassification( - flag_level="red", - indicators=["despair", "emotional crisis"], - categories=["emotional_suffering"], - confidence=0.88, - reasoning="Patient expressing crisis-level distress" - ) - - referral = generator.generate_referral(classification, patient_input) - - # Verify context is included - assert referral.context, "Context should not be empty" - assert len(referral.context) > 0, "Context should have content" - - print(f"✓ Context included: {referral.context[:150]}...") - return True - - -def test_requirement_4_5_professional_language(): - """ - Requirement 4.5: WHEN generating a referral message THEN the System SHALL - use professional, compassionate language appropriate for clinical communication - """ - print("\n" + "=" * 60) - print("Testing Requirement 4.5: Professional Language") - print("=" * 60) - - api = AIClientManager() - generator = ReferralMessageGenerator(api) - - patient_input = PatientInput( - message="I feel terrible and don't know what to do", - timestamp=datetime.now().isoformat() - ) - - classification = DistressClassification( - flag_level="yellow", - indicators=["emotional distress", "uncertainty"], - categories=["emotional_concern"], - confidence=0.7, - reasoning="Moderate distress requiring assessment" - ) - - referral = generator.generate_referral(classification, patient_input) - - # Verify message text exists and has reasonable length - assert referral.message_text, "Message text should not be empty" - assert len(referral.message_text) > 50, "Message should be substantive" - - # Check for unprofessional language (basic check) - unprofessional_terms = ["lol", "omg", "wtf", "crazy", "nuts"] - message_lower = referral.message_text.lower() - found_unprofessional = [term for term in unprofessional_terms if term in message_lower] - - assert not found_unprofessional, f"Message should not contain unprofessional terms: {found_unprofessional}" - - print(f"✓ Professional language used") - print(f" Message length: {len(referral.message_text)} characters") - return True - - -def test_requirement_7_2_inclusive_language(): - """ - Requirement 7.2: WHEN generating referral messages THEN the System SHALL - use inclusive, non-denominational language - """ - print("\n" + "=" * 60) - print("Testing Requirement 7.2: Multi-faith Inclusive Language") - print("=" * 60) - - api = AIClientManager() - generator = ReferralMessageGenerator(api) - - patient_input = PatientInput( - message="I feel spiritually lost and disconnected", - timestamp=datetime.now().isoformat() - ) - - classification = DistressClassification( - flag_level="yellow", - indicators=["spiritual distress", "disconnection"], - categories=["spiritual_concern"], - confidence=0.75, - reasoning="Patient expressing spiritual concerns" - ) - - referral = generator.generate_referral(classification, patient_input) - - # Check that system prompt includes multi-faith guidelines - from src.prompts.spiritual_prompts import SYSTEM_PROMPT_REFERRAL_GENERATOR - system_prompt = SYSTEM_PROMPT_REFERRAL_GENERATOR() - - assert "multi-faith" in system_prompt.lower() or "inclusive" in system_prompt.lower(), \ - "System prompt should include multi-faith guidelines" - assert "non-denominational" in system_prompt.lower(), \ - "System prompt should specify non-denominational language" - - print(f"✓ System prompt includes multi-faith guidelines") - print(f"✓ Referral message generated with inclusive language") - return True - - -def test_requirement_7_3_religious_context_preservation(): - """ - Requirement 7.3: WHEN patient input mentions specific religious concerns THEN - the System SHALL include this information in the referral - """ - print("\n" + "=" * 60) - print("Testing Requirement 7.3: Religious Context Preservation") - print("=" * 60) - - api = AIClientManager() - generator = ReferralMessageGenerator(api) - - patient_input = PatientInput( - message="I've been struggling with my Buddhist meditation practice and feel disconnected from my faith", - timestamp=datetime.now().isoformat() - ) - - classification = DistressClassification( - flag_level="yellow", - indicators=["spiritual struggle", "faith disconnection"], - categories=["spiritual_concern"], - confidence=0.8, - reasoning="Patient expressing specific religious concerns" - ) - - referral = generator.generate_referral(classification, patient_input) - - # Check that the prompt instructs to include patient-mentioned religious concerns - from src.prompts.spiritual_prompts import PROMPT_REFERRAL_GENERATOR - user_prompt = PROMPT_REFERRAL_GENERATOR( - patient_input.message, - classification.indicators, - classification.categories, - classification.reasoning - ) - - assert "buddhist" in patient_input.message.lower(), "Test input should mention Buddhism" - assert "religious concerns" in user_prompt.lower() or "specific religious" in user_prompt.lower(), \ - "Prompt should instruct to include patient-mentioned religious concerns" - - print(f"✓ Prompt instructs to preserve religious context") - print(f"✓ Patient's Buddhist practice mentioned in input") - return True - - -def test_all_requirements(): - """Run all requirement tests""" - print("\n" + "=" * 60) - print("REFERRAL MESSAGE GENERATOR - REQUIREMENTS VALIDATION") - print("=" * 60) - - tests = [ - ("4.2 - Patient Concerns", test_requirement_4_2_patient_concerns), - ("4.3 - Distress Indicators", test_requirement_4_3_distress_indicators), - ("4.4 - Conversation Context", test_requirement_4_4_conversation_context), - ("4.5 - Professional Language", test_requirement_4_5_professional_language), - ("7.2 - Inclusive Language", test_requirement_7_2_inclusive_language), - ("7.3 - Religious Context", test_requirement_7_3_religious_context_preservation), - ] - - results = [] - for name, test_func in tests: - try: - result = test_func() - results.append((name, result)) - except Exception as e: - print(f"\n✗ Test failed: {e}") - import traceback - traceback.print_exc() - results.append((name, False)) - - # Summary - print("\n" + "=" * 60) - print("REQUIREMENTS VALIDATION SUMMARY") - print("=" * 60) - - for name, passed in results: - status = "✅ PASSED" if passed else "❌ FAILED" - print(f"Requirement {name}: {status}") - - all_passed = all(result for _, result in results) - - if all_passed: - print("\n🎉 All requirements validated successfully!") - return 0 - else: - print("\n❌ Some requirements failed validation") - return 1 - - -if __name__ == "__main__": - sys.exit(test_all_requirements()) diff --git a/tests/spiritual/test_spiritual_analyzer.py b/tests/spiritual/test_spiritual_analyzer.py deleted file mode 100644 index 0fea3011e54b861a56844655f4c5a838f02ea282..0000000000000000000000000000000000000000 --- a/tests/spiritual/test_spiritual_analyzer.py +++ /dev/null @@ -1,232 +0,0 @@ -#!/usr/bin/env python3 -""" -Test script for Spiritual Distress Analyzer - -Tests the core functionality following the task requirements. -""" - -import sys -import os -from dotenv import load_dotenv - -# Load environment variables -load_dotenv() - -# Add src to path -sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src')) - -from src.core.ai_client import AIClientManager -from src.core.spiritual_analyzer import SpiritualDistressAnalyzer -from src.core.spiritual_classes import PatientInput - - -def test_analyzer_initialization(): - """Test that analyzer initializes correctly""" - print("\n=== Test 1: Analyzer Initialization ===") - - try: - api = AIClientManager() - analyzer = SpiritualDistressAnalyzer(api) - - print("✓ Analyzer initialized successfully") - print(f"✓ Loaded {len(analyzer.definitions)} definitions") - print(f"✓ Categories: {', '.join(analyzer.definitions_loader.get_all_categories())}") - return True - except Exception as e: - print(f"✗ Initialization failed: {e}") - return False - - -def test_red_flag_detection(): - """Test red flag detection with explicit severe distress""" - print("\n=== Test 2: Red Flag Detection ===") - - try: - api = AIClientManager() - analyzer = SpiritualDistressAnalyzer(api) - - # Test with a clear red flag message - patient_input = PatientInput( - message="I am angry all the time and I can't control it", - timestamp="" - ) - - print(f"Patient message: '{patient_input.message}'") - classification = analyzer.analyze_message(patient_input) - - print(f"✓ Classification: {classification.flag_level}") - print(f"✓ Indicators: {classification.indicators}") - print(f"✓ Categories: {classification.categories}") - print(f"✓ Confidence: {classification.confidence}") - print(f"✓ Reasoning: {classification.reasoning[:100]}...") - - # Verify it's a red flag - if classification.flag_level == "red": - print("✓ Correctly identified as RED FLAG") - return True - else: - print(f"⚠ Expected 'red' but got '{classification.flag_level}'") - return False - - except Exception as e: - print(f"✗ Red flag detection failed: {e}") - import traceback - traceback.print_exc() - return False - - -def test_yellow_flag_detection(): - """Test yellow flag detection with ambiguous indicators""" - print("\n=== Test 3: Yellow Flag Detection ===") - - try: - api = AIClientManager() - analyzer = SpiritualDistressAnalyzer(api) - - # Test with an ambiguous message - patient_input = PatientInput( - message="I've been feeling frustrated lately and things are bothering me more than usual", - timestamp="" - ) - - print(f"Patient message: '{patient_input.message}'") - classification = analyzer.analyze_message(patient_input) - - print(f"✓ Classification: {classification.flag_level}") - print(f"✓ Indicators: {classification.indicators}") - print(f"✓ Categories: {classification.categories}") - print(f"✓ Confidence: {classification.confidence}") - print(f"✓ Reasoning: {classification.reasoning[:100]}...") - - # Verify it's a yellow flag - if classification.flag_level == "yellow": - print("✓ Correctly identified as YELLOW FLAG") - return True - else: - print(f"⚠ Expected 'yellow' but got '{classification.flag_level}'") - return False - - except Exception as e: - print(f"✗ Yellow flag detection failed: {e}") - import traceback - traceback.print_exc() - return False - - -def test_no_flag_detection(): - """Test no flag detection with neutral message""" - print("\n=== Test 4: No Flag Detection ===") - - try: - api = AIClientManager() - analyzer = SpiritualDistressAnalyzer(api) - - # Test with a neutral message - patient_input = PatientInput( - message="I have a question about my medication schedule", - timestamp="" - ) - - print(f"Patient message: '{patient_input.message}'") - classification = analyzer.analyze_message(patient_input) - - print(f"✓ Classification: {classification.flag_level}") - print(f"✓ Indicators: {classification.indicators}") - print(f"✓ Categories: {classification.categories}") - print(f"✓ Confidence: {classification.confidence}") - print(f"✓ Reasoning: {classification.reasoning[:100]}...") - - # Verify it's no flag - if classification.flag_level == "none": - print("✓ Correctly identified as NO FLAG") - return True - else: - print(f"⚠ Expected 'none' but got '{classification.flag_level}'") - # This is acceptable due to conservative logic - print(" (Conservative escalation is acceptable)") - return True - - except Exception as e: - print(f"✗ No flag detection failed: {e}") - import traceback - traceback.print_exc() - return False - - -def test_multi_category_detection(): - """Test detection of multiple distress categories""" - print("\n=== Test 5: Multi-Category Detection ===") - - try: - api = AIClientManager() - analyzer = SpiritualDistressAnalyzer(api) - - # Test with message containing multiple indicators - patient_input = PatientInput( - message="I am angry all the time and I am crying all the time. I feel hopeless.", - timestamp="" - ) - - print(f"Patient message: '{patient_input.message}'") - classification = analyzer.analyze_message(patient_input) - - print(f"✓ Classification: {classification.flag_level}") - print(f"✓ Indicators: {classification.indicators}") - print(f"✓ Categories: {classification.categories}") - print(f"✓ Confidence: {classification.confidence}") - print(f"✓ Reasoning: {classification.reasoning[:100]}...") - - # Verify multiple categories detected - if len(classification.categories) > 1: - print(f"✓ Correctly detected {len(classification.categories)} categories") - return True - else: - print(f"⚠ Expected multiple categories but got {len(classification.categories)}") - return False - - except Exception as e: - print(f"✗ Multi-category detection failed: {e}") - import traceback - traceback.print_exc() - return False - - -def main(): - """Run all tests""" - print("=" * 60) - print("SPIRITUAL DISTRESS ANALYZER - CORE FUNCTIONALITY TESTS") - print("=" * 60) - - results = [] - - # Run tests - results.append(("Initialization", test_analyzer_initialization())) - results.append(("Red Flag Detection", test_red_flag_detection())) - results.append(("Yellow Flag Detection", test_yellow_flag_detection())) - results.append(("No Flag Detection", test_no_flag_detection())) - results.append(("Multi-Category Detection", test_multi_category_detection())) - - # Summary - print("\n" + "=" * 60) - print("TEST SUMMARY") - print("=" * 60) - - passed = sum(1 for _, result in results if result) - total = len(results) - - for test_name, result in results: - status = "✓ PASS" if result else "✗ FAIL" - print(f"{status}: {test_name}") - - print(f"\nTotal: {passed}/{total} tests passed") - - if passed == total: - print("\n✓ All tests passed!") - return 0 - else: - print(f"\n⚠ {total - passed} test(s) failed") - return 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/tests/spiritual/test_spiritual_analyzer_structure.py b/tests/spiritual/test_spiritual_analyzer_structure.py deleted file mode 100644 index b0ea28ee468517b8954d0bb38141d4eda3b0730d..0000000000000000000000000000000000000000 --- a/tests/spiritual/test_spiritual_analyzer_structure.py +++ /dev/null @@ -1,263 +0,0 @@ -#!/usr/bin/env python3 -""" -Structure test for Spiritual Distress Analyzer - -Verifies the implementation follows the required patterns without needing AI provider. -""" - -import sys -import os - -# Add src to path -sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src')) - -from src.core.ai_client import AIClientManager -from src.core.spiritual_analyzer import SpiritualDistressAnalyzer -from src.core.spiritual_classes import PatientInput, DistressClassification -from src.prompts.spiritual_prompts import SYSTEM_PROMPT_SPIRITUAL_ANALYZER, PROMPT_SPIRITUAL_ANALYZER - - -def test_class_structure(): - """Verify the class follows the required structure""" - print("\n=== Test: Class Structure ===") - - # Check class exists and has required methods - assert hasattr(SpiritualDistressAnalyzer, '__init__'), "Missing __init__ method" - assert hasattr(SpiritualDistressAnalyzer, 'analyze_message'), "Missing analyze_message method" - - print("✓ SpiritualDistressAnalyzer class has required methods") - - # Check initialization signature - import inspect - init_sig = inspect.signature(SpiritualDistressAnalyzer.__init__) - params = list(init_sig.parameters.keys()) - - assert 'self' in params, "Missing self parameter" - assert 'api' in params, "Missing api parameter" - - print("✓ __init__ has correct signature: (self, api: AIClientManager)") - - return True - - -def test_prompt_functions(): - """Verify prompt functions exist and return strings""" - print("\n=== Test: Prompt Functions ===") - - # Test SYSTEM_PROMPT_SPIRITUAL_ANALYZER - system_prompt = SYSTEM_PROMPT_SPIRITUAL_ANALYZER() - assert isinstance(system_prompt, str), "SYSTEM_PROMPT_SPIRITUAL_ANALYZER must return string" - assert len(system_prompt) > 0, "System prompt cannot be empty" - assert "spiritual" in system_prompt.lower(), "System prompt should mention spiritual" - - print("✓ SYSTEM_PROMPT_SPIRITUAL_ANALYZER() returns valid string") - - # Test PROMPT_SPIRITUAL_ANALYZER - test_definitions = { - "anger": { - "definition": "Test definition", - "red_flag_examples": ["example1"], - "yellow_flag_examples": ["example2"], - "keywords": ["angry"] - } - } - user_prompt = PROMPT_SPIRITUAL_ANALYZER("test message", test_definitions) - assert isinstance(user_prompt, str), "PROMPT_SPIRITUAL_ANALYZER must return string" - assert len(user_prompt) > 0, "User prompt cannot be empty" - assert "test message" in user_prompt, "User prompt should contain patient message" - - print("✓ PROMPT_SPIRITUAL_ANALYZER() returns valid string with patient message") - - return True - - -def test_initialization(): - """Test analyzer initialization""" - print("\n=== Test: Initialization ===") - - try: - api = AIClientManager() - analyzer = SpiritualDistressAnalyzer(api) - - # Verify attributes - assert hasattr(analyzer, 'api'), "Missing api attribute" - assert hasattr(analyzer, 'definitions'), "Missing definitions attribute" - assert hasattr(analyzer, 'definitions_loader'), "Missing definitions_loader attribute" - - print("✓ Analyzer initializes with correct attributes") - - # Verify definitions loaded - assert isinstance(analyzer.definitions, dict), "Definitions should be a dictionary" - assert len(analyzer.definitions) > 0, "Definitions should not be empty" - - print(f"✓ Loaded {len(analyzer.definitions)} definitions") - - return True - except Exception as e: - print(f"✗ Initialization failed: {e}") - return False - - -def test_analyze_message_signature(): - """Test analyze_message method signature""" - print("\n=== Test: analyze_message Signature ===") - - import inspect - - api = AIClientManager() - analyzer = SpiritualDistressAnalyzer(api) - - # Check method signature - sig = inspect.signature(analyzer.analyze_message) - params = list(sig.parameters.keys()) - - assert 'patient_input' in params, "Missing patient_input parameter" - - print("✓ analyze_message has correct signature: (patient_input: PatientInput)") - - # Check return type annotation - return_annotation = sig.return_annotation - assert return_annotation == DistressClassification, "Should return DistressClassification" - - print("✓ analyze_message returns DistressClassification") - - return True - - -def test_conservative_logic(): - """Test conservative classification logic""" - print("\n=== Test: Conservative Logic ===") - - api = AIClientManager() - analyzer = SpiritualDistressAnalyzer(api) - - # Test _apply_conservative_logic method exists - assert hasattr(analyzer, '_apply_conservative_logic'), "Missing _apply_conservative_logic method" - - # Test conservative logic with low confidence - test_classification = DistressClassification( - flag_level="none", - indicators=[], - categories=[], - confidence=0.3, - reasoning="Test" - ) - - adjusted = analyzer._apply_conservative_logic(test_classification) - - # Should escalate to yellow due to low confidence - assert adjusted.flag_level == "yellow", "Should escalate to yellow with low confidence" - print("✓ Conservative logic escalates low confidence 'none' to 'yellow'") - - # Test with indicators but no flag - test_classification2 = DistressClassification( - flag_level="none", - indicators=["test_indicator"], - categories=[], - confidence=0.8, - reasoning="Test" - ) - - adjusted2 = analyzer._apply_conservative_logic(test_classification2) - - # Should escalate to yellow due to indicators - assert adjusted2.flag_level == "yellow", "Should escalate to yellow when indicators present" - print("✓ Conservative logic escalates 'none' with indicators to 'yellow'") - - return True - - -def test_json_parsing(): - """Test JSON response parsing""" - print("\n=== Test: JSON Parsing ===") - - api = AIClientManager() - analyzer = SpiritualDistressAnalyzer(api) - - # Test parsing clean JSON - test_json = '{"flag_level": "red", "indicators": ["test"], "categories": ["anger"], "confidence": 0.9, "reasoning": "test"}' - result = analyzer._parse_json_response(test_json) - - assert isinstance(result, dict), "Should return dictionary" - assert result["flag_level"] == "red", "Should parse flag_level correctly" - print("✓ Parses clean JSON correctly") - - # Test parsing JSON with markdown code blocks - test_json_markdown = '```json\n{"flag_level": "yellow", "indicators": [], "categories": [], "confidence": 0.5, "reasoning": "test"}\n```' - result2 = analyzer._parse_json_response(test_json_markdown) - - assert isinstance(result2, dict), "Should return dictionary" - assert result2["flag_level"] == "yellow", "Should parse flag_level from markdown" - print("✓ Parses JSON with markdown code blocks") - - return True - - -def test_error_handling(): - """Test error handling and safe defaults""" - print("\n=== Test: Error Handling ===") - - api = AIClientManager() - analyzer = SpiritualDistressAnalyzer(api) - - # Test safe default classification - safe_default = analyzer._create_safe_default_classification("Test error") - - assert isinstance(safe_default, DistressClassification), "Should return DistressClassification" - assert safe_default.flag_level == "yellow", "Safe default should be yellow flag" - assert safe_default.confidence == 0.0, "Safe default should have 0 confidence" - assert "Test error" in safe_default.reasoning, "Should include error message" - - print("✓ Creates safe default classification on error") - print(f"✓ Safe default: flag_level='{safe_default.flag_level}', confidence={safe_default.confidence}") - - return True - - -def main(): - """Run all structure tests""" - print("=" * 60) - print("SPIRITUAL DISTRESS ANALYZER - STRUCTURE VERIFICATION") - print("=" * 60) - - results = [] - - # Run tests - results.append(("Class Structure", test_class_structure())) - results.append(("Prompt Functions", test_prompt_functions())) - results.append(("Initialization", test_initialization())) - results.append(("analyze_message Signature", test_analyze_message_signature())) - results.append(("Conservative Logic", test_conservative_logic())) - results.append(("JSON Parsing", test_json_parsing())) - results.append(("Error Handling", test_error_handling())) - - # Summary - print("\n" + "=" * 60) - print("TEST SUMMARY") - print("=" * 60) - - passed = sum(1 for _, result in results if result) - total = len(results) - - for test_name, result in results: - status = "✓ PASS" if result else "✗ FAIL" - print(f"{status}: {test_name}") - - print(f"\nTotal: {passed}/{total} tests passed") - - if passed == total: - print("\n✓ All structure tests passed!") - print("\nImplementation follows required patterns:") - print(" - Uses AIClientManager for LLM calls") - print(" - Follows EntryClassifier/MedicalAssistant pattern") - print(" - Implements JSON response parsing") - print(" - Has conservative classification logic") - print(" - Returns DistressClassification objects") - return 0 - else: - print(f"\n⚠ {total - passed} test(s) failed") - return 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/tests/spiritual/test_spiritual_app.py b/tests/spiritual/test_spiritual_app.py deleted file mode 100644 index 1e5aeb5c4da6ff228dc4f99302ee166c37cdc86c..0000000000000000000000000000000000000000 --- a/tests/spiritual/test_spiritual_app.py +++ /dev/null @@ -1,321 +0,0 @@ -#!/usr/bin/env python3 -""" -Test script for Spiritual Health Assessment App - -Tests the main application class and integration of all components. -""" - -import sys -import logging - -# Configure logging -logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' -) - -def test_app_initialization(): - """Test that the app can be initialized""" - print("Testing app initialization...") - - try: - from spiritual_app import SpiritualHealthApp, create_app - - print("✅ Successfully imported spiritual_app module") - - # Test direct initialization - app = SpiritualHealthApp() - print(f"✅ Created SpiritualHealthApp instance") - - # Verify app has required components - assert hasattr(app, 'api'), "App missing 'api' attribute" - assert hasattr(app, 'analyzer'), "App missing 'analyzer' attribute" - assert hasattr(app, 'referral_generator'), "App missing 'referral_generator' attribute" - assert hasattr(app, 'question_generator'), "App missing 'question_generator' attribute" - assert hasattr(app, 'feedback_store'), "App missing 'feedback_store' attribute" - print("✅ App has all required components") - - # Test convenience function - app2 = create_app() - print("✅ create_app() function works") - - return True - - except Exception as e: - print(f"❌ Error: {e}") - import traceback - traceback.print_exc() - return False - - -def test_process_assessment(): - """Test the process_assessment method""" - print("\nTesting process_assessment method...") - - try: - from spiritual_app import SpiritualHealthApp - - app = SpiritualHealthApp() - - # Test with red flag message - print("\n--- Testing RED FLAG assessment ---") - classification, referral, questions, status = app.process_assessment( - "I am angry all the time and I can't stop crying" - ) - - print(f"Flag Level: {classification.flag_level}") - print(f"Indicators: {classification.indicators}") - print(f"Confidence: {classification.confidence:.2%}") - print(f"Status: {status[:100]}...") - - assert classification is not None, "Classification is None" - assert classification.flag_level in ["red", "yellow", "none"], f"Invalid flag level: {classification.flag_level}" - print("✅ Red flag assessment works") - - # Test with yellow flag message - print("\n--- Testing YELLOW FLAG assessment ---") - classification2, referral2, questions2, status2 = app.process_assessment( - "I've been feeling frustrated lately" - ) - - print(f"Flag Level: {classification2.flag_level}") - print(f"Questions: {len(questions2)}") - - assert classification2 is not None, "Classification is None" - print("✅ Yellow flag assessment works") - - # Test with no flag message - print("\n--- Testing NO FLAG assessment ---") - classification3, referral3, questions3, status3 = app.process_assessment( - "I'm doing well today and feeling optimistic" - ) - - print(f"Flag Level: {classification3.flag_level}") - - assert classification3 is not None, "Classification is None" - print("✅ No flag assessment works") - - # Test empty input handling - print("\n--- Testing EMPTY INPUT handling ---") - classification4, referral4, questions4, status4 = app.process_assessment("") - - print(f"Status: {status4}") - assert "empty" in status4.lower() or "error" in status4.lower(), "Empty input not handled" - print("✅ Empty input handling works") - - return True - - except Exception as e: - print(f"❌ Error: {e}") - import traceback - traceback.print_exc() - return False - - -def test_feedback_submission(): - """Test feedback submission""" - print("\nTesting feedback submission...") - - try: - from spiritual_app import SpiritualHealthApp - - app = SpiritualHealthApp() - - # First, create an assessment - classification, referral, questions, status = app.process_assessment( - "I am angry all the time" - ) - - print(f"Assessment created: {classification.flag_level}") - - # Submit feedback - success, message = app.submit_feedback( - provider_id="test_provider", - agrees_with_classification=True, - agrees_with_referral=True, - comments="Test feedback" - ) - - print(f"Feedback submission: {message}") - assert success, "Feedback submission failed" - print("✅ Feedback submission works") - - # Test feedback without assessment - app2 = SpiritualHealthApp() - success2, message2 = app2.submit_feedback( - provider_id="test_provider", - agrees_with_classification=True, - agrees_with_referral=False, - comments="" - ) - - print(f"No assessment feedback: {message2}") - assert not success2, "Should fail without assessment" - print("✅ Feedback validation works") - - return True - - except Exception as e: - print(f"❌ Error: {e}") - import traceback - traceback.print_exc() - return False - - -def test_metrics_and_export(): - """Test metrics and export functionality""" - print("\nTesting metrics and export...") - - try: - from spiritual_app import SpiritualHealthApp - - app = SpiritualHealthApp() - - # Get metrics (should work even with no data) - metrics = app.get_feedback_metrics() - print(f"Metrics: {metrics['total_assessments']} assessments") - assert 'total_assessments' in metrics, "Metrics missing total_assessments" - print("✅ Metrics retrieval works") - - # Test export (may have no data) - success, result = app.export_feedback_data() - print(f"Export result: {result}") - # Don't assert success since there may be no data - print("✅ Export functionality works") - - return True - - except Exception as e: - print(f"❌ Error: {e}") - import traceback - traceback.print_exc() - return False - - -def test_session_management(): - """Test session management""" - print("\nTesting session management...") - - try: - from spiritual_app import SpiritualHealthApp - - app = SpiritualHealthApp() - - # Create some assessments - app.process_assessment("Test message 1") - app.process_assessment("Test message 2") - - # Get history - history = app.get_assessment_history() - print(f"History: {len(history)} assessments") - assert len(history) == 2, f"Expected 2 assessments, got {len(history)}" - print("✅ History tracking works") - - # Get status - status = app.get_status_info() - print(f"Status info length: {len(status)} chars") - assert len(status) > 0, "Status info is empty" - assert "Spiritual Health Assessment Status" in status, "Status missing header" - print("✅ Status info works") - - # Reset session - reset_msg = app.reset_session() - print(f"Reset: {reset_msg}") - - history_after = app.get_assessment_history() - assert len(history_after) == 0, "History not cleared after reset" - print("✅ Session reset works") - - return True - - except Exception as e: - print(f"❌ Error: {e}") - import traceback - traceback.print_exc() - return False - - -def test_re_evaluation(): - """Test re-evaluation functionality""" - print("\nTesting re-evaluation...") - - try: - from spiritual_app import SpiritualHealthApp - - app = SpiritualHealthApp() - - # Create a yellow flag assessment - classification, referral, questions, status = app.process_assessment( - "I've been feeling frustrated lately" - ) - - print(f"Initial classification: {classification.flag_level}") - - if classification.flag_level == "yellow" and questions: - # Re-evaluate with follow-up - new_classification, new_referral, new_status = app.re_evaluate_with_followup( - followup_questions=questions, - followup_answers=["I feel angry all the time", "It's affecting my sleep"] - ) - - print(f"Re-evaluation result: {new_classification.flag_level}") - assert new_classification.flag_level in ["red", "none"], f"Re-evaluation should be red or none, got {new_classification.flag_level}" - print("✅ Re-evaluation works") - else: - print("⚠️ Skipping re-evaluation test (no yellow flag generated)") - - # Test re-evaluation without assessment - app2 = SpiritualHealthApp() - classification2, referral2, status2 = app2.re_evaluate_with_followup( - followup_questions=["Test?"], - followup_answers=["Test answer"] - ) - - print(f"No assessment re-evaluation: {status2}") - assert "No current assessment" in status2, "Should fail without assessment" - print("✅ Re-evaluation validation works") - - return True - - except Exception as e: - print(f"❌ Error: {e}") - import traceback - traceback.print_exc() - return False - - -if __name__ == "__main__": - print("="*60) - print("SPIRITUAL HEALTH APP TEST SUITE") - print("="*60) - - results = [] - - # Run tests - results.append(("App Initialization", test_app_initialization())) - results.append(("Process Assessment", test_process_assessment())) - results.append(("Feedback Submission", test_feedback_submission())) - results.append(("Metrics and Export", test_metrics_and_export())) - results.append(("Session Management", test_session_management())) - results.append(("Re-evaluation", test_re_evaluation())) - - # Summary - print("\n" + "="*60) - print("TEST SUMMARY") - print("="*60) - - passed = sum(1 for _, result in results if result) - total = len(results) - - for test_name, result in results: - status = "✅ PASS" if result else "❌ FAIL" - print(f"{status}: {test_name}") - - print(f"\nTotal: {passed}/{total} tests passed") - - if passed == total: - print("\n🎉 All tests passed! The app is ready to use.") - sys.exit(0) - else: - print("\n⚠️ Some tests failed. Please review the errors above.") - sys.exit(1) diff --git a/tests/spiritual/test_spiritual_classes.py b/tests/spiritual/test_spiritual_classes.py deleted file mode 100644 index ab1b051f192c6884ee47fcaa5ce38296f5fb0689..0000000000000000000000000000000000000000 --- a/tests/spiritual/test_spiritual_classes.py +++ /dev/null @@ -1,241 +0,0 @@ -#!/usr/bin/env python3 -""" -Test script to verify spiritual_classes.py data structures -""" - -from datetime import datetime -from src.core.spiritual_classes import ( - PatientInput, DistressClassification, ReferralMessage, ProviderFeedback, - SpiritualDistressDefinitions -) - - -def test_patient_input(): - """Test PatientInput dataclass""" - print("Testing PatientInput...") - - # Test with minimal fields - input1 = PatientInput( - message="I am angry all the time", - timestamp=datetime.now().isoformat() - ) - assert input1.message == "I am angry all the time" - assert input1.conversation_history == [] - print(" ✅ PatientInput with minimal fields works") - - # Test with conversation history - input2 = PatientInput( - message="I feel better now", - timestamp=datetime.now().isoformat(), - conversation_history=["Previous message 1", "Previous message 2"] - ) - assert len(input2.conversation_history) == 2 - print(" ✅ PatientInput with conversation history works") - - # Test auto-timestamp - input3 = PatientInput(message="Test", timestamp="") - assert input3.timestamp != "" - print(" ✅ PatientInput auto-timestamp works") - - -def test_distress_classification(): - """Test DistressClassification dataclass""" - print("\nTesting DistressClassification...") - - # Test with minimal fields - classification1 = DistressClassification( - flag_level="red" - ) - assert classification1.flag_level == "red" - assert classification1.indicators == [] - assert classification1.categories == [] - assert classification1.confidence == 0.0 - print(" ✅ DistressClassification with minimal fields works") - - # Test with full fields - classification2 = DistressClassification( - flag_level="yellow", - indicators=["persistent anger", "emotional distress"], - categories=["anger", "emotional_suffering"], - confidence=0.75, - reasoning="Patient shows signs of distress", - timestamp=datetime.now().isoformat() - ) - assert len(classification2.indicators) == 2 - assert len(classification2.categories) == 2 - assert classification2.confidence == 0.75 - print(" ✅ DistressClassification with full fields works") - - # Test auto-timestamp - classification3 = DistressClassification(flag_level="none", timestamp="") - assert classification3.timestamp != "" - print(" ✅ DistressClassification auto-timestamp works") - - -def test_referral_message(): - """Test ReferralMessage dataclass""" - print("\nTesting ReferralMessage...") - - # Test with minimal fields - referral1 = ReferralMessage( - patient_concerns="Persistent anger" - ) - assert referral1.patient_concerns == "Persistent anger" - assert referral1.distress_indicators == [] - print(" ✅ ReferralMessage with minimal fields works") - - # Test with full fields - referral2 = ReferralMessage( - patient_concerns="Patient expressing persistent anger", - distress_indicators=["anger", "emotional_distress"], - context="Patient reports feeling angry all the time", - message_text="Referral for spiritual care: Patient expressing...", - timestamp=datetime.now().isoformat() - ) - assert len(referral2.distress_indicators) == 2 - assert referral2.context != "" - print(" ✅ ReferralMessage with full fields works") - - # Test auto-timestamp - referral3 = ReferralMessage(patient_concerns="Test", timestamp="") - assert referral3.timestamp != "" - print(" ✅ ReferralMessage auto-timestamp works") - - -def test_provider_feedback(): - """Test ProviderFeedback dataclass""" - print("\nTesting ProviderFeedback...") - - # Test with minimal fields - feedback1 = ProviderFeedback( - assessment_id="test-123" - ) - assert feedback1.assessment_id == "test-123" - assert feedback1.provider_id == "provider_001" - assert feedback1.agrees_with_classification == False - print(" ✅ ProviderFeedback with minimal fields works") - - # Test with full fields - feedback2 = ProviderFeedback( - assessment_id="test-456", - provider_id="provider_002", - agrees_with_classification=True, - agrees_with_referral=True, - comments="Accurate assessment", - timestamp=datetime.now().isoformat() - ) - assert feedback2.agrees_with_classification == True - assert feedback2.agrees_with_referral == True - assert feedback2.comments == "Accurate assessment" - print(" ✅ ProviderFeedback with full fields works") - - # Test auto-timestamp - feedback3 = ProviderFeedback(assessment_id="test-789", timestamp="") - assert feedback3.timestamp != "" - print(" ✅ ProviderFeedback auto-timestamp works") - - -def test_spiritual_distress_definitions(): - """Test SpiritualDistressDefinitions class""" - print("\nTesting SpiritualDistressDefinitions...") - - # Test loading definitions - definitions = SpiritualDistressDefinitions() - definitions.load_definitions("data/spiritual_distress_definitions.json") - print(" ✅ Definitions loaded successfully") - - # Test get_all_categories - categories = definitions.get_all_categories() - assert len(categories) > 0 - assert "anger" in categories - assert "persistent_sadness" in categories - print(f" ✅ Found {len(categories)} categories") - - # Test get_definition - anger_def = definitions.get_definition("anger") - assert anger_def is not None - assert "anger" in anger_def.lower() - print(" ✅ get_definition() works") - - # Test get_red_flag_examples - red_flags = definitions.get_red_flag_examples("anger") - assert len(red_flags) > 0 - print(f" ✅ get_red_flag_examples() returns {len(red_flags)} examples") - - # Test get_yellow_flag_examples - yellow_flags = definitions.get_yellow_flag_examples("anger") - assert len(yellow_flags) > 0 - print(f" ✅ get_yellow_flag_examples() returns {len(yellow_flags)} examples") - - # Test get_keywords - keywords = definitions.get_keywords("anger") - assert len(keywords) > 0 - print(f" ✅ get_keywords() returns {len(keywords)} keywords") - - # Test get_category_data - category_data = definitions.get_category_data("anger") - assert category_data is not None - assert "definition" in category_data - assert "red_flag_examples" in category_data - assert "yellow_flag_examples" in category_data - assert "keywords" in category_data - print(" ✅ get_category_data() returns complete data") - - # Test non-existent category - result = definitions.get_definition("non_existent") - assert result is None - print(" ✅ Returns None for non-existent category") - - # Test error handling - calling methods before loading - definitions2 = SpiritualDistressDefinitions() - try: - definitions2.get_all_categories() - assert False, "Should have raised RuntimeError" - except RuntimeError: - print(" ✅ Raises RuntimeError when not loaded") - - -def test_ai_client_manager_availability(): - """Test that AIClientManager is available for reuse""" - print("\nTesting AIClientManager availability...") - - try: - from src.core.ai_client import AIClientManager - print(" ✅ AIClientManager imported successfully") - - # Verify it can be instantiated (without actually making API calls) - # We won't instantiate it here to avoid needing API keys - print(" ✅ AIClientManager is available for reuse") - - except ImportError as e: - print(f" ❌ AIClientManager import failed: {e}") - raise - - -def main(): - """Run all tests""" - print("=" * 60) - print("Testing Spiritual Health Assessment Data Classes") - print("=" * 60) - - try: - test_patient_input() - test_distress_classification() - test_referral_message() - test_provider_feedback() - test_spiritual_distress_definitions() - test_ai_client_manager_availability() - - print("\n" + "=" * 60) - print("✅ All tests passed!") - print("=" * 60) - - except Exception as e: - print("\n" + "=" * 60) - print(f"❌ Test failed: {e}") - print("=" * 60) - raise - - -if __name__ == "__main__": - main() diff --git a/tests/spiritual/test_spiritual_interface.py b/tests/spiritual/test_spiritual_interface.py deleted file mode 100644 index fb9d50232458fd562e6424f510c32b4b57c65115..0000000000000000000000000000000000000000 --- a/tests/spiritual/test_spiritual_interface.py +++ /dev/null @@ -1,156 +0,0 @@ -#!/usr/bin/env python3 -""" -Test script for spiritual interface - -Verifies that the interface can be created and basic components work. -""" - -import sys -import logging - -# Configure logging -logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' -) - -def test_interface_creation(): - """Test that the interface can be created""" - print("Testing spiritual interface creation...") - - try: - from src.interface.spiritual_interface import create_spiritual_interface, SessionData - - print("✅ Successfully imported spiritual_interface module") - - # Test SessionData creation - session = SessionData() - print(f"✅ Created SessionData with ID: {session.session_id[:8]}...") - - # Verify session has required components - assert hasattr(session, 'api'), "SessionData missing 'api' attribute" - assert hasattr(session, 'analyzer'), "SessionData missing 'analyzer' attribute" - assert hasattr(session, 'referral_generator'), "SessionData missing 'referral_generator' attribute" - assert hasattr(session, 'question_generator'), "SessionData missing 'question_generator' attribute" - assert hasattr(session, 'feedback_store'), "SessionData missing 'feedback_store' attribute" - print("✅ SessionData has all required components") - - # Test interface creation (don't launch) - print("Creating Gradio interface...") - demo = create_spiritual_interface() - print("✅ Successfully created Gradio interface") - - # Verify it's a Gradio Blocks object - import gradio as gr - assert isinstance(demo, gr.Blocks), "Interface is not a Gradio Blocks object" - print("✅ Interface is a valid Gradio Blocks object") - - print("\n" + "="*60) - print("✅ ALL TESTS PASSED") - print("="*60) - print("\nThe spiritual interface is ready to use!") - print("To launch the interface, run:") - print(" python src/interface/spiritual_interface.py") - - return True - - except ImportError as e: - print(f"❌ Import error: {e}") - return False - except Exception as e: - print(f"❌ Error: {e}") - import traceback - traceback.print_exc() - return False - - -def test_session_isolation(): - """Test that sessions are properly isolated""" - print("\nTesting session isolation...") - - try: - from src.interface.spiritual_interface import SessionData - - # Create two sessions - session1 = SessionData() - session2 = SessionData() - - # Verify they have different IDs - assert session1.session_id != session2.session_id, "Sessions have same ID!" - print(f"✅ Session 1 ID: {session1.session_id[:8]}...") - print(f"✅ Session 2 ID: {session2.session_id[:8]}...") - print("✅ Sessions are properly isolated") - - return True - - except Exception as e: - print(f"❌ Error testing session isolation: {e}") - return False - - -def test_session_methods(): - """Test SessionData methods""" - print("\nTesting SessionData methods...") - - try: - from src.interface.spiritual_interface import SessionData - - session = SessionData() - - # Test update_activity - old_activity = session.last_activity - import time - time.sleep(0.1) - session.update_activity() - assert session.last_activity != old_activity, "Activity timestamp not updated" - print("✅ update_activity() works") - - # Test to_dict - session_dict = session.to_dict() - assert 'session_id' in session_dict, "to_dict missing session_id" - assert 'created_at' in session_dict, "to_dict missing created_at" - assert 'last_activity' in session_dict, "to_dict missing last_activity" - assert 'assessment_count' in session_dict, "to_dict missing assessment_count" - print("✅ to_dict() works") - - return True - - except Exception as e: - print(f"❌ Error testing session methods: {e}") - import traceback - traceback.print_exc() - return False - - -if __name__ == "__main__": - print("="*60) - print("SPIRITUAL INTERFACE TEST SUITE") - print("="*60) - - results = [] - - # Run tests - results.append(("Interface Creation", test_interface_creation())) - results.append(("Session Isolation", test_session_isolation())) - results.append(("Session Methods", test_session_methods())) - - # Summary - print("\n" + "="*60) - print("TEST SUMMARY") - print("="*60) - - passed = sum(1 for _, result in results if result) - total = len(results) - - for test_name, result in results: - status = "✅ PASS" if result else "❌ FAIL" - print(f"{status}: {test_name}") - - print(f"\nTotal: {passed}/{total} tests passed") - - if passed == total: - print("\n🎉 All tests passed! The interface is ready to use.") - sys.exit(0) - else: - print("\n⚠️ Some tests failed. Please review the errors above.") - sys.exit(1) diff --git a/tests/spiritual/test_spiritual_interface_integration.py b/tests/spiritual/test_spiritual_interface_integration.py deleted file mode 100644 index 50d0a952a911be02c17171a800734f1e8b3bb726..0000000000000000000000000000000000000000 --- a/tests/spiritual/test_spiritual_interface_integration.py +++ /dev/null @@ -1,262 +0,0 @@ -#!/usr/bin/env python3 -""" -Integration test for spiritual interface - -Tests the full workflow: analyze -> display -> feedback -""" - -import sys -import logging -from datetime import datetime - -# Configure logging -logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' -) - -def test_full_workflow(): - """Test complete assessment workflow""" - print("Testing full assessment workflow...") - - try: - from src.interface.spiritual_interface import SessionData - from src.core.spiritual_classes import PatientInput - - # Create session - session = SessionData() - print(f"✅ Created session: {session.session_id[:8]}...") - - # Test red flag analysis - print("\n--- Testing RED FLAG analysis ---") - red_flag_message = "I am angry all the time and I can't stop crying" - patient_input = PatientInput( - message=red_flag_message, - timestamp=datetime.now().isoformat() - ) - - classification = session.analyzer.analyze_message(patient_input) - print(f"Flag Level: {classification.flag_level}") - print(f"Indicators: {classification.indicators}") - print(f"Confidence: {classification.confidence:.2%}") - - assert classification.flag_level in ["red", "yellow"], f"Expected red/yellow flag, got {classification.flag_level}" - assert len(classification.indicators) > 0, "No indicators detected" - print("✅ Red flag analysis works") - - # Test referral generation for red flag - if classification.flag_level == "red": - print("\n--- Testing REFERRAL generation ---") - referral = session.referral_generator.generate_referral( - classification, - patient_input - ) - print(f"Patient Concerns: {referral.patient_concerns[:50]}...") - print(f"Message Length: {len(referral.message_text)} chars") - - assert len(referral.message_text) > 0, "Referral message is empty" - assert len(referral.distress_indicators) > 0, "No indicators in referral" - print("✅ Referral generation works") - - # Test yellow flag analysis - print("\n--- Testing YELLOW FLAG analysis ---") - yellow_flag_message = "I've been feeling frustrated lately" - patient_input2 = PatientInput( - message=yellow_flag_message, - timestamp=datetime.now().isoformat() - ) - - classification2 = session.analyzer.analyze_message(patient_input2) - print(f"Flag Level: {classification2.flag_level}") - print(f"Indicators: {classification2.indicators}") - print(f"Confidence: {classification2.confidence:.2%}") - - # Test question generation for yellow flag - if classification2.flag_level == "yellow": - print("\n--- Testing QUESTION generation ---") - questions = session.question_generator.generate_questions( - classification2, - patient_input2 - ) - print(f"Generated {len(questions)} questions:") - for i, q in enumerate(questions, 1): - print(f" {i}. {q[:60]}...") - - assert len(questions) > 0, "No questions generated" - assert len(questions) <= 3, "Too many questions generated" - print("✅ Question generation works") - - # Test no flag analysis - print("\n--- Testing NO FLAG analysis ---") - no_flag_message = "I'm doing well today and feeling optimistic" - patient_input3 = PatientInput( - message=no_flag_message, - timestamp=datetime.now().isoformat() - ) - - classification3 = session.analyzer.analyze_message(patient_input3) - print(f"Flag Level: {classification3.flag_level}") - print(f"Indicators: {classification3.indicators}") - print(f"Confidence: {classification3.confidence:.2%}") - - print("✅ No flag analysis works") - - # Test feedback storage - print("\n--- Testing FEEDBACK storage ---") - from src.core.spiritual_classes import ProviderFeedback - - feedback = ProviderFeedback( - assessment_id="", - provider_id="test_provider", - agrees_with_classification=True, - agrees_with_referral=True, - comments="Test feedback" - ) - - assessment_id = session.feedback_store.save_feedback( - patient_input=patient_input, - classification=classification, - referral_message=referral if classification.flag_level == "red" else None, - provider_feedback=feedback - ) - - print(f"Saved feedback with ID: {assessment_id[:8]}...") - - # Retrieve feedback - retrieved = session.feedback_store.get_feedback_by_id(assessment_id) - assert retrieved is not None, "Failed to retrieve feedback" - assert retrieved['assessment_id'] == assessment_id, "Assessment ID mismatch" - print("✅ Feedback storage and retrieval works") - - # Test metrics - print("\n--- Testing METRICS calculation ---") - metrics = session.feedback_store.get_accuracy_metrics() - print(f"Total Assessments: {metrics['total_assessments']}") - print(f"Classification Agreement: {metrics['classification_agreement_rate']:.1%}") - print("✅ Metrics calculation works") - - print("\n" + "="*60) - print("✅ FULL WORKFLOW TEST PASSED") - print("="*60) - - return True - - except Exception as e: - print(f"❌ Error in workflow test: {e}") - import traceback - traceback.print_exc() - return False - - -def test_ui_components(): - """Test that UI components are properly structured""" - print("\nTesting UI component structure...") - - try: - from src.interface.spiritual_interface import create_spiritual_interface - import gradio as gr - - # Create interface - demo = create_spiritual_interface() - - # Check that it has the expected structure - # Note: We can't easily inspect Gradio's internal structure, - # but we can verify it's a valid Blocks object - assert isinstance(demo, gr.Blocks), "Not a Gradio Blocks object" - print("✅ UI components properly structured") - - return True - - except Exception as e: - print(f"❌ Error testing UI components: {e}") - return False - - -def test_session_state_management(): - """Test session state management""" - print("\nTesting session state management...") - - try: - from src.interface.spiritual_interface import SessionData - from src.core.spiritual_classes import PatientInput - - session = SessionData() - - # Initially, no current assessment - assert session.current_patient_input is None, "Should start with no patient input" - assert session.current_classification is None, "Should start with no classification" - assert session.current_referral is None, "Should start with no referral" - assert len(session.current_questions) == 0, "Should start with no questions" - print("✅ Initial state is correct") - - # Simulate an assessment - patient_input = PatientInput( - message="Test message", - timestamp=datetime.now().isoformat() - ) - - classification = session.analyzer.analyze_message(patient_input) - - # Update session state - session.current_patient_input = patient_input - session.current_classification = classification - - # Verify state is updated - assert session.current_patient_input is not None, "Patient input not stored" - assert session.current_classification is not None, "Classification not stored" - print("✅ State updates correctly") - - # Add to history - session.assessment_history.append({ - "timestamp": datetime.now().isoformat(), - "message": patient_input.message, - "flag_level": classification.flag_level - }) - - assert len(session.assessment_history) == 1, "History not updated" - print("✅ History tracking works") - - return True - - except Exception as e: - print(f"❌ Error testing session state: {e}") - import traceback - traceback.print_exc() - return False - - -if __name__ == "__main__": - print("="*60) - print("SPIRITUAL INTERFACE INTEGRATION TEST SUITE") - print("="*60) - - results = [] - - # Run tests - results.append(("Full Workflow", test_full_workflow())) - results.append(("UI Components", test_ui_components())) - results.append(("Session State Management", test_session_state_management())) - - # Summary - print("\n" + "="*60) - print("TEST SUMMARY") - print("="*60) - - passed = sum(1 for _, result in results if result) - total = len(results) - - for test_name, result in results: - status = "✅ PASS" if result else "❌ FAIL" - print(f"{status}: {test_name}") - - print(f"\nTotal: {passed}/{total} tests passed") - - if passed == total: - print("\n🎉 All integration tests passed!") - print("\nThe spiritual interface is fully functional and ready for use.") - print("\nTo launch the interface:") - print(" ./venv/bin/python src/interface/spiritual_interface.py") - sys.exit(0) - else: - print("\n⚠️ Some tests failed. Please review the errors above.") - sys.exit(1) diff --git a/tests/spiritual/test_spiritual_interface_integration_task9.py b/tests/spiritual/test_spiritual_interface_integration_task9.py deleted file mode 100644 index ec5d56ab2df4d258038dc844282e2a284690d87c..0000000000000000000000000000000000000000 --- a/tests/spiritual/test_spiritual_interface_integration_task9.py +++ /dev/null @@ -1,274 +0,0 @@ -""" -Integration test for Task 9: Spiritual Interface - -Tests the complete workflow of the spiritual interface including: -- Session initialization -- Patient message analysis -- Results display -- Feedback submission -- History tracking -""" - -import sys -from datetime import datetime -from src.interface.spiritual_interface import SessionData - - -def test_session_initialization(): - """Test session initialization""" - print("✓ Testing session initialization...") - - session = SessionData() - - # Verify session has unique ID - assert session.session_id is not None - assert len(session.session_id) > 0 - - # Verify timestamps - assert session.created_at is not None - assert session.last_activity is not None - - # Verify components are initialized - assert session.api is not None - assert session.analyzer is not None - assert session.referral_generator is not None - assert session.question_generator is not None - assert session.feedback_store is not None - - # Verify state is clean - assert session.current_patient_input is None - assert session.current_classification is None - assert session.current_referral is None - assert len(session.current_questions) == 0 - assert len(session.assessment_history) == 0 - - print(" ✅ Session initialization successful") - - -def test_activity_tracking(): - """Test activity timestamp updates""" - print("✓ Testing activity tracking...") - - session = SessionData() - initial_activity = session.last_activity - - # Wait a moment and update activity - import time - time.sleep(0.1) - session.update_activity() - - # Verify timestamp changed - assert session.last_activity != initial_activity - assert session.last_activity > initial_activity - - print(" ✅ Activity tracking works correctly") - - -def test_session_serialization(): - """Test session can be serialized""" - print("✓ Testing session serialization...") - - session = SessionData() - - # Serialize session - session_dict = session.to_dict() - - # Verify required fields - assert 'session_id' in session_dict - assert 'created_at' in session_dict - assert 'last_activity' in session_dict - assert 'assessment_count' in session_dict - - # Verify values - assert session_dict['session_id'] == session.session_id - assert session_dict['assessment_count'] == 0 - - print(" ✅ Session serialization works correctly") - - -def test_multiple_sessions_isolated(): - """Test that multiple sessions are isolated""" - print("✓ Testing session isolation...") - - session1 = SessionData() - session2 = SessionData() - - # Verify different session IDs - assert session1.session_id != session2.session_id - - # Verify different component instances - assert session1.analyzer is not session2.analyzer - assert session1.feedback_store is not session2.feedback_store - - # Verify independent state - session1.assessment_history.append({"test": "data1"}) - assert len(session1.assessment_history) == 1 - assert len(session2.assessment_history) == 0 - - print(" ✅ Session isolation verified") - - -def test_component_integration(): - """Test that all components are properly integrated""" - print("✓ Testing component integration...") - - session = SessionData() - - # Verify analyzer has API client - assert hasattr(session.analyzer, 'api') - assert session.analyzer.api is not None - - # Verify referral generator has API client - assert hasattr(session.referral_generator, 'api') - assert session.referral_generator.api is not None - - # Verify question generator has API client - assert hasattr(session.question_generator, 'api') - assert session.question_generator.api is not None - - # Verify feedback store is ready - assert hasattr(session.feedback_store, 'save_feedback') - assert hasattr(session.feedback_store, 'get_all_feedback') - - print(" ✅ Component integration verified") - - -def test_interface_creation(): - """Test that interface can be created""" - print("✓ Testing interface creation...") - - from src.interface.spiritual_interface import create_spiritual_interface - - # Create interface - demo = create_spiritual_interface() - - # Verify interface is created - assert demo is not None - - # Verify it's a Gradio Blocks instance - import gradio as gr - assert isinstance(demo, gr.Blocks) - - print(" ✅ Interface creation successful") - - -def test_handler_signatures(): - """Test that event handlers have correct signatures""" - print("✓ Testing handler signatures...") - - from src.interface.spiritual_interface import create_spiritual_interface - import inspect - - # Get source code - source = inspect.getsource(create_spiritual_interface) - - # Verify handlers accept session parameter - handlers = [ - 'handle_analyze', - 'handle_clear', - 'handle_submit_feedback', - 'handle_refresh_history', - 'handle_export_csv', - 'load_example' - ] - - for handler in handlers: - assert f'{handler}' in source, f"Handler {handler} should exist" - # Most handlers should accept session parameter - if handler != 'initialize_session': - assert 'session: SessionData' in source or 'session:' in source, \ - f"Handler {handler} should accept session parameter" - - print(" ✅ Handler signatures verified") - - -def test_requirements_mapping(): - """Test that all task requirements are addressed""" - print("✓ Testing requirements mapping...") - - from src.interface.spiritual_interface import create_spiritual_interface - import inspect - - source = inspect.getsource(create_spiritual_interface) - - # Map requirements to implementation features - requirements = { - '5.1': 'patient_message', # Input panel - '5.2': 'patient_message', # Original patient input display - '5.3': 'referral_display', # Referral message display - '5.4': 'indicators_display', # Indicators and reasoning - '5.5': 'agrees_classification', # Feedback options - '5.6': 'feedback_comments', # Comments - '8.1': 'classification_display', # Classification display - '8.2': 'patient_message', # Original input - '8.3': 'referral_display', # Referral message - '8.4': 'history_table', # History panel - '8.5': 'history_table', # Multiple assessments - '10.2': 'color', # Color coding - '10.4': 'feedback', # Visual feedback - '10.5': 'Error', # Error messages - } - - for req, feature in requirements.items(): - assert feature.lower() in source.lower(), \ - f"Requirement {req} feature '{feature}' not found in implementation" - - print(" ✅ All requirements mapped to implementation") - - -def main(): - """Run all integration tests""" - print("\n" + "="*60) - print("Task 9 Integration Tests") - print("Spiritual Interface End-to-End Verification") - print("="*60 + "\n") - - tests = [ - test_session_initialization, - test_activity_tracking, - test_session_serialization, - test_multiple_sessions_isolated, - test_component_integration, - test_interface_creation, - test_handler_signatures, - test_requirements_mapping - ] - - passed = 0 - failed = 0 - - for test in tests: - try: - test() - passed += 1 - except AssertionError as e: - print(f" ❌ FAILED: {e}") - failed += 1 - except Exception as e: - print(f" ❌ ERROR: {e}") - import traceback - traceback.print_exc() - failed += 1 - - print("\n" + "="*60) - print(f"Results: {passed} passed, {failed} failed") - print("="*60 + "\n") - - if failed == 0: - print("✅ All integration tests passed!") - print("\nVerified functionality:") - print(" • Session initialization and isolation") - print(" • Activity tracking") - print(" • Session serialization") - print(" • Component integration") - print(" • Interface creation") - print(" • Event handler signatures") - print(" • Requirements mapping") - return 0 - else: - print(f"❌ {failed} test(s) failed") - return 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/tests/spiritual/test_spiritual_interface_task9.py b/tests/spiritual/test_spiritual_interface_task9.py deleted file mode 100644 index 1eb5084e7d618364c232baf422f4f5939c329c3d..0000000000000000000000000000000000000000 --- a/tests/spiritual/test_spiritual_interface_task9.py +++ /dev/null @@ -1,207 +0,0 @@ -""" -Test script to verify Task 9 implementation requirements. - -This test verifies that the spiritual_interface.py implementation -meets all the requirements specified in the task. -""" - -import sys -import inspect -from src.interface.spiritual_interface import ( - SessionData, - create_spiritual_interface -) - - -def test_session_data_pattern(): - """Verify SessionData pattern is implemented (following gradio_app.py)""" - print("✓ Testing SessionData pattern...") - - # Check SessionData class exists - assert SessionData is not None, "SessionData class should exist" - - # Check SessionData has required attributes - session = SessionData() - assert hasattr(session, 'session_id'), "SessionData should have session_id" - assert hasattr(session, 'created_at'), "SessionData should have created_at" - assert hasattr(session, 'last_activity'), "SessionData should have last_activity" - assert hasattr(session, 'analyzer'), "SessionData should have analyzer" - assert hasattr(session, 'referral_generator'), "SessionData should have referral_generator" - assert hasattr(session, 'question_generator'), "SessionData should have question_generator" - assert hasattr(session, 'feedback_store'), "SessionData should have feedback_store" - - # Check update_activity method exists - assert hasattr(session, 'update_activity'), "SessionData should have update_activity method" - - print(" ✅ SessionData pattern correctly implemented") - - -def test_interface_structure(): - """Verify interface has tabs structure (Assessment, History, Instructions)""" - print("✓ Testing interface structure...") - - # Check create_spiritual_interface function exists - assert create_spiritual_interface is not None, "create_spiritual_interface should exist" - - # Get the source code to verify tabs - source = inspect.getsource(create_spiritual_interface) - - # Check for tabs - assert 'gr.Tabs()' in source, "Interface should use gr.Tabs()" - assert 'TabItem("🔍 Assessment"' in source or 'TabItem("Assessment"' in source, "Should have Assessment tab" - assert 'TabItem("📊 History"' in source or 'TabItem("History"' in source, "Should have History tab" - assert 'TabItem("📖 Instructions"' in source or 'TabItem("Instructions"' in source, "Should have Instructions tab" - - print(" ✅ Tab structure correctly implemented") - - -def test_input_panel(): - """Verify input panel with gr.Textbox""" - print("✓ Testing input panel...") - - source = inspect.getsource(create_spiritual_interface) - - # Check for patient message textbox - assert 'gr.Textbox' in source, "Should use gr.Textbox for input" - assert 'patient_message' in source, "Should have patient_message input" - - print(" ✅ Input panel correctly implemented") - - -def test_results_display(): - """Verify results display with gr.Markdown for color-coded badges""" - print("✓ Testing results display...") - - source = inspect.getsource(create_spiritual_interface) - - # Check for markdown displays - assert 'gr.Markdown' in source, "Should use gr.Markdown for displays" - assert 'classification_display' in source, "Should have classification_display" - assert 'indicators_display' in source, "Should have indicators_display" - assert 'reasoning_display' in source, "Should have reasoning_display" - assert 'referral_display' in source, "Should have referral_display" - - # Check for color-coded badges - assert '🔴' in source or 'red' in source.lower(), "Should have red flag indicator" - assert '🟡' in source or 'yellow' in source.lower(), "Should have yellow flag indicator" - assert '🟢' in source or 'green' in source.lower() or 'none' in source.lower(), "Should have no flag indicator" - - print(" ✅ Results display correctly implemented") - - -def test_feedback_panel(): - """Verify feedback panel with gr.Checkbox and gr.Textbox""" - print("✓ Testing feedback panel...") - - source = inspect.getsource(create_spiritual_interface) - - # Check for feedback components - assert 'gr.Checkbox' in source, "Should use gr.Checkbox for feedback" - assert 'agrees_classification' in source, "Should have agrees_classification checkbox" - assert 'agrees_referral' in source, "Should have agrees_referral checkbox" - assert 'feedback_comments' in source, "Should have feedback_comments textbox" - assert 'submit_feedback' in source.lower(), "Should have submit feedback button" - - print(" ✅ Feedback panel correctly implemented") - - -def test_history_panel(): - """Verify history panel with gr.Dataframe""" - print("✓ Testing history panel...") - - source = inspect.getsource(create_spiritual_interface) - - # Check for history table - assert 'gr.Dataframe' in source, "Should use gr.Dataframe for history" - assert 'history_table' in source, "Should have history_table" - - print(" ✅ History panel correctly implemented") - - -def test_session_isolated_handlers(): - """Verify session-isolated event handlers pattern""" - print("✓ Testing session-isolated event handlers...") - - source = inspect.getsource(create_spiritual_interface) - - # Check for session-isolated handlers - assert 'handle_analyze' in source, "Should have handle_analyze handler" - assert 'handle_clear' in source, "Should have handle_clear handler" - assert 'handle_submit_feedback' in source, "Should have handle_submit_feedback handler" - assert 'handle_refresh_history' in source, "Should have handle_refresh_history handler" - - # Check handlers accept session parameter - assert 'session: SessionData' in source, "Handlers should accept SessionData parameter" - - print(" ✅ Session-isolated handlers correctly implemented") - - -def test_requirements_coverage(): - """Verify requirements are documented in code""" - print("✓ Testing requirements coverage...") - - source = inspect.getsource(create_spiritual_interface) - - # Check for requirement references - assert 'Requirements: 5.1' in source or 'Requirement 5.1' in source, "Should reference requirement 5.1" - assert 'Requirements: 8.1' in source or 'Requirement 8.1' in source, "Should reference requirement 8.1" - assert 'Requirements: 10.2' in source or 'Requirement 10.2' in source, "Should reference requirement 10.2" - - print(" ✅ Requirements properly documented") - - -def main(): - """Run all tests""" - print("\n" + "="*60) - print("Task 9 Implementation Verification") - print("Build validation interface with Gradio") - print("="*60 + "\n") - - tests = [ - test_session_data_pattern, - test_interface_structure, - test_input_panel, - test_results_display, - test_feedback_panel, - test_history_panel, - test_session_isolated_handlers, - test_requirements_coverage - ] - - passed = 0 - failed = 0 - - for test in tests: - try: - test() - passed += 1 - except AssertionError as e: - print(f" ❌ FAILED: {e}") - failed += 1 - except Exception as e: - print(f" ❌ ERROR: {e}") - failed += 1 - - print("\n" + "="*60) - print(f"Results: {passed} passed, {failed} failed") - print("="*60 + "\n") - - if failed == 0: - print("✅ All Task 9 requirements verified successfully!") - print("\nImplementation includes:") - print(" • SessionData pattern for session isolation") - print(" • Tabs structure (Assessment, History, Instructions)") - print(" • Input panel with gr.Textbox") - print(" • Results display with gr.Markdown and color-coded badges") - print(" • Feedback panel with gr.Checkbox and gr.Textbox") - print(" • History panel with gr.Dataframe") - print(" • Session-isolated event handlers") - print(" • Requirements properly documented") - return 0 - else: - print(f"❌ {failed} test(s) failed") - return 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/tests/spiritual/test_spiritual_live.py b/tests/spiritual/test_spiritual_live.py deleted file mode 100644 index f8bc18f3025db0476f2901cb91d1623a3e98174e..0000000000000000000000000000000000000000 --- a/tests/spiritual/test_spiritual_live.py +++ /dev/null @@ -1,73 +0,0 @@ -#!/usr/bin/env python3 -""" -Live test of Spiritual Distress Analyzer with real API calls -""" - -import os -from dotenv import load_dotenv - -# Load environment -load_dotenv() - -from src.core.ai_client import AIClientManager -from src.core.spiritual_analyzer import SpiritualDistressAnalyzer -from src.core.spiritual_classes import PatientInput - -print("=" * 70) -print("LIVE SPIRITUAL DISTRESS ANALYZER TEST") -print("=" * 70) - -# Initialize -api = AIClientManager() -analyzer = SpiritualDistressAnalyzer(api) - -# Test cases -test_cases = [ - { - "message": "I am angry all the time and I can't control it anymore", - "expected": "red" - }, - { - "message": "I've been feeling frustrated lately", - "expected": "yellow" - }, - { - "message": "I'm doing well today, thank you for asking", - "expected": "none" - } -] - -print("\nRunning live tests with real AI API...\n") - -for i, test in enumerate(test_cases, 1): - print(f"\n{'='*70}") - print(f"Test {i}: {test['message'][:50]}...") - print(f"Expected: {test['expected'].upper()}") - print(f"{'='*70}") - - from datetime import datetime - patient_input = PatientInput( - message=test["message"], - timestamp=datetime.now().isoformat() - ) - - try: - classification = analyzer.analyze_message(patient_input) - - print(f"\n✅ Classification: {classification.flag_level.upper()}") - print(f" Confidence: {classification.confidence:.0%}") - print(f" Indicators: {', '.join(classification.indicators[:3])}") - print(f" Categories: {', '.join(classification.categories)}") - print(f"\n Reasoning: {classification.reasoning[:200]}...") - - if classification.flag_level == test['expected']: - print(f"\n✅ PASS: Correctly classified as {test['expected'].upper()}") - else: - print(f"\n⚠️ MISMATCH: Expected {test['expected'].upper()}, got {classification.flag_level.upper()}") - - except Exception as e: - print(f"\n❌ ERROR: {e}") - -print("\n" + "=" * 70) -print("LIVE TEST COMPLETE") -print("=" * 70) diff --git a/tests/spiritual/test_ui_error_messages.py b/tests/spiritual/test_ui_error_messages.py deleted file mode 100644 index 69eb208309da4f0cb487d9c2067e613e7f70fbf0..0000000000000000000000000000000000000000 --- a/tests/spiritual/test_ui_error_messages.py +++ /dev/null @@ -1,274 +0,0 @@ -#!/usr/bin/env python3 -""" -Test User-Friendly Error Messages in UI - -Verifies that the UI displays helpful, actionable error messages -as specified in Task 11 (Requirement 10.5) -""" - -import os -import sys -from unittest.mock import Mock, patch -from datetime import datetime - -# Add src to path -sys.path.insert(0, os.path.abspath('.')) - -from src.core.spiritual_classes import PatientInput - - -def test_ui_error_message_formats(): - """Test that UI error messages are user-friendly and actionable""" - print("\n=== Test: UI Error Message Formats ===") - - # Read the interface code directly (avoid importing gradio) - with open('src/interface/spiritual_interface.py', 'r') as f: - interface_code = f.read() - - # Check for user-friendly error message patterns - error_patterns = [ - "❌ **Error:**", - "⚠️ **Warning:**", - "**What to do:**", - "Connection Timeout", - "Service Limit Reached", - "Connection Error", - "Service Error", - "Data Processing Error", - "Unexpected Error" - ] - - found_patterns = [] - for pattern in error_patterns: - if pattern in interface_code: - found_patterns.append(pattern) - print(f" ✅ Found pattern: {pattern}") - - assert len(found_patterns) >= 6, f"Should have at least 6 error message patterns, found {len(found_patterns)}" - - # Check for actionable guidance - actionable_patterns = [ - "try again", - "Try again", - "contact support", - "Check your", - "Wait a", - "If the problem" - ] - - found_actionable = [] - for pattern in actionable_patterns: - if pattern in interface_code: - found_actionable.append(pattern) - print(f" ✅ Found actionable guidance: {pattern}") - - assert len(found_actionable) >= 4, f"Should have actionable guidance, found {len(found_actionable)}" - - print("\n ✅ UI has user-friendly error messages with actionable guidance") - return True - - -def test_error_message_structure(): - """Test that error messages follow a consistent structure""" - print("\n=== Test: Error Message Structure ===") - - with open('src/interface/spiritual_interface.py', 'r') as f: - interface_code = f.read() - - # Check for structured error messages with: - # 1. Clear title/heading - # 2. Explanation of what happened - # 3. Actionable steps - - # Look for multi-line error message blocks - error_blocks = interface_code.count('❌ **') - print(f" Found {error_blocks} error message blocks") - assert error_blocks >= 5, "Should have multiple error message types" - - # Check for "What to do:" sections - what_to_do_count = interface_code.count('**What to do:**') - print(f" Found {what_to_do_count} 'What to do' sections") - assert what_to_do_count >= 4, "Should have 'What to do' guidance in error messages" - - # Check for specific error types - error_types = [ - 'Connection Timeout', - 'Service Limit Reached', - 'Connection Error', - 'Service Error', - 'Data Processing Error', - 'Unexpected Error' - ] - - found_types = [] - for error_type in error_types: - if error_type in interface_code: - found_types.append(error_type) - print(f" ✅ Has error type: {error_type}") - - assert len(found_types) >= 5, f"Should have multiple error types, found {len(found_types)}" - - print("\n ✅ Error messages follow consistent structure") - return True - - -def test_validation_error_messages(): - """Test validation error messages are clear""" - print("\n=== Test: Validation Error Messages ===") - - with open('src/interface/spiritual_interface.py', 'r') as f: - interface_code = f.read() - - # Check for input validation messages - validation_messages = [ - "Please enter a patient message", - "cannot be empty", - "very short", - "provide more context" - ] - - found_validation = [] - for msg in validation_messages: - if msg in interface_code: - found_validation.append(msg) - print(f" ✅ Has validation message: {msg}") - - assert len(found_validation) >= 3, f"Should have validation messages, found {len(found_validation)}" - - print("\n ✅ Validation error messages are clear and helpful") - return True - - -def test_error_recovery_guidance(): - """Test that error messages provide recovery guidance""" - print("\n=== Test: Error Recovery Guidance ===") - - with open('src/interface/spiritual_interface.py', 'r') as f: - interface_code = f.read() - - # Check for recovery guidance phrases - recovery_phrases = [ - "try again", - "Try again", - "wait", - "Wait", - "contact support", - "check", - "Check", - "verify", - "Verify" - ] - - found_recovery = [] - for phrase in recovery_phrases: - if phrase in interface_code: - found_recovery.append(phrase) - - print(f" Found {len(found_recovery)} recovery guidance phrases") - assert len(found_recovery) >= 5, "Should have recovery guidance in error messages" - - # Check for specific recovery actions - recovery_actions = [ - "Try submitting your message again", - "Wait a moment and try again", - "Check your internet connection", - "contact support" - ] - - found_actions = [] - for action in recovery_actions: - if action in interface_code: - found_actions.append(action) - print(f" ✅ Has recovery action: {action}") - - assert len(found_actions) >= 2, "Should have specific recovery actions" - - print("\n ✅ Error messages provide clear recovery guidance") - return True - - -def test_error_context_information(): - """Test that error messages provide context""" - print("\n=== Test: Error Context Information ===") - - with open('src/interface/spiritual_interface.py', 'r') as f: - interface_code = f.read() - - # Check for context-providing phrases - context_phrases = [ - "This could be due to", - "An error occurred", - "Unable to", - "The AI service", - "returned data in an unexpected format" - ] - - found_context = [] - for phrase in context_phrases: - if phrase in interface_code: - found_context.append(phrase) - print(f" ✅ Provides context: {phrase}") - - assert len(found_context) >= 3, "Should provide context in error messages" - - print("\n ✅ Error messages provide helpful context") - return True - - -def run_all_tests(): - """Run all UI error message tests""" - print("="*70) - print("UI ERROR MESSAGE TESTS") - print("Testing User-Friendly Error Messages (Task 11, Requirement 10.5)") - print("="*70) - - tests = [ - ("UI Error Message Formats", test_ui_error_message_formats), - ("Error Message Structure", test_error_message_structure), - ("Validation Error Messages", test_validation_error_messages), - ("Error Recovery Guidance", test_error_recovery_guidance), - ("Error Context Information", test_error_context_information), - ] - - results = [] - for test_name, test_func in tests: - try: - result = test_func() - results.append((test_name, result)) - except Exception as e: - print(f" ❌ Test failed with exception: {e}") - import traceback - traceback.print_exc() - results.append((test_name, False)) - - # Summary - print("\n" + "="*70) - print("TEST SUMMARY") - print("="*70) - - passed = sum(1 for _, result in results if result) - total = len(results) - - for test_name, result in results: - status = "✅ PASS" if result else "❌ FAIL" - print(f"{status}: {test_name}") - - print(f"\nTotal: {passed}/{total} tests passed") - - if passed == total: - print("\n🎉 All UI error message tests passed!") - print("\nVerified UI error messages have:") - print(" ✅ Clear, user-friendly language") - print(" ✅ Consistent structure") - print(" ✅ Actionable recovery guidance") - print(" ✅ Helpful context information") - print(" ✅ Multiple error types covered") - return True - else: - print(f"\n⚠️ {total - passed} test(s) failed") - return False - - -if __name__ == "__main__": - success = run_all_tests() - sys.exit(0 if success else 1) diff --git a/tests/test_ai_providers.py b/tests/test_ai_providers.py deleted file mode 100644 index 55870a99000afef0ba7fedb40b0b2feda8369dcb..0000000000000000000000000000000000000000 --- a/tests/test_ai_providers.py +++ /dev/null @@ -1,158 +0,0 @@ -#!/usr/bin/env python3 -""" -Test script for AI Providers functionality -""" - -import os -from src.config.ai_providers_config import validate_configuration, check_environment_setup, get_agent_config -from src.core.ai_client import create_ai_client - -def test_configuration(): - """Test the AI providers configuration""" - print("🧪 Testing AI Providers Configuration\n") - - # Check environment setup - print("📋 Environment Setup:") - env_status = check_environment_setup() - for provider, status in env_status.items(): - print(f" {provider}: {status}") - - # Validate configuration - print("\n🔍 Configuration Validation:") - validation = validate_configuration() - - if validation["valid"]: - print(" ✅ Configuration is valid") - else: - print(" ❌ Configuration has errors:") - for error in validation["errors"]: - print(f" - {error}") - - if validation["warnings"]: - print(" ⚠️ Warnings:") - for warning in validation["warnings"]: - print(f" - {warning}") - - print(f"\n📊 Available Providers: {', '.join(validation['available_providers'])}") - - print("\n🎯 Agent Assignments:") - for agent, status in validation["agent_status"].items(): - provider_info = f"{status['provider']} ({status['model']})" - availability = "✅" if status["available"] else "❌" - print(f" {agent}: {provider_info} {availability}") - - if status.get("fallback_needed"): - fallback_info = f"{status.get('fallback_provider')} ({status.get('fallback_model')})" - print(f" → Fallback: {fallback_info}") - -def test_agent_configurations(): - """Test specific agent configurations""" - print("\n🎯 Testing Agent Configurations\n") - - test_agents = [ - "MainLifestyleAssistant", - "EntryClassifier", - "MedicalAssistant", - "TriageExitClassifier" - ] - - for agent_name in test_agents: - print(f"📋 **{agent_name}**:") - config = get_agent_config(agent_name) - - print(f" Provider: {config['provider'].value}") - print(f" Model: {config['model'].value}") - print(f" Temperature: {config['temperature']}") - print(f" Reasoning: {config['reasoning']}") - print() - -def test_client_creation(): - """Test AI client creation for different agents""" - print("🤖 Testing AI Client Creation\n") - - test_agents = ["MainLifestyleAssistant", "EntryClassifier", "MedicalAssistant"] - - for agent_name in test_agents: - print(f"🔧 Creating client for {agent_name}:") - try: - client = create_ai_client(agent_name) - info = client.get_client_info() - - print(f" ✅ Success!") - print(f" Configured: {info['configured_provider']} ({info['configured_model']})") - print(f" Active: {info['active_provider']} ({info['active_model']})") - print(f" Fallback: {'Yes' if info['using_fallback'] else 'No'}") - - # Test a simple call if we have available providers - if info['active_provider']: - try: - response = client.generate_response( - "You are a helpful assistant.", - "Say 'Hello' in one word.", - call_type="TEST" - ) - print(f" Test response: {response[:50]}...") - except Exception as e: - print(f" ⚠️ Test call failed: {e}") - - except Exception as e: - print(f" ❌ Failed: {e}") - - print() - -def test_anthropic_specific(): - """Test Anthropic-specific functionality for MainLifestyleAssistant""" - print("🧠 Testing Anthropic Integration for MainLifestyleAssistant\n") - - # Check if Anthropic is available - anthropic_key = os.getenv("ANTHROPIC_API_KEY") - if not anthropic_key: - print(" ⚠️ ANTHROPIC_API_KEY not set - skipping Anthropic tests") - return - - try: - client = create_ai_client("MainLifestyleAssistant") - info = client.get_client_info() - - print(f" Provider: {info['active_provider']}") - print(f" Model: {info['active_model']}") - - if info['active_provider'] == 'anthropic': - print(" ✅ MainLifestyleAssistant is using Anthropic Claude!") - - # Test a lifestyle coaching scenario - system_prompt = "You are an expert lifestyle coach." - user_prompt = "A patient wants to start exercising but has diabetes. What should they consider?" - - response = client.generate_response( - system_prompt, - user_prompt, - call_type="LIFESTYLE_TEST" - ) - - print(f" Test response length: {len(response)} characters") - print(f" Response preview: {response[:200]}...") - - else: - print(f" ⚠️ MainLifestyleAssistant is using {info['active_provider']} (fallback)") - - except Exception as e: - print(f" ❌ Error: {e}") - -if __name__ == "__main__": - print("🚀 AI Providers Test Suite") - print("=" * 50) - - test_configuration() - test_agent_configurations() - test_client_creation() - test_anthropic_specific() - - print("\n📋 **Summary:**") - print(" • Configuration system working ✅") - print(" • Agent-specific provider assignment ✅") - print(" • MainLifestyleAssistant → Anthropic Claude") - print(" • Other agents → Google Gemini") - print(" • Automatic fallback support ✅") - print(" • Backward compatibility maintained ✅") - print("\n✅ AI Providers integration complete!") \ No newline at end of file diff --git a/tests/test_app_startup.py b/tests/test_app_startup.py deleted file mode 100644 index 4b0d7e5266f5272f1cc8446c334197a472aebbc9..0000000000000000000000000000000000000000 --- a/tests/test_app_startup.py +++ /dev/null @@ -1,116 +0,0 @@ -#!/usr/bin/env python3 -""" -Test that the application can start up without errors -""" - -def test_app_imports(): - """Test that all required modules can be imported""" - print("🧪 Testing Application Imports\n") - - try: - from src.core.ai_client import AIClientManager - print(" ✅ AIClientManager imported successfully") - except Exception as e: - print(f" ❌ AIClientManager import error: {e}") - return False - - try: - from lifestyle_app import ExtendedLifestyleJourneyApp - print(" ✅ ExtendedLifestyleJourneyApp imported successfully") - except Exception as e: - print(f" ❌ ExtendedLifestyleJourneyApp import error: {e}") - return False - - return True - -def test_app_initialization(): - """Test that the app can be initialized""" - print("\n🏥 **Testing Application Initialization:**") - - try: - from lifestyle_app import ExtendedLifestyleJourneyApp - app = ExtendedLifestyleJourneyApp() - print(" ✅ App initialized successfully") - - # Test that API manager is properly set up - if hasattr(app, 'api') and hasattr(app.api, 'call_counter'): - print(f" ✅ API manager ready (call_counter: {app.api.call_counter})") - else: - print(" ❌ API manager not properly initialized") - return False - - return True - - except Exception as e: - print(f" ❌ App initialization error: {e}") - return False - -def test_status_info(): - """Test that _get_status_info works without errors""" - print("\n📊 **Testing Status Info Generation:**") - - try: - from lifestyle_app import ExtendedLifestyleJourneyApp - app = ExtendedLifestyleJourneyApp() - - # This was the problematic method - status = app._get_status_info() - print(" ✅ Status info generated successfully") - print(f" Status length: {len(status)} characters") - - # Check that it contains expected sections - if "AI STATISTICS" in status: - print(" ✅ AI statistics section present") - else: - print(" ⚠️ AI statistics section missing") - - if "AI PROVIDERS STATUS" in status: - print(" ✅ AI providers status section present") - else: - print(" ⚠️ AI providers status section missing") - - return True - - except Exception as e: - print(f" ❌ Status info error: {e}") - return False - -def test_ai_providers_status(): - """Test the new AI providers status method""" - print("\n🤖 **Testing AI Providers Status:**") - - try: - from lifestyle_app import ExtendedLifestyleJourneyApp - app = ExtendedLifestyleJourneyApp() - - # Test the new method - ai_status = app._get_ai_providers_status() - print(" ✅ AI providers status generated successfully") - print(f" Status preview: {ai_status[:100]}...") - - return True - - except Exception as e: - print(f" ❌ AI providers status error: {e}") - return False - -if __name__ == "__main__": - print("🚀 Application Startup Test Suite") - print("=" * 50) - - success = True - success &= test_app_imports() - success &= test_app_initialization() - success &= test_status_info() - success &= test_ai_providers_status() - - print("\n📋 **Summary:**") - if success: - print(" ✅ All tests passed - application should start successfully") - print(" ✅ Backward compatibility maintained") - print(" ✅ AI providers integration working") - print(" ✅ Status info generation fixed") - else: - print(" ❌ Some tests failed - check errors above") - - print(f"\n{'✅ SUCCESS' if success else '❌ FAILURE'}: Application startup test {'passed' if success else 'failed'}!") \ No newline at end of file diff --git a/tests/test_backward_compatibility.py b/tests/test_backward_compatibility.py deleted file mode 100644 index 52c817a711eace159851241bdc830c8498464112..0000000000000000000000000000000000000000 --- a/tests/test_backward_compatibility.py +++ /dev/null @@ -1,131 +0,0 @@ -#!/usr/bin/env python3 -""" -Test backward compatibility of AIClientManager with old GeminiAPI interface -""" - -from src.core.ai_client import AIClientManager - -def test_backward_compatibility(): - """Test that AIClientManager has all required attributes and methods""" - print("🧪 Testing Backward Compatibility\n") - - # Create AIClientManager (replaces GeminiAPI) - api = AIClientManager() - - # Test required attributes - print("📋 **Testing Required Attributes:**") - - # Test call_counter attribute - try: - counter = api.call_counter - print(f" ✅ call_counter: {counter}") - except AttributeError as e: - print(f" ❌ call_counter missing: {e}") - - # Test _clients attribute - try: - clients = api._clients - print(f" ✅ _clients: {len(clients)} clients") - except AttributeError as e: - print(f" ❌ _clients missing: {e}") - - print("\n📋 **Testing Required Methods:**") - - # Test generate_response method - try: - # This will fail without API keys, but method should exist - hasattr(api, 'generate_response') - print(" ✅ generate_response method exists") - except Exception as e: - print(f" ❌ generate_response error: {e}") - - # Test get_client method - try: - hasattr(api, 'get_client') - print(" ✅ get_client method exists") - except Exception as e: - print(f" ❌ get_client error: {e}") - - # Test get_client_info method - try: - hasattr(api, 'get_client_info') - print(" ✅ get_client_info method exists") - except Exception as e: - print(f" ❌ get_client_info error: {e}") - - # Test new get_all_clients_info method - try: - info = api.get_all_clients_info() - print(f" ✅ get_all_clients_info: {info}") - except Exception as e: - print(f" ❌ get_all_clients_info error: {e}") - -def test_call_counter_increment(): - """Test that call_counter increments properly""" - print("\n🔢 **Testing Call Counter Increment:**") - - api = AIClientManager() - initial_count = api.call_counter - print(f" Initial count: {initial_count}") - - # Simulate API calls (will fail without keys, but counter should still increment) - try: - api.generate_response("test", "test", agent_name="TestAgent") - except: - pass # Expected to fail without API keys - - try: - api.generate_response("test", "test", agent_name="TestAgent") - except: - pass # Expected to fail without API keys - - final_count = api.call_counter - print(f" Final count: {final_count}") - - if final_count > initial_count: - print(" ✅ Call counter increments correctly") - else: - print(" ❌ Call counter not incrementing") - -def test_lifestyle_app_compatibility(): - """Test compatibility with lifestyle_app.py usage patterns""" - print("\n🏥 **Testing Lifestyle App Compatibility:**") - - # Simulate how lifestyle_app.py uses the API - api = AIClientManager() - - # Test accessing call_counter (used in _get_status_info) - try: - status_info = f"API calls: {api.call_counter}" - print(f" ✅ Status info generation: {status_info}") - except Exception as e: - print(f" ❌ Status info error: {e}") - - # Test accessing _clients (used in _get_status_info) - try: - clients_count = len(api._clients) - print(f" ✅ Clients count access: {clients_count}") - except Exception as e: - print(f" ❌ Clients count error: {e}") - - # Test get_all_clients_info (new method for detailed status) - try: - detailed_info = api.get_all_clients_info() - print(f" ✅ Detailed info keys: {list(detailed_info.keys())}") - except Exception as e: - print(f" ❌ Detailed info error: {e}") - -if __name__ == "__main__": - print("🚀 Backward Compatibility Test Suite") - print("=" * 50) - - test_backward_compatibility() - test_call_counter_increment() - test_lifestyle_app_compatibility() - - print("\n📋 **Summary:**") - print(" • AIClientManager provides full backward compatibility") - print(" • All required attributes and methods present") - print(" • Call counter tracking works correctly") - print(" • Compatible with existing lifestyle_app.py code") - print("\n✅ Backward compatibility verified!") \ No newline at end of file diff --git a/tests/test_combined_assistant.py b/tests/test_combined_assistant.py deleted file mode 100644 index d821286777516558e28db44f362c5b18dd151595..0000000000000000000000000000000000000000 --- a/tests/test_combined_assistant.py +++ /dev/null @@ -1,419 +0,0 @@ -# test_combined_assistant.py -""" -Unit tests for CombinedAssistant - -Tests the coordination of Lifestyle and Spiritual assistants. -""" - -import pytest -from unittest.mock import Mock, MagicMock - -from src.core.combined_assistant import CombinedAssistant, create_combined_assistant -from src.core.spiritual_classes import DistressClassification - - -@pytest.fixture -def mock_lifestyle_assistant(): - """Create mock lifestyle assistant""" - return Mock() - - -@pytest.fixture -def mock_spiritual_assistant(): - """Create mock spiritual assistant""" - return Mock() - - -@pytest.fixture -def combined_assistant(mock_lifestyle_assistant, mock_spiritual_assistant): - """Create CombinedAssistant with mocked dependencies""" - return CombinedAssistant(mock_lifestyle_assistant, mock_spiritual_assistant) - - -class TestCombinedAssistantInit: - """Test CombinedAssistant initialization""" - - def test_init_success(self, mock_lifestyle_assistant, mock_spiritual_assistant): - """Test successful initialization""" - assistant = CombinedAssistant(mock_lifestyle_assistant, mock_spiritual_assistant) - assert assistant.lifestyle == mock_lifestyle_assistant - assert assistant.spiritual == mock_spiritual_assistant - - -class TestProcessMessageBothAssistants: - """Test that both assistants are invoked""" - - def test_both_assistants_called(self, combined_assistant, mock_lifestyle_assistant, mock_spiritual_assistant): - """Test that both assistants are invoked""" - # Setup - mock_lifestyle_assistant.process_message.return_value = { - "message": "Lifestyle response", - "action": "lifestyle_dialog", - "reasoning": "Normal lifestyle" - } - - mock_spiritual_assistant.process_message.return_value = { - "message": "Spiritual response", - "action": "continue", - "classification": DistressClassification( - flag_level="none", - indicators=[], - categories=[], - confidence=0.8, - reasoning="No distress" - ), - "reasoning": "No spiritual distress" - } - - # Execute - result = combined_assistant.process_message( - "Test message", - [], - Mock(), - Mock(), - 1 - ) - - # Assert - assert mock_lifestyle_assistant.process_message.called - assert mock_spiritual_assistant.process_message.called - assert result["lifestyle_result"] is not None - assert result["spiritual_result"] is not None - - -class TestPrioritySpiritualRedFlag: - """Test priority when spiritual detects red flag""" - - def test_spiritual_red_flag_gets_priority(self, combined_assistant, mock_lifestyle_assistant, mock_spiritual_assistant): - """Test that spiritual red flag gets highest priority""" - # Setup - mock_lifestyle_assistant.process_message.return_value = { - "message": "Lifestyle response", - "action": "lifestyle_dialog", - "reasoning": "Normal" - } - - mock_spiritual_assistant.process_message.return_value = { - "message": "Spiritual RED FLAG response", - "action": "escalate", - "classification": DistressClassification( - flag_level="red", - indicators=["anger_all_the_time"], - categories=["emotional_distress"], - confidence=0.95, - reasoning="Severe distress" - ), - "reasoning": "Red flag detected" - } - - # Execute - result = combined_assistant.process_message( - "I am angry all the time", - [], - Mock(), - Mock(), - 1 - ) - - # Assert - assert result["priority"] == "spiritual" - assert result["action"] == "escalate_spiritual" - assert "Spiritual RED FLAG response" in result["message"] - # Lifestyle should still be included but secondary - assert "Lifestyle response" in result["message"] - - def test_spiritual_red_flag_message_format(self, combined_assistant, mock_lifestyle_assistant, mock_spiritual_assistant): - """Test that spiritual red flag appears first in message""" - # Setup - mock_lifestyle_assistant.process_message.return_value = { - "message": "LIFESTYLE_MSG", - "action": "lifestyle_dialog" - } - - mock_spiritual_assistant.process_message.return_value = { - "message": "SPIRITUAL_MSG", - "action": "escalate", - "classification": DistressClassification( - flag_level="red", - indicators=["test"], - categories=["test"], - confidence=0.9, - reasoning="test" - ) - } - - # Execute - result = combined_assistant.process_message("Test", [], Mock(), Mock(), 1) - - # Assert - spiritual should appear before lifestyle - spiritual_pos = result["message"].find("SPIRITUAL_MSG") - lifestyle_pos = result["message"].find("LIFESTYLE_MSG") - assert spiritual_pos < lifestyle_pos - - -class TestPriorityLifestyleClose: - """Test priority when lifestyle wants to close""" - - def test_lifestyle_close_gets_priority(self, combined_assistant, mock_lifestyle_assistant, mock_spiritual_assistant): - """Test that lifestyle close action gets priority""" - # Setup - mock_lifestyle_assistant.process_message.return_value = { - "message": "Closing lifestyle session", - "action": "close", - "reasoning": "Session complete" - } - - mock_spiritual_assistant.process_message.return_value = { - "message": "Spiritual check", - "action": "continue", - "classification": DistressClassification( - flag_level="none", - indicators=[], - categories=[], - confidence=0.8, - reasoning="Normal" - ) - } - - # Execute - result = combined_assistant.process_message("Thanks", [], Mock(), Mock(), 5) - - # Assert - assert result["priority"] == "lifestyle" - assert result["action"] == "close" - - -class TestPriorityBalanced: - """Test balanced priority when both are normal""" - - def test_balanced_priority_both_normal(self, combined_assistant, mock_lifestyle_assistant, mock_spiritual_assistant): - """Test balanced priority when both assistants return normal results""" - # Setup - mock_lifestyle_assistant.process_message.return_value = { - "message": "Lifestyle advice", - "action": "lifestyle_dialog", - "reasoning": "Normal coaching" - } - - mock_spiritual_assistant.process_message.return_value = { - "message": "Spiritual support", - "action": "continue", - "classification": DistressClassification( - flag_level="none", - indicators=[], - categories=[], - confidence=0.85, - reasoning="No concerns" - ) - } - - # Execute - result = combined_assistant.process_message("How are you?", [], Mock(), Mock(), 1) - - # Assert - assert result["priority"] == "balanced" - assert result["action"] == "continue" - assert "Comprehensive Support" in result["message"] - assert "Lifestyle advice" in result["message"] - assert "Spiritual support" in result["message"] - - -class TestErrorHandling: - """Test error handling for assistant failures""" - - def test_lifestyle_error_uses_spiritual(self, combined_assistant, mock_lifestyle_assistant, mock_spiritual_assistant): - """Test that spiritual is used when lifestyle fails""" - # Setup - mock_lifestyle_assistant.process_message.side_effect = Exception("Lifestyle error") - - mock_spiritual_assistant.process_message.return_value = { - "message": "Spiritual response", - "action": "continue", - "classification": DistressClassification( - flag_level="none", - indicators=[], - categories=[], - confidence=0.8, - reasoning="Normal" - ) - } - - # Execute - result = combined_assistant.process_message("Test", [], Mock(), Mock(), 1) - - # Assert - assert result["priority"] == "spiritual" - assert result["lifestyle_result"]["error"] is True - assert "temporarily unavailable" in result["lifestyle_result"]["message"] - - def test_spiritual_error_uses_lifestyle(self, combined_assistant, mock_lifestyle_assistant, mock_spiritual_assistant): - """Test that lifestyle is used when spiritual fails""" - # Setup - mock_lifestyle_assistant.process_message.return_value = { - "message": "Lifestyle response", - "action": "lifestyle_dialog", - "reasoning": "Normal" - } - - mock_spiritual_assistant.process_message.side_effect = Exception("Spiritual error") - - # Execute - result = combined_assistant.process_message("Test", [], Mock(), Mock(), 1) - - # Assert - assert result["priority"] == "lifestyle" - assert result["spiritual_result"]["error"] is True - assert "temporarily unavailable" in result["spiritual_result"]["message"] - - def test_both_errors_returns_balanced(self, combined_assistant, mock_lifestyle_assistant, mock_spiritual_assistant): - """Test that both errors returns balanced priority""" - # Setup - mock_lifestyle_assistant.process_message.side_effect = Exception("Lifestyle error") - mock_spiritual_assistant.process_message.side_effect = Exception("Spiritual error") - - # Execute - result = combined_assistant.process_message("Test", [], Mock(), Mock(), 1) - - # Assert - assert result["priority"] == "balanced" - assert result["lifestyle_result"]["error"] is True - assert result["spiritual_result"]["error"] is True - - -class TestResponseCombination: - """Test response combination logic""" - - def test_spiritual_priority_format(self, combined_assistant, mock_lifestyle_assistant, mock_spiritual_assistant): - """Test message format for spiritual priority""" - # Setup - mock_lifestyle_assistant.process_message.return_value = { - "message": "LIFESTYLE", - "action": "lifestyle_dialog" - } - - mock_spiritual_assistant.process_message.return_value = { - "message": "SPIRITUAL", - "action": "escalate", - "classification": DistressClassification( - flag_level="red", - indicators=["test"], - categories=["test"], - confidence=0.9, - reasoning="test" - ) - } - - # Execute - result = combined_assistant.process_message("Test", [], Mock(), Mock(), 1) - - # Assert - assert "SPIRITUAL" in result["message"] - assert "💚 **Lifestyle Support**" in result["message"] - assert "LIFESTYLE" in result["message"] - - def test_lifestyle_priority_format(self, combined_assistant, mock_lifestyle_assistant, mock_spiritual_assistant): - """Test message format for lifestyle priority""" - # Setup - mock_lifestyle_assistant.process_message.return_value = { - "message": "LIFESTYLE", - "action": "close" - } - - mock_spiritual_assistant.process_message.return_value = { - "message": "SPIRITUAL", - "action": "continue", - "classification": DistressClassification( - flag_level="none", - indicators=[], - categories=[], - confidence=0.8, - reasoning="normal" - ) - } - - # Execute - result = combined_assistant.process_message("Test", [], Mock(), Mock(), 1) - - # Assert - assert "LIFESTYLE" in result["message"] - assert "🕊️ **Spiritual Wellness Check**" in result["message"] - assert "SPIRITUAL" in result["message"] - - def test_balanced_format(self, combined_assistant, mock_lifestyle_assistant, mock_spiritual_assistant): - """Test message format for balanced priority""" - # Setup - mock_lifestyle_assistant.process_message.return_value = { - "message": "LIFESTYLE", - "action": "lifestyle_dialog" - } - - mock_spiritual_assistant.process_message.return_value = { - "message": "SPIRITUAL", - "action": "continue", - "classification": DistressClassification( - flag_level="none", - indicators=[], - categories=[], - confidence=0.8, - reasoning="normal" - ) - } - - # Execute - result = combined_assistant.process_message("Test", [], Mock(), Mock(), 1) - - # Assert - assert "🌟 **Comprehensive Support**" in result["message"] - assert "💚 **Lifestyle Coaching:**" in result["message"] - assert "🕊️ **Spiritual Wellness:**" in result["message"] - assert "LIFESTYLE" in result["message"] - assert "SPIRITUAL" in result["message"] - - -class TestConvenienceFunction: - """Test convenience function""" - - def test_create_combined_assistant(self, mock_lifestyle_assistant, mock_spiritual_assistant): - """Test create_combined_assistant function""" - assistant = create_combined_assistant(mock_lifestyle_assistant, mock_spiritual_assistant) - assert isinstance(assistant, CombinedAssistant) - assert assistant.lifestyle == mock_lifestyle_assistant - assert assistant.spiritual == mock_spiritual_assistant - - -class TestReasoning: - """Test reasoning generation""" - - def test_reasoning_includes_both_assistants(self, combined_assistant, mock_lifestyle_assistant, mock_spiritual_assistant): - """Test that reasoning includes information from both assistants""" - # Setup - mock_lifestyle_assistant.process_message.return_value = { - "message": "Lifestyle", - "action": "lifestyle_dialog", - "reasoning": "Lifestyle reasoning" - } - - mock_spiritual_assistant.process_message.return_value = { - "message": "Spiritual", - "action": "continue", - "classification": DistressClassification( - flag_level="none", - indicators=[], - categories=[], - confidence=0.8, - reasoning="normal" - ), - "reasoning": "Spiritual reasoning" - } - - # Execute - result = combined_assistant.process_message("Test", [], Mock(), Mock(), 1) - - # Assert - assert "Lifestyle reasoning" in result["reasoning"] - assert "Spiritual reasoning" in result["reasoning"] - assert "balanced" in result["reasoning"].lower() - - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) diff --git a/tests/test_core.py b/tests/test_core.py deleted file mode 100644 index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000 diff --git a/tests/test_dynamic_prompt_composition.py b/tests/test_dynamic_prompt_composition.py deleted file mode 100644 index d7413e31a84f1563aa76f4c751fc0622629a83e0..0000000000000000000000000000000000000000 --- a/tests/test_dynamic_prompt_composition.py +++ /dev/null @@ -1,372 +0,0 @@ -# test_dynamic_prompt_composition.py - NEW TESTING FILE -""" -Comprehensive Testing Framework for Dynamic Medical Prompt Composition - -Strategic Testing Philosophy: -- Validate medical safety protocols in all generated prompts -- Test personalization accuracy across diverse patient profiles -- Ensure component modularity and independence -- Verify graceful degradation and fallback mechanisms -""" - -import json -import pytest -from typing import Dict, List, Any -from dataclasses import dataclass - -# Test imports -from src.core.core_classes import LifestyleProfile, MainLifestyleAssistant -from src.core.ai_client import AIClientManager -from prompt_composer import DynamicPromptComposer, PatientProfileAnalyzer -from src.prompts.components import PromptComponentLibrary - -class MockAIClient: - """Mock AI client for testing prompt composition without API calls""" - - def __init__(self): - self.call_count = 0 - self.last_prompt = "" - - def generate_response(self, system_prompt: str, user_prompt: str, **kwargs) -> str: - self.call_count += 1 - self.last_prompt = system_prompt - - # Return valid JSON response for testing - return json.dumps({ - "message": "Test response based on composed prompt", - "action": "lifestyle_dialog", - "reasoning": "Testing dynamic prompt composition" - }) - -@dataclass -class TestPatientProfile: - """Test patient profiles for comprehensive validation""" - name: str - profile: LifestyleProfile - expected_components: List[str] - safety_requirements: List[str] - -class TestDynamicPromptComposition: - """Comprehensive test suite for dynamic prompt composition system""" - - @classmethod - def setup_class(cls): - """Initialize test environment""" - cls.composer = DynamicPromptComposer() - cls.analyzer = PatientProfileAnalyzer() - cls.component_library = PromptComponentLibrary() - cls.mock_api = MockAIClient() - - # Create test patient profiles - cls.test_patients = cls._create_test_patient_profiles() - - @classmethod - def _create_test_patient_profiles(cls) -> List[TestPatientProfile]: - """Create diverse test patient profiles for comprehensive testing""" - - # Test Patient 1: Hypertensive data-driven professional - hypertensive_profile = LifestyleProfile( - patient_name="Test_Hypertensive_Patient", - patient_age="52", - conditions=["Essential hypertension", "Mild obesity"], - primary_goal="Reduce blood pressure through lifestyle modifications", - exercise_limitations=["Avoid isometric exercises", "Monitor blood pressure"], - personal_preferences=["data-driven approach", "evidence-based recommendations"], - journey_summary="Professional seeking evidence-based health improvements", - last_session_summary="Initial assessment completed" - ) - - # Test Patient 2: Diabetic with anticoagulation therapy - diabetic_anticoag_profile = LifestyleProfile( - patient_name="Test_Diabetic_Anticoag_Patient", - patient_age="67", - conditions=["Type 2 diabetes", "Atrial fibrillation", "Deep vein thrombosis"], - primary_goal="Manage diabetes safely while on blood thinners", - exercise_limitations=["On anticoagulation therapy", "Avoid high-impact activities"], - personal_preferences=["gradual changes", "safety-focused"], - journey_summary="Complex medical conditions requiring careful management", - last_session_summary="Discussing safe exercise options" - ) - - # Test Patient 3: Mobility-limited elderly patient - mobility_limited_profile = LifestyleProfile( - patient_name="Test_Mobility_Limited_Patient", - patient_age="78", - conditions=["Severe arthritis", "History of falls"], - primary_goal="Maintain independence and prevent further mobility decline", - exercise_limitations=["Wheelchair user", "High fall risk"], - personal_preferences=["supportive approach", "simple explanations"], - journey_summary="Elderly patient focused on maintaining current abilities", - last_session_summary="Working on chair-based exercises" - ) - - # Test Patient 4: Young athlete with injury - athlete_profile = LifestyleProfile( - patient_name="Test_Athlete_Patient", - patient_age="24", - conditions=["ACL reconstruction recovery"], - primary_goal="Return to competitive sports safely", - exercise_limitations=["No pivoting movements", "Physical therapy protocol"], - personal_preferences=["detailed explanations", "performance-focused"], - journey_summary="Motivated athlete in rehabilitation phase", - last_session_summary="Progressing through recovery milestones" - ) - - return [ - TestPatientProfile( - name="Hypertensive Professional", - profile=hypertensive_profile, - expected_components=["cardiovascular_condition", "personalization_module"], - safety_requirements=["blood pressure monitoring", "isometric exercise warning"] - ), - TestPatientProfile( - name="Diabetic with Anticoagulation", - profile=diabetic_anticoag_profile, - expected_components=["metabolic_condition", "anticoagulation_condition"], - safety_requirements=["bleeding risk management", "glucose monitoring"] - ), - TestPatientProfile( - name="Mobility Limited Elderly", - profile=mobility_limited_profile, - expected_components=["mobility_condition", "safety_protocols"], - safety_requirements=["fall prevention", "chair-based exercises"] - ), - TestPatientProfile( - name="Recovering Athlete", - profile=athlete_profile, - expected_components=["personalization_module", "progress_guidance"], - safety_requirements=["ACL protection", "rehabilitation compliance"] - ) - ] - - def test_prompt_composition_basic_functionality(self): - """Test basic prompt composition functionality""" - - for test_patient in self.test_patients: - print(f"\n🧪 Testing: {test_patient.name}") - - # Test composition - composed_prompt = self.composer.compose_lifestyle_prompt(test_patient.profile) - - # Basic validation - assert composed_prompt is not None, f"Prompt composition failed for {test_patient.name}" - assert len(composed_prompt) > 100, f"Composed prompt too short for {test_patient.name}" - assert "You are an expert lifestyle coach" in composed_prompt, "Missing base foundation" - - print(f"✅ Basic composition successful for {test_patient.name}") - - def test_condition_specific_components(self): - """Test that condition-specific components are correctly included""" - - for test_patient in self.test_patients: - composed_prompt = self.composer.compose_lifestyle_prompt(test_patient.profile) - - for expected_component in test_patient.expected_components: - # Check for component-specific content - component_indicators = { - "cardiovascular_condition": ["blood pressure", "hypertension", "DASH diet"], - "metabolic_condition": ["diabetes", "glucose", "carbohydrate"], - "anticoagulation_condition": ["bleeding risk", "anticoagulation", "bruising"], - "mobility_condition": ["chair-based", "adaptive", "mobility"], - "personalization_module": ["data-driven", "evidence-based", "detailed"], - "progress_guidance": ["progress", "stage", "assessment"] - } - - if expected_component in component_indicators: - indicators = component_indicators[expected_component] - found_indicator = any(indicator.lower() in composed_prompt.lower() - for indicator in indicators) - - assert found_indicator, f"Missing {expected_component} content for {test_patient.name}" - print(f"✅ {expected_component} correctly included for {test_patient.name}") - - def test_safety_requirements(self): - """Test that critical safety requirements are present in composed prompts""" - - for test_patient in self.test_patients: - composed_prompt = self.composer.compose_lifestyle_prompt(test_patient.profile) - - for safety_requirement in test_patient.safety_requirements: - safety_indicators = { - "blood pressure monitoring": ["blood pressure", "monitor", "BP"], - "isometric exercise warning": ["isometric", "avoid", "weightlifting"], - "bleeding risk management": ["bleeding", "bruising", "injury risk"], - "glucose monitoring": ["glucose", "blood sugar", "diabetes"], - "fall prevention": ["fall", "balance", "safety"], - "chair-based exercises": ["chair", "seated", "adaptive"], - "ACL protection": ["pivot", "cutting", "knee"], - "rehabilitation compliance": ["therapy", "protocol", "rehabilitation"] - } - - if safety_requirement in safety_indicators: - indicators = safety_indicators[safety_requirement] - found_indicator = any(indicator.lower() in composed_prompt.lower() - for indicator in indicators) - - assert found_indicator, f"Missing safety requirement '{safety_requirement}' for {test_patient.name}" - print(f"✅ Safety requirement '{safety_requirement}' present for {test_patient.name}") - - def test_profile_analysis_accuracy(self): - """Test accuracy of patient profile analysis""" - - # Test hypertensive professional - hypertensive_patient = self.test_patients[0].profile - analysis = self.analyzer.analyze_profile(hypertensive_patient) - - assert "cardiovascular" in analysis.conditions - assert analysis.preferences["data_driven"] == True - assert analysis.communication_style in ["analytical_detailed", "data_focused"] - - # Test diabetic with anticoagulation - diabetic_patient = self.test_patients[1].profile - analysis = self.analyzer.analyze_profile(diabetic_patient) - - assert "metabolic" in analysis.conditions - assert "anticoagulation" in analysis.conditions - assert "bleeding risk" in analysis.risk_factors or "anticoagulation" in analysis.risk_factors - - print("✅ Profile analysis accuracy validated") - - def test_personalization_effectiveness(self): - """Test that personalization components correctly adapt to patient preferences""" - - # Test data-driven patient - data_driven_patient = self.test_patients[0].profile # Hypertensive professional - composed_prompt = self.composer.compose_lifestyle_prompt(data_driven_patient) - - data_driven_indicators = ["evidence", "metrics", "data", "tracking", "clinical studies"] - found_data_driven = any(indicator in composed_prompt.lower() - for indicator in data_driven_indicators) - assert found_data_driven, "Data-driven personalization not effective" - - # Test gradual approach patient - gradual_patient = self.test_patients[1].profile # Diabetic with anticoagulation - composed_prompt = self.composer.compose_lifestyle_prompt(gradual_patient) - - gradual_indicators = ["gradual", "small steps", "progressive", "gentle"] - found_gradual = any(indicator in composed_prompt.lower() - for indicator in gradual_indicators) - assert found_gradual, "Gradual approach personalization not effective" - - print("✅ Personalization effectiveness validated") - - def test_integration_with_main_lifestyle_assistant(self): - """Test integration with MainLifestyleAssistant""" - - # Create assistant with dynamic prompts - assistant = MainLifestyleAssistant(self.mock_api) - - # Test that dynamic prompts are enabled - assert assistant.dynamic_prompts_enabled, "Dynamic prompts not enabled in MainLifestyleAssistant" - - # Test prompt generation - test_patient = self.test_patients[0].profile - generated_prompt = assistant.get_current_system_prompt(test_patient) - - # Should be different from static default - assert generated_prompt != assistant.default_system_prompt, "Dynamic prompt not generated" - assert len(generated_prompt) > len(assistant.default_system_prompt), "Dynamic prompt not enhanced" - - print("✅ Integration with MainLifestyleAssistant validated") - - def test_fallback_mechanisms(self): - """Test graceful degradation and fallback mechanisms""" - - # Test with None profile (should fall back to static) - assistant = MainLifestyleAssistant(self.mock_api) - fallback_prompt = assistant.get_current_system_prompt(None) - - assert fallback_prompt == assistant.default_system_prompt, "Fallback to static prompt failed" - - # Test with custom prompt override - custom_prompt = "Custom test prompt for validation" - assistant.set_custom_system_prompt(custom_prompt) - - override_prompt = assistant.get_current_system_prompt(self.test_patients[0].profile) - assert override_prompt == custom_prompt, "Custom prompt override failed" - - print("✅ Fallback mechanisms validated") - - def test_composition_logging_and_analytics(self): - """Test prompt composition logging and analytics""" - - assistant = MainLifestyleAssistant(self.mock_api) - - # Generate compositions for multiple patients - for test_patient in self.test_patients[:2]: # Test with first 2 patients - assistant.get_current_system_prompt(test_patient.profile) - - # Test analytics - analytics = assistant.get_composition_analytics() - - assert analytics["total_compositions"] >= 2, "Composition logging not working" - assert "dynamic_usage_rate" in analytics, "Analytics missing usage rate" - assert "average_prompt_length" in analytics, "Analytics missing prompt length" - - print("✅ Composition logging and analytics validated") - - def test_component_modularity(self): - """Test that individual components can be tested and validated independently""" - - # Test base foundation component - base_component = self.component_library.get_base_foundation() - assert base_component is not None, "Base foundation component not available" - assert "lifestyle coach" in base_component.content.lower(), "Base foundation content invalid" - - # Test condition-specific components - cardio_component = self.component_library.get_condition_component("cardiovascular") - assert cardio_component is not None, "Cardiovascular component not available" - assert "hypertension" in cardio_component.content.lower(), "Cardiovascular content invalid" - - # Test safety component - safety_component = self.component_library.get_safety_component(["bleeding risk"]) - assert safety_component is not None, "Safety component not available" - assert "bleeding" in safety_component.content.lower(), "Safety content invalid" - - print("✅ Component modularity validated") - -def run_comprehensive_test_suite(): - """Run the complete test suite and provide detailed results""" - - print("🚀 Starting Comprehensive Dynamic Prompt Composition Test Suite") - print("=" * 80) - - test_suite = TestDynamicPromptComposition() - test_suite.setup_class() - - test_methods = [ - ("Basic Functionality", test_suite.test_prompt_composition_basic_functionality), - ("Condition-Specific Components", test_suite.test_condition_specific_components), - ("Safety Requirements", test_suite.test_safety_requirements), - ("Profile Analysis Accuracy", test_suite.test_profile_analysis_accuracy), - ("Personalization Effectiveness", test_suite.test_personalization_effectiveness), - ("MainLifestyleAssistant Integration", test_suite.test_integration_with_main_lifestyle_assistant), - ("Fallback Mechanisms", test_suite.test_fallback_mechanisms), - ("Composition Logging", test_suite.test_composition_logging_and_analytics), - ("Component Modularity", test_suite.test_component_modularity) - ] - - passed_tests = 0 - total_tests = len(test_methods) - - for test_name, test_method in test_methods: - try: - print(f"\n🧪 Testing: {test_name}") - test_method() - print(f"✅ {test_name}: PASSED") - passed_tests += 1 - except Exception as e: - print(f"❌ {test_name}: FAILED - {str(e)}") - - print("\n" + "=" * 80) - print(f"📊 Test Results: {passed_tests}/{total_tests} tests passed") - - if passed_tests == total_tests: - print("🎉 All tests passed! Dynamic prompt composition system is ready for deployment.") - else: - print("⚠️ Some tests failed. Review and fix issues before deployment.") - - return passed_tests == total_tests - -if __name__ == "__main__": - run_comprehensive_test_suite() \ No newline at end of file diff --git a/tests/test_dynamic_prompts.py b/tests/test_dynamic_prompts.py deleted file mode 100644 index bd2afbd6143f48697213086a0d0943102d8fa63b..0000000000000000000000000000000000000000 --- a/tests/test_dynamic_prompts.py +++ /dev/null @@ -1,747 +0,0 @@ -# test_dynamic_prompts.py - Comprehensive Testing Framework -""" -Strategic Testing Philosophy: "Comprehensive validation ensures medical safety and system reliability" - -Core Testing Principles: -- Medical safety validation with zero tolerance for failures -- Performance benchmarking for production readiness assessment -- Integration testing with existing system components -- Stress testing for reliability under various conditions -- Mock-based testing for isolated component validation -""" - -import pytest -import json -import time -import asyncio -from unittest.mock import Mock, patch, MagicMock -from typing import Dict, List, Any, Optional -from datetime import datetime -import signal - -# Test imports - conditional to handle missing components gracefully -try: - from src.prompts.types import ( - PromptComponent, PromptCompositionSpec, ClassificationContext, - ComponentCategory, SafetyLevel, AssemblyResult, MedicalSafetyViolationError - ) - from src.prompts.components import MedicalComponentLibrary - from src.prompts.classifier import LLMPromptClassifier, ClassificationCache, create_prompt_classifier - from src.prompts.assembler import DynamicTemplateAssembler, MedicalSafetyValidator - from src.config.dynamic import DynamicPromptConfiguration, EnvironmentConfigurationManager - from src.core.core_classes import EnhancedMainLifestyleAssistant - from src.core.ai_client import AIClientManager - COMPONENTS_AVAILABLE = True -except ImportError as e: - COMPONENTS_AVAILABLE = False - pytest.skip(f"Dynamic prompt components not available: {e}", allow_module_level=True) - -# === MOCK CLASSES FOR TESTING === - -class MockAIClient: - """Mock AI client for testing without actual LLM calls""" - - def __init__(self, responses: Optional[Dict[str, str]] = None): - self.responses = responses or {} - self.call_count = 0 - self.last_system_prompt = "" - self.last_user_prompt = "" - self.call_history = [] - - def generate_response(self, system_prompt: str, user_prompt: str, **kwargs) -> str: - """Mock LLM response generation""" - self.call_count += 1 - self.last_system_prompt = system_prompt - self.last_user_prompt = user_prompt - - # Record call for analysis - self.call_history.append({ - 'timestamp': datetime.now().isoformat(), - 'system_prompt_length': len(system_prompt), - 'user_prompt_length': len(user_prompt), - 'temperature': kwargs.get('temperature', 0.7) - }) - - # Return predefined response or generate default - response_key = 'default' - if 'classification' in system_prompt.lower(): - response_key = 'classification' - elif 'lifestyle' in user_prompt.lower(): - response_key = 'lifestyle' - - return self.responses.get(response_key, self._generate_default_response(response_key)) - - def _generate_default_response(self, response_type: str) -> str: - """Generate appropriate default responses for testing""" - - if response_type == 'classification': - return json.dumps({ - "session_focus": "general_wellness", - "medical_emphasis": ["test_condition"], - "communication_style": "friendly", - "component_priorities": { - "condition_specific": ["test_condition"], - "motivational": "medium", - "educational": "detailed", - "safety_protocols": "standard" - }, - "safety_level": "standard", - "reasoning": "Test classification response" - }) - - elif response_type == 'lifestyle': - return json.dumps({ - "message": "Це тестова відповідь для lifestyle коучингу", - "action": "lifestyle_dialog", - "reasoning": "Test lifestyle response" - }) - - else: - return json.dumps({ - "message": "Тестова відповідь", - "action": "gather_info", - "reasoning": "Default test response" - }) - -class TestPatientProfiles: - """Test patient profiles for comprehensive testing scenarios""" - - @staticmethod - def get_diabetes_patient() -> Dict[str, Any]: - """Patient with diabetes for condition-specific testing""" - return { - 'clinical_background': { - 'patient_name': 'Тестовий Пацієнт', - 'active_problems': ['Цукровий діабет 2 типу', 'Ожиріння'], - 'current_medications': ['Метформін', 'Інсулін'], - 'critical_alerts': [] - }, - 'lifestyle_profile': { - 'journey_summary': 'Пацієнт розпочав lifestyle програму 2 місяці тому. Має проблеми з дотриманням дієти.', - 'communication_preferences': {'style': 'motivational'}, - 'progress_indicators': {'adherence_level': 'medium', 'motivation_state': 'struggling'} - } - } - - @staticmethod - def get_hypertension_patient() -> Dict[str, Any]: - """Patient with hypertension for cardiovascular testing""" - return { - 'clinical_background': { - 'patient_name': 'Тестовий Пацієнт', - 'active_problems': ['Артеріальна гіпертензія', 'Гіперхолестеринемія'], - 'current_medications': ['Ліноприл', 'Аторвастатин'], - 'critical_alerts': ['Неконтрольований АТ 180/110'] - }, - 'lifestyle_profile': { - 'journey_summary': 'Новий пацієнт, вперше звернувся за lifestyle коучингом.', - 'communication_preferences': {'style': 'conservative'}, - 'progress_indicators': {'adherence_level': 'unknown', 'motivation_state': 'engaged'} - } - } - - @staticmethod - def get_healthy_patient() -> Dict[str, Any]: - """Healthy patient for baseline testing""" - return { - 'clinical_background': { - 'patient_name': 'Здоровий Пацієнт', - 'active_problems': [], - 'current_medications': [], - 'critical_alerts': [] - }, - 'lifestyle_profile': { - 'journey_summary': 'Активний пацієнт з регулярними тренуваннями. Хоче покращити харчування.', - 'communication_preferences': {'style': 'technical'}, - 'progress_indicators': {'adherence_level': 'high', 'motivation_state': 'engaged'} - } - } - -# === COMPONENT LIBRARY TESTING === - -class TestMedicalComponentLibrary: - """Test suite for medical component library""" - - def setup_method(self): - """Setup for each test method""" - self.library = MedicalComponentLibrary() - - def test_library_initialization(self): - """Test library initializes with required components""" - # Check that essential safety components exist - assert self.library.get_component('base_medical_safety') is not None - assert self.library.get_component('emergency_protocols') is not None - - # Check that safety validation passes - safety_components = self.library.get_safety_components() - assert len(safety_components) > 0 - assert self.library.validate_component_safety(safety_components) - - def test_component_safety_validation(self): - """Test medical safety component validation""" - # Test with empty component list - assert not self.library.validate_component_safety([]) - - # Test with safety components - safety_components = self.library.get_safety_components() - assert self.library.validate_component_safety(safety_components) - - # Test with non-safety components only - non_safety_components = [ - comp for comp in self.library.components.values() - if not comp.medical_safety - ] - if non_safety_components: - assert not self.library.validate_component_safety(non_safety_components) - - def test_condition_specific_components(self): - """Test condition-specific component selection""" - # Test diabetes components - diabetes_components = self.library.get_components_by_condition('diabetes') - assert len(diabetes_components) > 0 - assert any('diabetes' in comp.name.lower() for comp in diabetes_components) - - # Test hypertension components - hypertension_components = self.library.get_components_by_condition('hypertension') - assert len(hypertension_components) > 0 - assert any('hypertension' in comp.name.lower() for comp in hypertension_components) - - def test_component_classification_integration(self): - """Test component selection based on classification specification""" - # Create test classification - test_spec = PromptCompositionSpec( - session_focus="medical_management", - medical_emphasis=["diabetes", "hypertension"], - communication_style="conservative", - component_priorities={"safety_protocols": "enhanced"}, - safety_level=SafetyLevel.ENHANCED - ) - - # Get components for classification - selected_components = self.library.get_components_for_classification(test_spec) - - # Validate selection - assert len(selected_components) > 0 - assert any(comp.medical_safety for comp in selected_components) - assert self.library.validate_component_safety(selected_components) - -# === PROMPT CLASSIFIER TESTING === - -class TestLLMPromptClassifier: - """Test suite for LLM prompt classifier""" - - def setup_method(self): - """Setup for each test method""" - self.mock_api = MockAIClient({ - 'classification': json.dumps({ - "session_focus": "weight_management", - "medical_emphasis": ["diabetes"], - "communication_style": "motivational", - "component_priorities": { - "condition_specific": ["diabetes"], - "motivational": "high", - "educational": "detailed", - "safety_protocols": "enhanced" - }, - "safety_level": "enhanced", - "reasoning": "Patient with diabetes requesting weight management guidance" - }) - }) - self.classifier = LLMPromptClassifier(self.mock_api) - - @pytest.mark.asyncio - async def test_classification_with_diabetes_patient(self): - """Test classification for diabetes patient""" - # Prepare test context - patient_data = TestPatientProfiles.get_diabetes_patient() - context = ClassificationContext( - patient_request="Хочу схуднути безпечно при діабеті", - clinical_background=patient_data['clinical_background'], - lifestyle_profile=patient_data['lifestyle_profile'] - ) - - # Perform classification - result = await self.classifier.classify_session_requirements(context) - - # Validate result - assert isinstance(result, PromptCompositionSpec) - assert result.session_focus in ['weight_management', 'medical_management', 'general_wellness'] - assert 'diabetes' in [emp.lower() for emp in result.medical_emphasis] or 'діабет' in [emp.lower() for emp in result.medical_emphasis] - assert result.safety_level in [SafetyLevel.ENHANCED, SafetyLevel.MAXIMUM] - - def test_classification_cache(self): - """Test classification caching functionality""" - # Create test context - patient_data = TestPatientProfiles.get_healthy_patient() - context = ClassificationContext( - patient_request="Хочу покращити фізичну форму", - clinical_background=patient_data['clinical_background'], - lifestyle_profile=patient_data['lifestyle_profile'] - ) - - # Test cache miss and hit - cached_result = self.classifier.cache.get_cached_classification(context) - assert cached_result is None # Should be cache miss - - # Cache a test result - test_spec = PromptCompositionSpec( - session_focus="fitness_building", - medical_emphasis=[], - communication_style="technical", - component_priorities={}, - safety_level=SafetyLevel.STANDARD - ) - - self.classifier.cache.cache_classification(context, test_spec) - - # Test cache hit - cached_result = self.classifier.cache.get_cached_classification(context) - assert cached_result is not None - assert cached_result.session_focus == "fitness_building" - - def test_safety_fallback_classification(self): - """Test fallback to safe classification when LLM fails""" - # Create classifier with failing mock API - failing_api = MockAIClient() - failing_api.generate_response = Mock(side_effect=Exception("API failure")) - - classifier = LLMPromptClassifier(failing_api) - - # Test fallback classification - patient_data = TestPatientProfiles.get_hypertension_patient() - context = ClassificationContext( - patient_request="Хочу почати займатися спортом", - clinical_background=patient_data['clinical_background'], - lifestyle_profile=patient_data['lifestyle_profile'] - ) - - # Should not raise exception and return safe default - result = classifier._generate_safe_default_classification(context) - - assert isinstance(result, PromptCompositionSpec) - assert result.safety_level in [SafetyLevel.ENHANCED, SafetyLevel.MAXIMUM] - assert result.communication_style == "conservative" # Conservative for safety - -# === TEMPLATE ASSEMBLER TESTING === - -class TestDynamicTemplateAssembler: - """Test suite for dynamic template assembler""" - - def setup_method(self): - """Setup for each test method""" - self.assembler = DynamicTemplateAssembler() - - def test_basic_prompt_assembly(self): - """Test basic prompt assembly functionality""" - # Create test classification - classification_spec = PromptCompositionSpec( - session_focus="general_wellness", - medical_emphasis=["diabetes"], - communication_style="friendly", - component_priorities={"safety_protocols": "standard"}, - safety_level=SafetyLevel.STANDARD - ) - - # Prepare test data - patient_data = TestPatientProfiles.get_diabetes_patient() - - # Assemble prompt - result = self.assembler.assemble_personalized_prompt( - classification_spec, - patient_data['clinical_background'], - patient_data['lifestyle_profile'] - ) - - # Validate assembly result - assert isinstance(result, AssemblyResult) - assert result.safety_validated - assert len(result.assembled_prompt) > 100 # Should be substantial prompt - assert 'diabetes' in result.assembled_prompt.lower() or 'діабет' in result.assembled_prompt.lower() - assert len(result.components_used) > 0 - - def test_medical_safety_validation(self): - """Test comprehensive medical safety validation""" - # Test safety validator directly - validator = MedicalSafetyValidator() - - # Test prompt with all safety elements - safe_prompt = """ - Ви є медичний lifestyle коуч. - - КРИТИЧНІ ПРОТОКОЛИ МЕДИЧНОЇ БЕЗПЕКИ: - • консультуватися з лікарем - • припинити активність - • екстрені ситуації - • моніторинг самопочуття - - При діабеті: - • моніторинг глюкози - • швидкі вуглеводи - • координація з прийомом їжі - """ - - patient_data = TestPatientProfiles.get_diabetes_patient() - classification_spec = PromptCompositionSpec( - session_focus="medical_management", - medical_emphasis=["diabetes"], - communication_style="conservative", - component_priorities={}, - safety_level=SafetyLevel.ENHANCED - ) - - is_safe, violations, warnings = validator.validate_assembled_prompt( - safe_prompt, - ["base_medical_safety", "diabetes_management"], - patient_data['clinical_background'], - classification_spec - ) - - assert is_safe - assert len(violations) == 0 - - def test_safety_correction_mechanism(self): - """Test automatic safety correction when validation fails""" - # Create assembler with controlled component library - assembler = self.assembler - - # Create classification that might miss safety requirements - classification_spec = PromptCompositionSpec( - session_focus="fitness_building", - medical_emphasis=[], # No medical emphasis - communication_style="motivational", - component_priorities={"motivational": "high"}, - safety_level=SafetyLevel.STANDARD - ) - - patient_data = TestPatientProfiles.get_hypertension_patient() - - # Assembly should still include safety components - result = assembler.assemble_personalized_prompt( - classification_spec, - patient_data['clinical_background'], - patient_data['lifestyle_profile'] - ) - - # Validate safety inclusion - assert result.safety_validated - assert any('safety' in comp_name for comp_name in result.components_used) - - def test_fallback_assembly(self): - """Test fallback assembly when normal process fails""" - # Test fallback generation - patient_data = TestPatientProfiles.get_healthy_patient() - - result = self.assembler._generate_fallback_assembly( - patient_data['clinical_background'], - patient_data['lifestyle_profile'], - "Test failure reason" - ) - - assert isinstance(result, AssemblyResult) - assert result.safety_validated - assert len(result.assembled_prompt) > 50 - assert "Тест" in result.assembly_notes[0] or "failure" in result.assembly_notes[0] - -# === INTEGRATION TESTING === - -class TestEnhancedMainLifestyleAssistant: - """Integration tests for enhanced lifestyle assistant""" - - def setup_method(self): - """Setup for each test method""" - self.mock_api = MockAIClient() - self.assistant = EnhancedMainLifestyleAssistant(self.mock_api) - - def test_static_mode_operation(self): - """Test assistant operates correctly in static mode""" - # Ensure dynamic composition is disabled - self.assistant._disable_dynamic_composition() - - # Test static prompt retrieval - prompt = self.assistant.get_current_system_prompt() - assert prompt == self.assistant.default_system_prompt - - # Test with profile data (should still use static) - patient_data = TestPatientProfiles.get_healthy_patient() - clinical_bg = Mock() - clinical_bg.patient_name = patient_data['clinical_background']['patient_name'] - clinical_bg.active_problems = patient_data['clinical_background']['active_problems'] - - lifestyle_profile = Mock() - lifestyle_profile.journey_summary = patient_data['lifestyle_profile']['journey_summary'] - - prompt = self.assistant.get_current_system_prompt( - lifestyle_profile=lifestyle_profile, - clinical_background=clinical_bg - ) - assert prompt == self.assistant.default_system_prompt - - def test_custom_prompt_priority(self): - """Test custom prompt takes priority over all other modes""" - custom_prompt = "Це кастомний промпт для тестування" - - # Set custom prompt - self.assistant.set_custom_system_prompt(custom_prompt) - - # Should return custom prompt regardless of other parameters - patient_data = TestPatientProfiles.get_diabetes_patient() - session_context = {'patient_request': 'Тестовий запит'} - - prompt = self.assistant.get_current_system_prompt( - session_context=session_context - ) - - assert prompt == custom_prompt - - def test_dynamic_composition_when_enabled(self): - """Test dynamic composition when properly enabled""" - # Enable dynamic composition for this test - with patch('src.config.dynamic.DynamicPromptConfig.ENABLED', True): - # Mock successful dynamic composition - self.assistant.dynamic_composition_enabled = True - self.assistant.prompt_classifier = Mock() - self.assistant.template_assembler = Mock() - - # Mock classification result - mock_classification = PromptCompositionSpec( - session_focus="test_focus", - medical_emphasis=["test_condition"], - communication_style="friendly", - component_priorities={}, - safety_level=SafetyLevel.STANDARD - ) - - # Mock assembly result - mock_assembly = AssemblyResult( - assembled_prompt="Тестовий динамічний промпт", - components_used=["test_component"], - safety_validated=True, - assembly_notes=["Test assembly"] - ) - - # Configure mocks - if hasattr(self.assistant.prompt_classifier, 'classify_session_requirements'): - self.assistant.prompt_classifier.classify_session_requirements.return_value = mock_classification - - if hasattr(self.assistant.template_assembler, 'assemble_personalized_prompt'): - self.assistant.template_assembler.assemble_personalized_prompt.return_value = mock_assembly - - # Test dynamic prompt generation - session_context = {'patient_request': 'Хочу схуднути'} - patient_data = TestPatientProfiles.get_healthy_patient() - - # Should attempt dynamic composition (would return fallback in real scenario) - prompt = self.assistant.get_current_system_prompt( - session_context=session_context - ) - - # Should fall back to static prompt due to mock limitations - assert prompt == self.assistant.default_system_prompt - - def test_composition_status_reporting(self): - """Test composition status reporting functionality""" - status = self.assistant.get_composition_status() - - # Validate status structure - assert 'dynamic_composition_enabled' in status - assert 'dynamic_components_available' in status - assert 'custom_prompt_active' in status - assert 'static_fallback_available' in status - assert 'configuration' in status - - # Static fallback should always be available - assert status['static_fallback_available'] is True - -# === PERFORMANCE TESTING === - -class TestPerformanceBenchmarks: - """Performance benchmarks for dynamic prompt composition""" - - def setup_method(self): - """Setup for performance testing""" - self.mock_api = MockAIClient() - self.library = MedicalComponentLibrary() - self.assembler = DynamicTemplateAssembler() - - def test_component_library_performance(self): - """Test component library performance""" - class TimeoutError(Exception): - pass - - def timeout_handler(signum, frame): - raise TimeoutError("Component library performance test timed out") - - # Set the signal handler - signal.signal(signal.SIGALRM, timeout_handler) - signal.alarm(5) # 5 second timeout - - try: - start_time = time.time() - - # Test multiple component selections - for i in range(100): - test_spec = PromptCompositionSpec( - session_focus="general_wellness", - medical_emphasis=["diabetes", "hypertension"], - communication_style="friendly", - component_priorities={}, - safety_level=SafetyLevel.STANDARD - ) - - components = self.library.get_components_for_classification(test_spec) - assert len(components) > 0 - - elapsed_time = time.time() - start_time - - # Should complete 100 selections in under 1 second - assert elapsed_time < 1.0, f"Component selection took too long: {elapsed_time:.3f}s" - print(f"✅ Component selection performance: {elapsed_time:.3f}s for 100 operations") - - except TimeoutError as e: - print(f"❌ {str(e)}") - raise - finally: - # Disable the alarm - signal.alarm(0) - - def test_prompt_assembly_performance(self): - """Test prompt assembly performance""" - class TimeoutError(Exception): - pass - - def timeout_handler(signum, frame): - raise TimeoutError("Prompt assembly performance test timed out") - - # Set the signal handler - signal.signal(signal.SIGALRM, timeout_handler) - signal.alarm(10) # 10 second timeout - - try: - patient_data = TestPatientProfiles.get_diabetes_patient() - - classification_spec = PromptCompositionSpec( - session_focus="medical_management", - medical_emphasis=["diabetes"], - communication_style="conservative", - component_priorities={}, - safety_level=SafetyLevel.ENHANCED - ) - - start_time = time.time() - - # Test multiple assemblies - for i in range(50): - result = self.assembler.assemble_personalized_prompt( - classification_spec, - patient_data['clinical_background'], - patient_data['lifestyle_profile'] - ) - assert result.safety_validated - - elapsed_time = time.time() - start_time - - # Should complete 50 assemblies in under 2 seconds - assert elapsed_time < 2.0, f"Prompt assembly took too long: {elapsed_time:.3f}s" - print(f"✅ Prompt assembly performance: {elapsed_time:.3f}s for 50 operations") - - except TimeoutError as e: - print(f"❌ {str(e)}") - raise - finally: - # Disable the alarm - signal.alarm(0) - -# === TEST EXECUTION AND REPORTING === - -def run_comprehensive_test_suite(): - """Run comprehensive test suite with detailed reporting""" - - print("=== COMPREHENSIVE DYNAMIC PROMPT TESTING ===") - print(f"Test execution started: {datetime.now().isoformat()}") - - # Check component availability - if not COMPONENTS_AVAILABLE: - print("❌ Dynamic prompt components not available - skipping tests") - return False - - # Test configuration - test_results = { - 'component_library': False, - 'prompt_classifier': False, - 'template_assembler': False, - 'integration': False, - 'performance': False - } - - try: - # Component Library Tests - print("\n🧪 Testing Medical Component Library...") - library_test = TestMedicalComponentLibrary() - library_test.setup_method() - library_test.test_library_initialization() - library_test.test_component_safety_validation() - library_test.test_condition_specific_components() - test_results['component_library'] = True - print("✅ Component Library tests passed") - - # Prompt Classifier Tests - print("\n🧪 Testing LLM Prompt Classifier...") - classifier_test = TestLLMPromptClassifier() - classifier_test.setup_method() - classifier_test.test_classification_cache() - classifier_test.test_safety_fallback_classification() - test_results['prompt_classifier'] = True - print("✅ Prompt Classifier tests passed") - - # Template Assembler Tests - print("\n🧪 Testing Dynamic Template Assembler...") - assembler_test = TestDynamicTemplateAssembler() - assembler_test.setup_method() - assembler_test.test_basic_prompt_assembly() - assembler_test.test_medical_safety_validation() - assembler_test.test_fallback_assembly() - test_results['template_assembler'] = True - print("✅ Template Assembler tests passed") - - # Integration Tests - print("\n🧪 Testing Enhanced Lifestyle Assistant Integration...") - integration_test = TestEnhancedMainLifestyleAssistant() - integration_test.setup_method() - integration_test.test_static_mode_operation() - integration_test.test_custom_prompt_priority() - integration_test.test_composition_status_reporting() - test_results['integration'] = True - print("✅ Integration tests passed") - - # Performance Tests - print("\n🧪 Testing Performance Benchmarks...") - performance_test = TestPerformanceBenchmarks() - performance_test.setup_method() - performance_test.test_component_library_performance() - performance_test.test_prompt_assembly_performance() - test_results['performance'] = True - print("✅ Performance tests passed") - - except Exception as e: - print(f"❌ Test execution failed: {e}") - import traceback - traceback.print_exc() - return False - - # Final report - print("\n=== TEST EXECUTION SUMMARY ===") - all_passed = all(test_results.values()) - - for test_category, passed in test_results.items(): - status = "✅ PASSED" if passed else "❌ FAILED" - print(f"{test_category.replace('_', ' ').title()}: {status}") - - print(f"\nOverall Result: {'✅ ALL TESTS PASSED' if all_passed else '❌ SOME TESTS FAILED'}") - print(f"Test execution completed: {datetime.now().isoformat()}") - - return all_passed - -if __name__ == "__main__": - # Run tests if executed directly - success = run_comprehensive_test_suite() - exit(0 if success else 1) \ No newline at end of file diff --git a/tests/test_english_logic.py b/tests/test_english_logic.py deleted file mode 100644 index a29216a977f712bc243383b466164b14c3e4923e..0000000000000000000000000000000000000000 --- a/tests/test_english_logic.py +++ /dev/null @@ -1,357 +0,0 @@ -#!/usr/bin/env python3 -""" -Test script for new logic without Gemini API dependencies - English version -""" - -import json -from datetime import datetime -from dataclasses import dataclass, asdict -from typing import List, Dict, Optional, Tuple - -# Mock classes for testing without API -@dataclass -class MockClinicalBackground: - patient_name: str = "Test Patient" - active_problems: List[str] = None - current_medications: List[str] = None - critical_alerts: List[str] = None - - def __post_init__(self): - if self.active_problems is None: - self.active_problems = ["Hypertension", "Type 2 diabetes"] - if self.current_medications is None: - self.current_medications = ["Metformin", "Enalapril"] - if self.critical_alerts is None: - self.critical_alerts = [] - -@dataclass -class MockLifestyleProfile: - patient_name: str = "Test Patient" - patient_age: str = "45" - primary_goal: str = "Improve physical fitness" - journey_summary: str = "" - last_session_summary: str = "" - -class MockAPI: - def __init__(self): - self.call_counter = 0 - - def generate_response(self, system_prompt: str, user_prompt: str, temperature: float = 0.3, call_type: str = "") -> str: - self.call_counter += 1 - - # Mock responses for different classifier types - if call_type == "ENTRY_CLASSIFIER": - # New K/V/T format - lifestyle_keywords = ["exercise", "sport", "workout", "fitness", "training", "exercising", "running"] - medical_keywords = ["pain", "hurt", "sick", "ache"] - - has_lifestyle = any(keyword in user_prompt.lower() for keyword in lifestyle_keywords) - has_medical = any(keyword in user_prompt.lower() for keyword in medical_keywords) - - if has_lifestyle and has_medical: - return json.dumps({ - "K": "Lifestyle Mode", - "V": "hybrid", - "T": "2025-09-04T11:30:00Z" - }) - elif has_medical: - return json.dumps({ - "K": "Lifestyle Mode", - "V": "off", - "T": "2025-09-04T11:30:00Z" - }) - elif has_lifestyle: - return json.dumps({ - "K": "Lifestyle Mode", - "V": "on", - "T": "2025-09-04T11:30:00Z" - }) - elif any(greeting in user_prompt.lower() for greeting in ["hello", "hi", "good morning", "goodbye", "thank you"]): - return json.dumps({ - "K": "Lifestyle Mode", - "V": "off", - "T": "2025-09-04T11:30:00Z" - }) - else: - return json.dumps({ - "K": "Lifestyle Mode", - "V": "off", - "T": "2025-09-04T11:30:00Z" - }) - - elif call_type == "TRIAGE_EXIT_CLASSIFIER": - return json.dumps({ - "ready_for_lifestyle": True, - "reasoning": "Medical issues resolved, ready for lifestyle coaching", - "medical_status": "stable" - }) - - elif call_type == "LIFESTYLE_EXIT_CLASSIFIER": - # Improved logic for recognizing different exit reasons - exit_keywords = ["finish", "end", "stop", "enough", "done", "quit"] - medical_keywords = ["pain", "hurt", "sick", "symptom", "feel bad"] - - user_lower = user_prompt.lower() - - # Check for medical complaints - if any(keyword in user_lower for keyword in medical_keywords): - return json.dumps({ - "should_exit": True, - "reasoning": "Medical complaints detected - need to switch to medical mode", - "exit_reason": "medical_concerns" - }) - - # Check for completion requests - elif any(keyword in user_lower for keyword in exit_keywords): - return json.dumps({ - "should_exit": True, - "reasoning": "Patient requests to end lifestyle session", - "exit_reason": "patient_request" - }) - - # Check session length (simulation through message length) - elif len(user_prompt) > 500: - return json.dumps({ - "should_exit": True, - "reasoning": "Session running too long", - "exit_reason": "session_length" - }) - - # Continue session - else: - return json.dumps({ - "should_exit": False, - "reasoning": "Continue lifestyle session", - "exit_reason": "none" - }) - - elif call_type == "MEDICAL_ASSISTANT": - return f"🏥 Medical response to: {user_prompt[:50]}..." - - elif call_type == "MAIN_LIFESTYLE": - # Mock for new Main Lifestyle Assistant - if any(keyword in user_prompt.lower() for keyword in ["pain", "hurt", "sick"]): - return json.dumps({ - "message": "I understand you have discomfort. Let's discuss this with a doctor.", - "action": "close", - "reasoning": "Medical complaints require ending lifestyle session" - }) - elif any(keyword in user_prompt.lower() for keyword in ["finish", "end", "done", "stop"]): - return json.dumps({ - "message": "Thank you for the session! You did great work today.", - "action": "close", - "reasoning": "Patient requests to end session" - }) - elif len(user_prompt) > 400: # Simulation of long session - return json.dumps({ - "message": "We've done good work today. Time to wrap up.", - "action": "close", - "reasoning": "Session running too long" - }) - # Improved logic for gather_info - elif any(keyword in user_prompt.lower() for keyword in ["how to start", "what should", "which exercises", "suitable for me"]): - return json.dumps({ - "message": "Tell me more about your preferences and limitations.", - "action": "gather_info", - "reasoning": "Need to gather more information for better recommendations" - }) - # Check if this is start of lifestyle session (needs info gathering) - elif ("want to start" in user_prompt.lower() or "start exercising" in user_prompt.lower()) and any(keyword in user_prompt.lower() for keyword in ["exercise", "sport", "workout", "exercising"]): - return json.dumps({ - "message": "Great! Tell me about your current activity level and preferences.", - "action": "gather_info", - "reasoning": "Start of lifestyle session - need to gather basic information" - }) - else: - return json.dumps({ - "message": "💚 Excellent! Here are my recommendations for you...", - "action": "lifestyle_dialog", - "reasoning": "Providing lifestyle advice and support" - }) - - elif call_type == "LIFESTYLE_ASSISTANT": - return f"💚 Lifestyle response to: {user_prompt[:50]}..." - - else: - return f"Mock response for {call_type}: {user_prompt[:30]}..." - -def test_entry_classifier(): - """Tests Entry Classifier logic""" - print("🧪 Testing Entry Classifier...") - - api = MockAPI() - - test_cases = [ - ("I have a headache", "off"), - ("I want to start exercising", "on"), - ("I want to exercise but my back hurts", "hybrid"), - ("Hello", "off"), # now neutral → off - ("How are you?", "off"), - ("Goodbye", "off"), - ("Thank you", "off"), - ("What should I do about blood pressure?", "off") - ] - - for message, expected in test_cases: - response = api.generate_response("", message, call_type="ENTRY_CLASSIFIER") - try: - result = json.loads(response) - actual = result.get("V") # New K/V/T format - status = "✅" if actual == expected else "❌" - print(f" {status} '{message}' → V={actual} (expected: {expected})") - except: - print(f" ❌ Parse error for: '{message}'") - -def test_lifecycle_flow(): - """Tests complete lifecycle flow""" - print("\n🔄 Testing Lifecycle flow...") - - api = MockAPI() - - # Simulation of different scenarios - scenarios = [ - { - "name": "Medical → Medical", - "message": "I have a headache", - "expected_flow": "MEDICAL → medical_response" - }, - { - "name": "Lifestyle → Lifestyle", - "message": "I want to start running", - "expected_flow": "LIFESTYLE → lifestyle_response" - }, - { - "name": "Hybrid → Triage → Lifestyle", - "message": "I want to exercise but my back hurts", - "expected_flow": "HYBRID → medical_triage → lifestyle_response" - } - ] - - for scenario in scenarios: - print(f"\n 📋 Scenario: {scenario['name']}") - print(f" Message: '{scenario['message']}'") - - # Entry classification - entry_response = api.generate_response("", scenario['message'], call_type="ENTRY_CLASSIFIER") - try: - entry_result = json.loads(entry_response) - category = entry_result.get("category") - print(f" Entry Classifier: {category}") - - if category == "HYBRID": - # Triage assessment - triage_response = api.generate_response("", scenario['message'], call_type="TRIAGE_EXIT_CLASSIFIER") - triage_result = json.loads(triage_response) - ready = triage_result.get("ready_for_lifestyle") - print(f" Triage Assessment: ready_for_lifestyle={ready}") - - except Exception as e: - print(f" ❌ Error: {e}") - -def test_neutral_interactions(): - """Tests neutral interactions""" - print("\n🤝 Testing neutral interactions...") - - neutral_responses = { - "hello": "Hello! How are you feeling today?", - "good morning": "Good morning! How is your health?", - "how are you": "Thank you for asking! How are your health matters?", - "goodbye": "Goodbye! Take care and reach out if you have questions.", - "thank you": "You're welcome! Always happy to help. How are you feeling?" - } - - for message, expected_pattern in neutral_responses.items(): - # Simulation of neutral response - message_lower = message.lower().strip() - found_match = False - - for key in neutral_responses.keys(): - if key in message_lower: - found_match = True - break - - status = "✅" if found_match else "❌" - print(f" {status} '{message}' → neutral response (expected: natural interaction)") - - print(" ✅ Neutral interactions work correctly") - -def test_main_lifestyle_assistant(): - """Tests new Main Lifestyle Assistant with 3 actions""" - print("\n🎯 Testing Main Lifestyle Assistant...") - - api = MockAPI() - - test_cases = [ - ("I want to start exercising", "gather_info", "Information gathering"), - ("Give me nutrition advice", "lifestyle_dialog", "Lifestyle dialog"), - ("My back hurts", "close", "Medical complaints → close"), - ("I want to finish for today", "close", "Request to end"), - ("Which exercises are suitable for me?", "gather_info", "Need additional information"), - ("How to start training?", "gather_info", "Starting question"), - ("Let's continue our workout", "lifestyle_dialog", "Continue lifestyle dialog") - ] - - for message, expected_action, description in test_cases: - response = api.generate_response("", message, call_type="MAIN_LIFESTYLE") - try: - result = json.loads(response) - actual_action = result.get("action") - message_text = result.get("message", "") - status = "✅" if actual_action == expected_action else "❌" - print(f" {status} '{message}' → {actual_action} ({description})") - print(f" Response: {message_text[:60]}...") - except Exception as e: - print(f" ❌ Parse error for: '{message}' - {e}") - - print(" ✅ Main Lifestyle Assistant works correctly") - -def test_profile_update(): - """Tests profile update""" - print("\n📝 Testing profile update...") - - # Simulation of chat_history - mock_messages = [ - {"role": "user", "message": "I want to start running", "mode": "lifestyle"}, - {"role": "assistant", "message": "Excellent! Let's start with light jogging", "mode": "lifestyle"}, - {"role": "user", "message": "How many times per week?", "mode": "lifestyle"}, - {"role": "assistant", "message": "I recommend 3 times per week", "mode": "lifestyle"} - ] - - # Initial profile - profile = MockLifestyleProfile() - print(f" Initial journey_summary: '{profile.journey_summary}'") - - # Simulation of update - session_date = datetime.now().strftime('%d.%m.%Y') - user_messages = [msg["message"] for msg in mock_messages if msg["role"] == "user"] - - if user_messages: - key_topics = [msg[:60] + "..." if len(msg) > 60 else msg for msg in user_messages[:3]] - session_summary = f"[{session_date}] Discussed: {'; '.join(key_topics)}" - profile.last_session_summary = session_summary - - new_entry = f" | {session_date}: {len([m for m in mock_messages if m['mode'] == 'lifestyle'])} messages" - profile.journey_summary += new_entry - - print(f" Updated last_session_summary: '{profile.last_session_summary}'") - print(f" Updated journey_summary: '{profile.journey_summary}'") - print(" ✅ Profile successfully updated") - -if __name__ == "__main__": - print("🚀 Testing new message processing logic\n") - - test_entry_classifier() - test_lifecycle_flow() - test_neutral_interactions() - test_main_lifestyle_assistant() - test_profile_update() - - print("\n✅ All tests completed!") - print("\n📋 Summary of improved logic:") - print(" • Entry Classifier: classifies MEDICAL/LIFESTYLE/HYBRID/NEUTRAL") - print(" • Neutral interactions: natural responses to greetings without premature lifestyle") - print(" • Main Lifestyle Assistant: 3 actions (gather_info, lifestyle_dialog, close)") - print(" • Triage Exit Classifier: evaluates readiness for lifestyle after triage") - print(" • Lifestyle Exit Classifier: controls exit from lifestyle mode (deprecated)") - print(" • Smart profile updates without data bloat") - print(" • Full backward compatibility with existing code") \ No newline at end of file diff --git a/tests/test_entry_classifier.py b/tests/test_entry_classifier.py deleted file mode 100644 index 512d0771ad6e095a645f44237314b1b16fd7e194..0000000000000000000000000000000000000000 --- a/tests/test_entry_classifier.py +++ /dev/null @@ -1,96 +0,0 @@ -#!/usr/bin/env python3 -""" -Test script to verify Entry Classifier is working correctly -""" - -import json -from src.core.core_classes import GeminiAPI, EntryClassifier, ClinicalBackground - -# Mock API for testing -class MockGeminiAPI: - def __init__(self): - self.call_counter = 0 - - def generate_response(self, system_prompt, user_prompt, temperature=0.7, call_type=""): - self.call_counter += 1 - - # Simulate real Gemini responses based on user message - user_message = user_prompt.split('PATIENT MESSAGE: "')[1].split('"')[0] if 'PATIENT MESSAGE: "' in user_prompt else "" - - print(f"🔍 Testing message: '{user_message}'") - - # Improved classification logic - if any(word in user_message.lower() for word in ["вправ", "спорт", "тренув", "реабілітац", "фізичн", "exercise", "workout", "fitness"]): - if any(word in user_message.lower() for word in ["болить", "біль", "pain", "симптом"]): - return '{"K": "Lifestyle Mode", "V": "hybrid", "T": "2024-09-05T12:00:00Z"}' - else: - return '{"K": "Lifestyle Mode", "V": "on", "T": "2024-09-05T12:00:00Z"}' - elif any(word in user_message.lower() for word in ["болить", "біль", "нудота", "симптом", "pain", "nausea"]): - return '{"K": "Lifestyle Mode", "V": "off", "T": "2024-09-05T12:00:00Z"}' - else: - return '{"K": "Lifestyle Mode", "V": "off", "T": "2024-09-05T12:00:00Z"}' - -def test_entry_classifier(): - """Test Entry Classifier with various messages""" - - print("🧪 Testing Entry Classifier with improved prompts...") - - # Create mock API and classifier - api = MockGeminiAPI() - classifier = EntryClassifier(api) - - # Create mock clinical background - clinical_bg = ClinicalBackground( - patient_id="test", - patient_name="Serhii", - patient_age="52", - active_problems=["Type 2 diabetes", "Hypertension"], - past_medical_history=[], - current_medications=["Amlodipine"], - allergies="None", - vital_signs_and_measurements=[], - laboratory_results=[], - assessment_and_plan="", - critical_alerts=[], - social_history={}, - recent_clinical_events=[] - ) - - # Test cases - test_cases = [ - ("усе добре давай займемося вправами", "on", "Clear exercise request"), - ("хочу почати тренуватися", "on", "Fitness motivation"), - ("поговоримо про реабілітацію", "on", "Rehabilitation discussion"), - ("давай займемося спортом", "on", "Sports activity request"), - ("які вправи мені підходять", "on", "Exercise inquiry"), - ("у мене болить голова", "off", "Medical symptom"), - ("привіт", "off", "Greeting"), - ("хочу займатися спортом але болить спина", "hybrid", "Mixed lifestyle + medical"), - ] - - results = [] - for message, expected, description in test_cases: - try: - classification = classifier.classify(message, clinical_bg) - actual = classification.get("V", "unknown") - status = "✅" if actual == expected else "❌" - results.append((status, message, actual, expected, description)) - print(f" {status} '{message}' → V={actual} (expected: {expected}) - {description}") - except Exception as e: - print(f" ❌ Error testing '{message}': {e}") - results.append(("❌", message, "error", expected, description)) - - # Summary - passed = sum(1 for r in results if r[0] == "✅") - total = len(results) - print(f"\n📊 Results: {passed}/{total} tests passed") - - if passed == total: - print("🎉 All Entry Classifier tests passed!") - else: - print("⚠️ Some tests failed - Entry Classifier needs adjustment") - - return passed == total - -if __name__ == "__main__": - test_entry_classifier() \ No newline at end of file diff --git a/tests/test_new_logic.py b/tests/test_new_logic.py deleted file mode 100644 index b450d49e846999fe91713c7f37265b0e97bd3c82..0000000000000000000000000000000000000000 --- a/tests/test_new_logic.py +++ /dev/null @@ -1,354 +0,0 @@ -#!/usr/bin/env python3 -""" -Тестовий скрипт для нової логіки без залежностей від Gemini API -""" - -import json -from datetime import datetime -from dataclasses import dataclass, asdict -from typing import List, Dict, Optional, Tuple - -# Мок класи для тестування без API -@dataclass -class MockClinicalBackground: - patient_name: str = "Тестовий Пацієнт" - active_problems: List[str] = None - current_medications: List[str] = None - critical_alerts: List[str] = None - - def __post_init__(self): - if self.active_problems is None: - self.active_problems = ["Гіпертензія", "Діабет 2 типу"] - if self.current_medications is None: - self.current_medications = ["Метформін", "Еналаприл"] - if self.critical_alerts is None: - self.critical_alerts = [] - -@dataclass -class MockLifestyleProfile: - patient_name: str = "Тестовий Пацієнт" - patient_age: str = "45" - primary_goal: str = "Покращити фізичну форму" - journey_summary: str = "" - last_session_summary: str = "" - -class MockAPI: - def __init__(self): - self.call_counter = 0 - - def generate_response(self, system_prompt: str, user_prompt: str, temperature: float = 0.3, call_type: str = "") -> str: - self.call_counter += 1 - - # Мок відповіді для різних типів класифікаторів - if call_type == "ENTRY_CLASSIFIER": - # Новий K/V/T формат - if "болить" in user_prompt.lower() and "спорт" in user_prompt.lower(): - return json.dumps({ - "K": "Lifestyle Mode", - "V": "hybrid", - "T": "2025-09-04T11:30:00Z" - }) - elif "болить" in user_prompt.lower(): - return json.dumps({ - "K": "Lifestyle Mode", - "V": "off", - "T": "2025-09-04T11:30:00Z" - }) - elif "спорт" in user_prompt.lower() or "фізична активність" in user_prompt.lower(): - return json.dumps({ - "K": "Lifestyle Mode", - "V": "on", - "T": "2025-09-04T11:30:00Z" - }) - elif any(greeting in user_prompt.lower() for greeting in ["привіт", "добрий день", "як справи", "до побачення", "дякую"]): - return json.dumps({ - "K": "Lifestyle Mode", - "V": "off", - "T": "2025-09-04T11:30:00Z" - }) - else: - return json.dumps({ - "K": "Lifestyle Mode", - "V": "off", - "T": "2025-09-04T11:30:00Z" - }) - - elif call_type == "TRIAGE_EXIT_CLASSIFIER": - return json.dumps({ - "ready_for_lifestyle": True, - "reasoning": "Медичні питання вирішені, можна переходити до lifestyle", - "medical_status": "stable" - }) - - elif call_type == "LIFESTYLE_EXIT_CLASSIFIER": - # Покращена логіка розпізнавання різних причин виходу - exit_keywords = ["закінчити", "завершити", "достатньо", "хватит", "стоп", "припинити"] - medical_keywords = ["болить", "біль", "погано", "нездужаю", "симптом"] - - user_lower = user_prompt.lower() - - # Перевіряємо медичні скарги - if any(keyword in user_lower for keyword in medical_keywords): - return json.dumps({ - "should_exit": True, - "reasoning": "Виявлені медичні скарги - потрібен перехід до медичного режиму", - "exit_reason": "medical_concerns" - }) - - # Перевіряємо прохання про завершення - elif any(keyword in user_lower for keyword in exit_keywords): - return json.dumps({ - "should_exit": True, - "reasoning": "Пацієнт просить завершити lifestyle сесію", - "exit_reason": "patient_request" - }) - - # Перевіряємо довжину сесії (симуляція через довжину повідомлення) - elif len(user_prompt) > 500: - return json.dumps({ - "should_exit": True, - "reasoning": "Сесія триває надто довго", - "exit_reason": "session_length" - }) - - # Продовжуємо сесію - else: - return json.dumps({ - "should_exit": False, - "reasoning": "Продовжуємо lifestyle сесію", - "exit_reason": "none" - }) - - elif call_type == "MEDICAL_ASSISTANT": - return f"🏥 Медична відповідь на: {user_prompt[:50]}..." - - elif call_type == "MAIN_LIFESTYLE": - # Мок для нового Main Lifestyle Assistant - if "болить" in user_prompt.lower(): - return json.dumps({ - "message": "Розумію, що у вас є дискомфорт. Давайте обговоримо це з лікарем.", - "action": "close", - "reasoning": "Медичні скарги потребують завершення lifestyle сесії" - }) - elif "закінчити" in user_prompt.lower() or "завершити" in user_prompt.lower(): - return json.dumps({ - "message": "Дякую за сесію! Ви зробили гарну роботу сьогодні.", - "action": "close", - "reasoning": "Пацієнт просить завершити сесію" - }) - elif len(user_prompt) > 400: # Симуляція довгої сесії - return json.dumps({ - "message": "Ми добре попрацювали сьогодні. Час підвести підсумки.", - "action": "close", - "reasoning": "Сесія триває надто довго" - }) - # Покращена логіка для gather_info - elif any(keyword in user_prompt.lower() for keyword in ["як почати", "що робити", "які вправи", "як мені", "підходять для мене"]): - return json.dumps({ - "message": "Розкажіть мені більше про ваші уподобання та обмеження.", - "action": "gather_info", - "reasoning": "Потрібно зібрати більше інформації для кращих рекомендацій" - }) - # Перевіряємо чи це початок lifestyle сесії (потребує збору інформації) - elif "хочу почати" in user_prompt.lower() and "спорт" in user_prompt.lower(): - return json.dumps({ - "message": "Чудово! Розкажіть мені про ваш поточний рівень активності та уподобання.", - "action": "gather_info", - "reasoning": "Початок lifestyle сесії - потрібно зібрати базову інформацію" - }) - else: - return json.dumps({ - "message": "💚 Чудово! Ось мої рекомендації для вас...", - "action": "lifestyle_dialog", - "reasoning": "Надаємо lifestyle поради та підтримку" - }) - - elif call_type == "LIFESTYLE_ASSISTANT": - return f"💚 Lifestyle відповідь на: {user_prompt[:50]}..." - - else: - return f"Мок відповідь для {call_type}: {user_prompt[:30]}..." - -def test_entry_classifier(): - """Тестує Entry Classifier логіку""" - print("🧪 Тестування Entry Classifier...") - - api = MockAPI() - - test_cases = [ - ("У мене болить голова", "off"), - ("Хочу почати займатися спортом", "on"), - ("Хочу займатися спортом, але у мене болить спина", "hybrid"), - ("Привіт", "off"), # тепер neutral → off - ("Як справи?", "off"), - ("До побачення", "off"), - ("Дякую", "off"), - ("Що робити з тиском?", "off") - ] - - for message, expected in test_cases: - response = api.generate_response("", message, call_type="ENTRY_CLASSIFIER") - try: - result = json.loads(response) - actual = result.get("V") # Новий формат K/V/T - status = "✅" if actual == expected else "❌" - print(f" {status} '{message}' → V={actual} (очікувалось: {expected})") - except: - print(f" ❌ Помилка парсингу для: '{message}'") - -def test_lifecycle_flow(): - """Тестує повний lifecycle потік""" - print("\n🔄 Тестування Lifecycle потоку...") - - api = MockAPI() - - # Симуляція різних сценаріїв - scenarios = [ - { - "name": "Medical → Medical", - "message": "У мене болить голова", - "expected_flow": "MEDICAL → medical_response" - }, - { - "name": "Lifestyle → Lifestyle", - "message": "Хочу почати бігати", - "expected_flow": "LIFESTYLE → lifestyle_response" - }, - { - "name": "Hybrid → Triage → Lifestyle", - "message": "Хочу займатися спортом, але у мене болить спина", - "expected_flow": "HYBRID → medical_triage → lifestyle_response" - } - ] - - for scenario in scenarios: - print(f"\n 📋 Сценарій: {scenario['name']}") - print(f" Повідомлення: '{scenario['message']}'") - - # Entry classification - entry_response = api.generate_response("", scenario['message'], call_type="ENTRY_CLASSIFIER") - try: - entry_result = json.loads(entry_response) - category = entry_result.get("category") - print(f" Entry Classifier: {category}") - - if category == "HYBRID": - # Triage assessment - triage_response = api.generate_response("", scenario['message'], call_type="TRIAGE_EXIT_CLASSIFIER") - triage_result = json.loads(triage_response) - ready = triage_result.get("ready_for_lifestyle") - print(f" Triage Assessment: ready_for_lifestyle={ready}") - - except Exception as e: - print(f" ❌ Помилка: {e}") - -# test_lifestyle_exit removed - functionality moved to MainLifestyleAssistant tests - -def test_neutral_interactions(): - """Тестує нейтральні взаємодії""" - print("\n🤝 Тестування нейтральних взаємодій...") - - neutral_responses = { - "привіт": "Привіт! Як ти сьогодні почуваєшся?", - "добрий день": "Добрий день! Як твоє самопочуття?", - "як справи": "Дякую за питання! А як твої справи зі здоров'ям?", - "до побачення": "До побачення! Бережи себе і звертайся, якщо будуть питання.", - "дякую": "Будь ласка! Завжди радий допомогти. Як ти себе почуваєш?" - } - - for message, expected_pattern in neutral_responses.items(): - # Симуляція нейтральної відповіді - message_lower = message.lower().strip() - found_match = False - - for key in neutral_responses.keys(): - if key in message_lower: - found_match = True - break - - status = "✅" if found_match else "❌" - print(f" {status} '{message}' → нейтральна відповідь (очікувалось: природна взаємодія)") - - print(" ✅ Нейтральні взаємодії працюють правильно") - -def test_main_lifestyle_assistant(): - """Тестує новий Main Lifestyle Assistant з 3 діями""" - print("\n🎯 Тестування Main Lifestyle Assistant...") - - api = MockAPI() - - test_cases = [ - ("Хочу почати займатися спортом", "gather_info", "Збір інформації"), - ("Дайте мені поради щодо харчування", "lifestyle_dialog", "Lifestyle діалог"), - ("У мене болить спина", "close", "Медичні скарги → завершення"), - ("Хочу закінчити на сьогодні", "close", "Прохання про завершення"), - ("Які вправи підходять для мене?", "gather_info", "Потрібна додаткова інформація"), - ("Як почати тренуватися?", "gather_info", "Питання про початок"), - ("Продовжуємо наші тренування", "lifestyle_dialog", "Продовження lifestyle діалогу") - ] - - for message, expected_action, description in test_cases: - response = api.generate_response("", message, call_type="MAIN_LIFESTYLE") - try: - result = json.loads(response) - actual_action = result.get("action") - message_text = result.get("message", "") - status = "✅" if actual_action == expected_action else "❌" - print(f" {status} '{message}' → {actual_action} ({description})") - print(f" Відповідь: {message_text[:60]}...") - except Exception as e: - print(f" ❌ Помилка парсингу для: '{message}' - {e}") - - print(" ✅ Main Lifestyle Assistant працює правильно") - -def test_profile_update(): - """Тестує оновлення профілю""" - print("\n📝 Тестування оновлення профілю...") - - # Симуляція chat_history - mock_messages = [ - {"role": "user", "message": "Хочу почати бігати", "mode": "lifestyle"}, - {"role": "assistant", "message": "Відмінно! Почнемо з легких пробіжок", "mode": "lifestyle"}, - {"role": "user", "message": "Скільки разів на тиждень?", "mode": "lifestyle"}, - {"role": "assistant", "message": "Рекомендую 3 рази на тиждень", "mode": "lifestyle"} - ] - - # Початковий профіль - profile = MockLifestyleProfile() - print(f" Початковий journey_summary: '{profile.journey_summary}'") - - # Симуляція оновлення - session_date = datetime.now().strftime('%d.%m.%Y') - user_messages = [msg["message"] for msg in mock_messages if msg["role"] == "user"] - - if user_messages: - key_topics = [msg[:60] + "..." if len(msg) > 60 else msg for msg in user_messages[:3]] - session_summary = f"[{session_date}] Обговорювали: {'; '.join(key_topics)}" - profile.last_session_summary = session_summary - - new_entry = f" | {session_date}: {len([m for m in mock_messages if m['mode'] == 'lifestyle'])} повідомлень" - profile.journey_summary += new_entry - - print(f" Оновлений last_session_summary: '{profile.last_session_summary}'") - print(f" Оновлений journey_summary: '{profile.journey_summary}'") - print(" ✅ Профіль успішно оновлено") - -if __name__ == "__main__": - print("🚀 Тестування нової логіки обробки повідомлень\n") - - test_entry_classifier() - test_lifecycle_flow() - # test_lifestyle_exit() removed - functionality moved to MainLifestyleAssistant - test_neutral_interactions() - test_main_lifestyle_assistant() - test_profile_update() - - print("\n✅ Всі тести завершено!") - print("\n📋 Резюме покращеної логіки:") - print(" • Entry Classifier: класифікує MEDICAL/LIFESTYLE/HYBRID/NEUTRAL") - print(" • Neutral взаємодії: природні відповіді на вітання без передчасного lifestyle") - print(" • Main Lifestyle Assistant: 3 дії (gather_info, lifestyle_dialog, close)") - print(" • Triage Exit Classifier: оцінює готовність до lifestyle після тріажу") - print(" • Lifestyle Exit Classifier: контролює вихід з lifestyle режиму (deprecated)") - print(" • Розумне оновлення профілю без розростання даних") - print(" • Повна зворотна сумісність з існуючим кодом") \ No newline at end of file diff --git a/tests/test_next_checkin_integration.py b/tests/test_next_checkin_integration.py deleted file mode 100644 index 5840855a48719e2bbbf82035fc178db5b70c77f9..0000000000000000000000000000000000000000 --- a/tests/test_next_checkin_integration.py +++ /dev/null @@ -1,164 +0,0 @@ -#!/usr/bin/env python3 -""" -Integration test for next_check_in functionality in LifestyleSessionManager -""" - -import json -from datetime import datetime, timedelta -from src.core.core_classes import LifestyleProfile, ChatMessage, LifestyleSessionManager - -class MockAPI: - def generate_response(self, system_prompt: str, user_prompt: str, temperature: float = 0.3, call_type: str = "") -> str: - """Mock API that returns realistic profile update responses""" - - if call_type == "LIFESTYLE_PROFILE_UPDATE": - # Return a realistic profile update with next_check_in - return json.dumps({ - "updates_needed": True, - "reasoning": "Patient completed first lifestyle session with good engagement", - "updated_fields": { - "exercise_preferences": ["upper body exercises", "seated exercises", "resistance band training"], - "personal_preferences": ["prefers gradual changes", "wants weekly check-ins initially"], - "session_summary": "First lifestyle session completed. Patient motivated to start adapted exercise program.", - "next_check_in": "2025-09-08", - "progress_metrics": {"initial_motivation": "high", "session_1_completion": "successful"} - }, - "session_insights": "Patient shows high motivation despite physical limitations. Requires close monitoring initially.", - "next_session_rationale": "New patient needs immediate follow-up in 3 days to ensure safe program initiation and address any concerns." - }) - - return "Mock response" - -def test_next_checkin_integration(): - """Test the complete next_check_in workflow""" - - print("🧪 Testing Next Check-in Integration\n") - - # Create mock components - api = MockAPI() - session_manager = LifestyleSessionManager(api) - - # Create test lifestyle profile - profile = LifestyleProfile( - patient_name="Test Patient", - patient_age="52", - conditions=["Type 2 diabetes", "Hypertension"], - primary_goal="Improve exercise tolerance", - exercise_preferences=["upper body exercises"], - exercise_limitations=["Right below knee amputation"], - dietary_notes=["Diabetic diet"], - personal_preferences=["prefers gradual changes"], - journey_summary="Initial assessment completed", - last_session_summary="", - next_check_in="not set", - progress_metrics={} - ) - - # Create mock session messages - session_messages = [ - ChatMessage( - timestamp="2025-09-05T10:00:00Z", - role="user", - message="I want to start exercising but I'm worried about my amputation", - mode="lifestyle" - ), - ChatMessage( - timestamp="2025-09-05T10:01:00Z", - role="assistant", - message="I understand your concerns. Let's start with safe, adapted exercises.", - mode="lifestyle" - ), - ChatMessage( - timestamp="2025-09-05T10:02:00Z", - role="user", - message="What exercises would be good for me to start with?", - mode="lifestyle" - ) - ] - - print("📋 **Before Update:**") - print(f" Next check-in: {profile.next_check_in}") - print(f" Exercise preferences: {profile.exercise_preferences}") - print(f" Progress metrics: {profile.progress_metrics}") - print() - - # Test the profile update with next_check_in - try: - updated_profile = session_manager.update_profile_after_session( - profile, - session_messages, - "First lifestyle coaching session", - save_to_disk=False - ) - - print("📋 **After Update:**") - print(f" ✅ Next check-in: {updated_profile.next_check_in}") - print(f" ✅ Exercise preferences: {updated_profile.exercise_preferences}") - print(f" ✅ Personal preferences: {updated_profile.personal_preferences}") - print(f" ✅ Progress metrics: {updated_profile.progress_metrics}") - print(f" ✅ Last session summary: {updated_profile.last_session_summary}") - print() - - # Validate the next_check_in was set - if updated_profile.next_check_in != "not set": - print("✅ Next check-in successfully updated!") - - # Try to parse the date to validate format - try: - check_in_date = datetime.strptime(updated_profile.next_check_in, "%Y-%m-%d") - today = datetime.now() - days_until = (check_in_date - today).days - print(f"📅 Next session in {days_until} days ({updated_profile.next_check_in})") - except ValueError: - print(f"⚠️ Next check-in format may be descriptive: {updated_profile.next_check_in}") - else: - print("❌ Next check-in was not updated") - - except Exception as e: - print(f"❌ Error during profile update: {e}") - -def test_different_checkin_scenarios(): - """Test different scenarios for next check-in timing""" - - print("\n🎯 Testing Different Check-in Scenarios\n") - - scenarios = [ - { - "name": "New Patient", - "expected_days": 1-3, - "description": "First session, needs immediate follow-up" - }, - { - "name": "Active Coaching", - "expected_days": 7, - "description": "Regular coaching phase, weekly check-ins" - }, - { - "name": "Stable Progress", - "expected_days": 14-21, - "description": "Good progress, bi-weekly follow-up" - }, - { - "name": "Maintenance Phase", - "expected_days": 30, - "description": "Established routine, monthly check-ins" - } - ] - - for scenario in scenarios: - print(f"📋 **{scenario['name']}**") - print(f" Expected timing: {scenario['expected_days']} days") - print(f" Description: {scenario['description']}") - print() - -if __name__ == "__main__": - test_next_checkin_integration() - test_different_checkin_scenarios() - - print("📋 **Summary:**") - print(" • Next check-in field successfully integrated into profile updates") - print(" • LLM determines optimal timing based on patient status") - print(" • Date format: YYYY-MM-DD for easy parsing") - print(" • Rationale provided for timing decisions") - print(" • Supports different follow-up intervals based on patient needs") - print("\n✅ Next check-in functionality fully integrated!") \ No newline at end of file diff --git a/tests/test_patients.py b/tests/test_patients.py deleted file mode 100644 index a0147bb8536b9d433251e60cf9322d8014fb7536..0000000000000000000000000000000000000000 --- a/tests/test_patients.py +++ /dev/null @@ -1,198 +0,0 @@ -# test_patients.py - Test patient data for Testing Lab - -from typing import Dict, Any, Tuple - -class TestPatientData: - """Class for managing test patient data""" - - @staticmethod - def get_patient_types() -> Dict[str, str]: - """Returns available test patient types with descriptions""" - return { - "elderly": "👵 Elderly Mary (76 years old, complex comorbidity)", - "athlete": "🏃 Athletic John (24 роки, відновлення після травми)", - "pregnant": "🤰 Pregnant Sarah (28 років, вагітність з ускладненнями)" - } - - @staticmethod - def get_elderly_patient() -> Tuple[Dict[str, Any], Dict[str, Any]]: - """Повертає дані для літнього пацієнта з множинними захворюваннями""" - clinical_data = { - "patient_summary": { - "active_problems": [ - "Essential hypertension (uncontrolled)", - "Type 2 diabetes mellitus with complications", - "Chronic kidney disease stage 3", - "Falls risk - history of 3 falls last year" - ], - "current_medications": [ - "Amlodipine 10mg daily", - "Metformin 1000mg twice daily", - "Lisinopril 20mg daily", - "Furosemide 40mg daily" - ], - "allergies": "Penicillin - rash, NSAIDs - GI upset" - }, - "vital_signs_and_measurements": [ - "Blood Pressure: 165/95 (last visit)", - "Weight: 78kg", - "BMI: 31.2 kg/m²" - ], - "critical_alerts": [ - "High fall risk - requires mobility assessment", - "Uncontrolled hypertension and diabetes" - ], - "assessment_and_plan": "76-year-old female with multiple cardiovascular risk factors and functional limitations." - } - - lifestyle_data = { - "patient_name": "Mary", - "patient_age": "76", - "conditions": ["essential hypertension", "type 2 diabetes", "high fall risk"], - "primary_goal": "Improve mobility and independence while managing chronic conditions safely", - "exercise_preferences": ["chair exercises", "gentle walking"], - "exercise_limitations": [ - "High fall risk - balance issues", - "Limited endurance due to heart condition", - "Requires walking frame for mobility" - ], - "dietary_notes": [ - "Diabetic diet - needs simple carb counting", - "Low sodium for hypertension" - ], - "personal_preferences": [ - "very cautious due to fall anxiety", - "needs frequent encouragement" - ], - "journey_summary": "Elderly patient with complex medical needs seeking to maintain independence.", - "last_session_summary": "", - "progress_metrics": { - "exercise_frequency": "0 times/week - afraid to move", - "fall_incidents": "3 in past 12 months" - } - } - - return clinical_data, lifestyle_data - - @staticmethod - def get_athlete_patient() -> Tuple[Dict[str, Any], Dict[str, Any]]: - """Повертає дані для спортсмена після травми""" - clinical_data = { - "patient_summary": { - "active_problems": [ - "ACL reconstruction recovery (3 months post-op)", - "Post-surgical knee pain and swelling", - "Anxiety related to return to sport" - ], - "current_medications": [ - "Ibuprofen 400mg as needed for pain", - "Physiotherapy exercises daily" - ], - "allergies": "No known drug allergies" - }, - "vital_signs_and_measurements": [ - "Blood Pressure: 118/72", - "Weight: 82kg (lost 3kg since surgery)", - "BMI: 24.0 kg/m²" - ], - "critical_alerts": [ - "Do not exceed physiotherapy exercise guidelines", - "No pivoting or cutting movements until cleared" - ], - "assessment_and_plan": "24-year-old male athlete 3 months post ACL reconstruction." - } - - lifestyle_data = { - "patient_name": "John", - "patient_age": "24", - "conditions": ["ACL reconstruction recovery", "sports performance anxiety"], - "primary_goal": "Return to competitive football safely and regain pre-injury fitness", - "exercise_preferences": ["weight training", "swimming", "cycling"], - "exercise_limitations": [ - "No pivoting or cutting movements yet", - "Must follow physiotherapy protocol strictly" - ], - "dietary_notes": [ - "High protein intake for muscle recovery", - "Anti-inflammatory foods" - ], - "personal_preferences": [ - "highly motivated and goal-oriented", - "impatient with slow recovery process" - ], - "journey_summary": "Motivated athlete recovering from major knee surgery.", - "last_session_summary": "", - "progress_metrics": { - "knee_flexion_range": "120 degrees (target: 135+)", - "return_to_sport_timeline": "3-4 months if progress continues" - } - } - - return clinical_data, lifestyle_data - - @staticmethod - def get_pregnant_patient() -> Tuple[Dict[str, Any], Dict[str, Any]]: - """Повертає дані для вагітної пацієнтки з ускладненнями""" - clinical_data = { - "patient_summary": { - "active_problems": [ - "Pregnancy 28 weeks gestation", - "Gestational diabetes mellitus (diet-controlled)", - "Pregnancy-induced hypertension (mild)" - ], - "current_medications": [ - "Prenatal vitamins with iron", - "Additional iron supplement 65mg daily" - ], - "allergies": "No known drug allergies" - }, - "vital_signs_and_measurements": [ - "Blood Pressure: 142/88 (elevated for pregnancy)", - "Current weight: 78kg", - "Weight gain: 10kg (appropriate)" - ], - "critical_alerts": [ - "Monitor blood pressure - risk of preeclampsia", - "Avoid exercises lying flat on back after 20 weeks" - ], - "assessment_and_plan": "28-year-old female, 28 weeks pregnant with gestational diabetes." - } - - lifestyle_data = { - "patient_name": "Sarah", - "patient_age": "28", - "conditions": ["pregnancy 28 weeks", "gestational diabetes"], - "primary_goal": "Maintain healthy pregnancy with good blood sugar control", - "exercise_preferences": ["prenatal yoga", "walking", "swimming"], - "exercise_limitations": [ - "No lying flat on back after 20 weeks", - "Monitor heart rate - shouldn't exceed 140 bpm" - ], - "dietary_notes": [ - "Gestational diabetes diet - controlled carbohydrates", - "Small frequent meals to manage blood sugar" - ], - "personal_preferences": [ - "motivated to have healthy pregnancy", - "anxious about blood sugar control" - ], - "journey_summary": "Second pregnancy with gestational diabetes.", - "last_session_summary": "", - "progress_metrics": { - "blood_glucose_control": "diet-controlled, monitoring 4x daily" - } - } - - return clinical_data, lifestyle_data - - @classmethod - def get_patient_data(cls, patient_type: str) -> Tuple[Dict[str, Any], Dict[str, Any]]: - """Універсальний метод для отримання даних пацієнта за типом""" - if patient_type == "elderly": - return cls.get_elderly_patient() - elif patient_type == "athlete": - return cls.get_athlete_patient() - elif patient_type == "pregnant": - return cls.get_pregnant_patient() - else: - raise ValueError(f"Невідомий тип пацієнта: {patient_type}") \ No newline at end of file diff --git a/tests/test_profile_updater.py b/tests/test_profile_updater.py deleted file mode 100644 index 6fdb5cfd7b749bcce470f3a79a76928338353a69..0000000000000000000000000000000000000000 --- a/tests/test_profile_updater.py +++ /dev/null @@ -1,195 +0,0 @@ -#!/usr/bin/env python3 -""" -Test script for the updated Lifestyle Profile Updater with next_check_in functionality -""" - -import json -from datetime import datetime, timedelta -from dataclasses import dataclass -from typing import List, Dict - -@dataclass -class MockLifestyleProfile: - patient_name: str = "Serhii" - patient_age: str = "52" - conditions: List[str] = None - primary_goal: str = "Improve exercise tolerance safely" - exercise_preferences: List[str] = None - exercise_limitations: List[str] = None - dietary_notes: List[str] = None - personal_preferences: List[str] = None - last_session_summary: str = "" - progress_metrics: Dict = None - - def __post_init__(self): - if self.conditions is None: - self.conditions = ["Type 2 diabetes", "Hypertension"] - if self.exercise_preferences is None: - self.exercise_preferences = ["upper body exercises", "seated exercises"] - if self.exercise_limitations is None: - self.exercise_limitations = ["Right below knee amputation"] - if self.dietary_notes is None: - self.dietary_notes = ["Diabetic diet", "Low sodium"] - if self.personal_preferences is None: - self.personal_preferences = ["prefers gradual changes"] - if self.progress_metrics is None: - self.progress_metrics = {"baseline_bp": "148/98"} - -class MockAPI: - def generate_response(self, system_prompt: str, user_prompt: str, temperature: float = 0.3, call_type: str = "") -> str: - """Mock response for profile updater""" - - # Simulate different scenarios based on session content - if "new patient" in user_prompt.lower() or "first session" in user_prompt.lower(): - # New patient scenario - needs immediate follow-up - return json.dumps({ - "updates_needed": True, - "reasoning": "First lifestyle session completed. Patient shows motivation but needs close monitoring due to complex medical conditions.", - "updated_fields": { - "exercise_preferences": ["upper body exercises", "seated exercises", "adaptive equipment training"], - "exercise_limitations": ["Right below knee amputation", "Monitor blood glucose before/after exercise"], - "dietary_notes": ["Diabetic diet", "Low sodium", "Discussed meal timing with exercise"], - "personal_preferences": ["prefers gradual changes", "wants medical supervision initially"], - "primary_goal": "Improve exercise tolerance safely with medical supervision", - "progress_metrics": {"baseline_bp": "148/98", "initial_motivation_level": "high"}, - "session_summary": "Initial lifestyle assessment completed. Patient motivated to start adapted exercise program.", - "next_check_in": "2025-09-08" - }, - "session_insights": "Patient demonstrates high motivation despite physical limitations. Requires careful medical supervision.", - "next_session_rationale": "New patient with complex conditions needs immediate follow-up in 3 days to ensure safe program initiation." - }) - - elif "progress" in user_prompt.lower() or "week" in user_prompt.lower(): - # Ongoing coaching scenario - regular follow-up - return json.dumps({ - "updates_needed": True, - "reasoning": "Patient showing good progress with exercise program. Ready for program advancement.", - "updated_fields": { - "exercise_preferences": ["upper body exercises", "seated exercises", "resistance band training"], - "progress_metrics": {"baseline_bp": "148/98", "week_2_bp": "142/92", "exercise_frequency": "3 times/week"}, - "session_summary": "Good progress with exercise program. Patient comfortable with current routine.", - "next_check_in": "2025-09-19" - }, - "session_insights": "Patient adapting well to exercise routine. Blood pressure showing improvement.", - "next_session_rationale": "Stable progress allows for 2-week follow-up to monitor continued improvement." - }) - - elif "maintenance" in user_prompt.lower() or "stable" in user_prompt.lower(): - # Maintenance phase scenario - long-term follow-up - return json.dumps({ - "updates_needed": False, - "reasoning": "Patient in maintenance phase with stable progress and established routine.", - "updated_fields": { - "session_summary": "Maintenance check-in. Patient continuing established routine successfully.", - "next_check_in": "2025-10-05" - }, - "session_insights": "Patient has established sustainable lifestyle habits. Minimal intervention needed.", - "next_session_rationale": "Maintenance phase patient can be followed up monthly to ensure continued adherence." - }) - - else: - # Default scenario - return json.dumps({ - "updates_needed": True, - "reasoning": "Standard lifestyle coaching session completed.", - "updated_fields": { - "session_summary": "Regular lifestyle coaching session completed.", - "next_check_in": "2025-09-12" - }, - "session_insights": "Patient engaged in lifestyle coaching process.", - "next_session_rationale": "Regular follow-up in 1 week for active coaching phase." - }) - -def test_profile_updater_scenarios(): - """Test different scenarios for next_check_in planning""" - - print("🧪 Testing Lifestyle Profile Updater with Next Check-in Planning\n") - - api = MockAPI() - profile = MockLifestyleProfile() - - # Test scenarios - scenarios = [ - { - "name": "New Patient - First Session", - "session_context": "First lifestyle coaching session with new patient", - "messages": [ - {"role": "user", "message": "I'm ready to start exercising but worried about my amputation"}, - {"role": "user", "message": "What exercises can I do safely?"} - ] - }, - { - "name": "Active Coaching - Progress Check", - "session_context": "Week 2 progress check - patient showing improvement", - "messages": [ - {"role": "user", "message": "I've been doing the exercises 3 times this week"}, - {"role": "user", "message": "My blood pressure seems better"} - ] - }, - { - "name": "Maintenance Phase - Stable Patient", - "session_context": "Monthly maintenance check for stable patient", - "messages": [ - {"role": "user", "message": "Everything is going well with my routine"}, - {"role": "user", "message": "I'm maintaining my exercise schedule"} - ] - } - ] - - for scenario in scenarios: - print(f"📋 **{scenario['name']}**") - print(f" Context: {scenario['session_context']}") - - # Simulate the prompt (simplified) - user_prompt = f""" - SESSION CONTEXT: {scenario['session_context']} - PATIENT MESSAGES: {[msg['message'] for msg in scenario['messages']]} - """ - - try: - response = api.generate_response("", user_prompt) - result = json.loads(response) - - print(f" ✅ Updates needed: {result.get('updates_needed')}") - print(f" 📅 Next check-in: {result.get('updated_fields', {}).get('next_check_in', 'Not set')}") - print(f" 💭 Rationale: {result.get('next_session_rationale', 'Not provided')}") - print(f" 📝 Session summary: {result.get('updated_fields', {}).get('session_summary', 'Not provided')}") - print() - - except Exception as e: - print(f" ❌ Error: {e}") - print() - -def test_next_checkin_date_formats(): - """Test different date format scenarios""" - - print("📅 Testing Next Check-in Date Formats\n") - - # Test different date scenarios - today = datetime.now() - - date_scenarios = [ - ("Immediate follow-up", today + timedelta(days=2)), - ("Short-term follow-up", today + timedelta(weeks=1)), - ("Regular follow-up", today + timedelta(weeks=2)), - ("Long-term follow-up", today + timedelta(weeks=4)) - ] - - for scenario_name, target_date in date_scenarios: - formatted_date = target_date.strftime("%Y-%m-%d") - print(f" {scenario_name}: {formatted_date}") - - print("\n✅ Date format examples generated successfully") - -if __name__ == "__main__": - test_profile_updater_scenarios() - test_next_checkin_date_formats() - - print("\n📋 **Summary of Next Check-in Feature:**") - print(" • New patients: 1-3 days follow-up") - print(" • Active coaching: 1 week follow-up") - print(" • Stable progress: 2-3 weeks follow-up") - print(" • Maintenance phase: 1 month+ follow-up") - print(" • Date format: YYYY-MM-DD") - print(" • Includes rationale for timing decision") - print("\n✅ Profile updater enhanced with next session planning!") \ No newline at end of file diff --git a/tests/test_reevaluation.py b/tests/test_reevaluation.py deleted file mode 100644 index 055e92c9e0297bf7f9b6b8bfe8482b1d2db48a11..0000000000000000000000000000000000000000 --- a/tests/test_reevaluation.py +++ /dev/null @@ -1,264 +0,0 @@ -""" -Test re-evaluation logic for spiritual distress analyzer. - -Tests the re_evaluate_with_followup() method to ensure: -1. It combines original input with follow-up answers -2. It returns either red flag or no flag (never yellow) -3. It handles edge cases appropriately -""" - -import os -import sys -from datetime import datetime - -# Add src to path -sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src')) - -from src.core.ai_client import AIClientManager -from src.core.spiritual_analyzer import SpiritualDistressAnalyzer -from src.core.spiritual_classes import PatientInput, DistressClassification - - -def test_reevaluation_escalates_to_red(): - """Test that re-evaluation escalates to red flag when distress is confirmed.""" - print("\n=== Test: Re-evaluation escalates to red flag ===") - - # Initialize analyzer - api = AIClientManager() - analyzer = SpiritualDistressAnalyzer(api) - - # Create original input (yellow flag case) - original_input = PatientInput( - message="I've been feeling frustrated lately", - timestamp=datetime.now().isoformat() - ) - - # Create original classification (yellow flag) - original_classification = DistressClassification( - flag_level="yellow", - indicators=["frustration", "emotional_concern"], - categories=["anger"], - confidence=0.6, - reasoning="Patient mentions frustration but severity is unclear" - ) - - # Follow-up questions and answers that confirm severe distress - followup_questions = [ - "Can you tell me more about these feelings of frustration?", - "How has this been affecting your daily life?" - ] - - followup_answers = [ - "I'm angry all the time now. I can't control it anymore.", - "It's affecting everything. I can't sleep, I can't focus, I just feel rage constantly." - ] - - # Re-evaluate - result = analyzer.re_evaluate_with_followup( - original_input=original_input, - original_classification=original_classification, - followup_questions=followup_questions, - followup_answers=followup_answers - ) - - print(f"Flag Level: {result.flag_level}") - print(f"Indicators: {result.indicators}") - print(f"Confidence: {result.confidence}") - print(f"Reasoning: {result.reasoning[:200]}...") - - # Verify result - assert result.flag_level in ["red", "none"], f"Expected red or none, got {result.flag_level}" - print(f"✓ Re-evaluation returned valid flag level: {result.flag_level}") - - # For this case, we expect red flag - if result.flag_level == "red": - print("✓ Correctly escalated to red flag based on follow-up") - else: - print("⚠ Warning: Expected red flag but got none (may need prompt tuning)") - - return result - - -def test_reevaluation_clears_to_none(): - """Test that re-evaluation clears to no flag when distress is not confirmed.""" - print("\n=== Test: Re-evaluation clears to no flag ===") - - # Initialize analyzer - api = AIClientManager() - analyzer = SpiritualDistressAnalyzer(api) - - # Create original input (yellow flag case) - original_input = PatientInput( - message="I've been feeling a bit down", - timestamp=datetime.now().isoformat() - ) - - # Create original classification (yellow flag) - original_classification = DistressClassification( - flag_level="yellow", - indicators=["sadness", "mood_change"], - categories=["persistent_sadness"], - confidence=0.5, - reasoning="Patient mentions feeling down but severity is unclear" - ) - - # Follow-up questions and answers that clarify no severe distress - followup_questions = [ - "Can you tell me more about feeling down?", - "How long have you been feeling this way?" - ] - - followup_answers = [ - "Oh, it's just been a rough week with work stress. Nothing major.", - "Just the past few days. I'm sure it will pass once this project is done." - ] - - # Re-evaluate - result = analyzer.re_evaluate_with_followup( - original_input=original_input, - original_classification=original_classification, - followup_questions=followup_questions, - followup_answers=followup_answers - ) - - print(f"Flag Level: {result.flag_level}") - print(f"Indicators: {result.indicators}") - print(f"Confidence: {result.confidence}") - print(f"Reasoning: {result.reasoning[:200]}...") - - # Verify result - assert result.flag_level in ["red", "none"], f"Expected red or none, got {result.flag_level}" - print(f"✓ Re-evaluation returned valid flag level: {result.flag_level}") - - # For this case, we expect no flag - if result.flag_level == "none": - print("✓ Correctly cleared to no flag based on follow-up") - else: - print("⚠ Warning: Expected no flag but got red (may need prompt tuning)") - - return result - - -def test_reevaluation_handles_mismatched_qa(): - """Test that re-evaluation handles mismatched questions and answers gracefully.""" - print("\n=== Test: Re-evaluation handles mismatched Q&A ===") - - # Initialize analyzer - api = AIClientManager() - analyzer = SpiritualDistressAnalyzer(api) - - # Create original input - original_input = PatientInput( - message="I'm feeling overwhelmed", - timestamp=datetime.now().isoformat() - ) - - # Create original classification - original_classification = DistressClassification( - flag_level="yellow", - indicators=["overwhelmed"], - categories=["emotional_distress"], - confidence=0.5, - reasoning="Patient mentions feeling overwhelmed" - ) - - # Mismatched questions and answers (different lengths) - followup_questions = [ - "Can you tell me more?", - "How long has this been going on?", - "What would help?" - ] - - followup_answers = [ - "It's been really hard lately." - ] - - # Re-evaluate (should handle gracefully) - result = analyzer.re_evaluate_with_followup( - original_input=original_input, - original_classification=original_classification, - followup_questions=followup_questions, - followup_answers=followup_answers - ) - - print(f"Flag Level: {result.flag_level}") - print(f"Indicators: {result.indicators}") - print(f"Reasoning: {result.reasoning[:200]}...") - - # Verify result - assert result.flag_level in ["red", "none"], f"Expected red or none, got {result.flag_level}" - print(f"✓ Re-evaluation handled mismatched Q&A and returned: {result.flag_level}") - - return result - - -def test_reevaluation_never_returns_yellow(): - """Test that re-evaluation never returns yellow flag.""" - print("\n=== Test: Re-evaluation never returns yellow ===") - - # Initialize analyzer - api = AIClientManager() - analyzer = SpiritualDistressAnalyzer(api) - - # Create original input - original_input = PatientInput( - message="I'm not sure how I feel", - timestamp=datetime.now().isoformat() - ) - - # Create original classification - original_classification = DistressClassification( - flag_level="yellow", - indicators=["uncertainty"], - categories=[], - confidence=0.4, - reasoning="Patient expresses uncertainty" - ) - - # Ambiguous follow-up answers - followup_questions = [ - "Can you describe what you're experiencing?" - ] - - followup_answers = [ - "I don't know, just feeling off I guess." - ] - - # Re-evaluate - result = analyzer.re_evaluate_with_followup( - original_input=original_input, - original_classification=original_classification, - followup_questions=followup_questions, - followup_answers=followup_answers - ) - - print(f"Flag Level: {result.flag_level}") - print(f"Reasoning: {result.reasoning[:200]}...") - - # Verify result is NOT yellow - assert result.flag_level != "yellow", "Re-evaluation should never return yellow flag" - assert result.flag_level in ["red", "none"], f"Expected red or none, got {result.flag_level}" - print(f"✓ Re-evaluation correctly avoided yellow flag, returned: {result.flag_level}") - - return result - - -if __name__ == "__main__": - print("Testing re-evaluation logic for spiritual distress analyzer") - print("=" * 70) - - try: - # Run tests - test_reevaluation_escalates_to_red() - test_reevaluation_clears_to_none() - test_reevaluation_handles_mismatched_qa() - test_reevaluation_never_returns_yellow() - - print("\n" + "=" * 70) - print("✓ All re-evaluation tests passed!") - - except Exception as e: - print(f"\n✗ Test failed with error: {e}") - import traceback - traceback.print_exc() - sys.exit(1) diff --git a/tests/test_reevaluation_integration.py b/tests/test_reevaluation_integration.py deleted file mode 100644 index 395c07ea47106877494f703070b0d7d1c1d65d25..0000000000000000000000000000000000000000 --- a/tests/test_reevaluation_integration.py +++ /dev/null @@ -1,301 +0,0 @@ -""" -Integration test for re-evaluation workflow. - -Demonstrates the complete workflow: -1. Initial analysis (yellow flag) -2. Generate clarifying questions -3. Re-evaluate with follow-up answers -4. Verify result is red or none (never yellow) -""" - -import os -import sys -from datetime import datetime -from unittest.mock import Mock - -# Add src to path -sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src')) - -from src.core.spiritual_analyzer import SpiritualDistressAnalyzer, ClarifyingQuestionGenerator -from src.core.spiritual_classes import PatientInput, DistressClassification - - -def test_complete_reevaluation_workflow(): - """Test the complete workflow from yellow flag to re-evaluation.""" - print("\n=== Integration Test: Complete Re-evaluation Workflow ===") - - # Create mock API with responses for each step - mock_api = Mock() - - # Step 1: Initial analysis returns yellow flag - mock_api.generate_response.return_value = ''' - { - "flag_level": "yellow", - "indicators": ["frustration", "emotional_concern"], - "categories": ["anger"], - "confidence": 0.6, - "reasoning": "Patient mentions frustration but severity is unclear. Need more information." - } - ''' - - # Create analyzer - analyzer = SpiritualDistressAnalyzer(mock_api) - question_generator = ClarifyingQuestionGenerator(mock_api) - - # Step 1: Initial analysis - print("\nStep 1: Initial Analysis") - print("-" * 50) - - patient_input = PatientInput( - message="I've been feeling frustrated lately", - timestamp=datetime.now().isoformat() - ) - - initial_classification = analyzer.analyze_message(patient_input) - - print(f"Patient Message: {patient_input.message}") - print(f"Initial Classification: {initial_classification.flag_level}") - print(f"Indicators: {initial_classification.indicators}") - print(f"Reasoning: {initial_classification.reasoning[:100]}...") - - # Verify initial classification is yellow - assert initial_classification.flag_level == "yellow", "Expected yellow flag initially" - print("✓ Initial classification is yellow flag") - - # Step 2: Generate clarifying questions - print("\nStep 2: Generate Clarifying Questions") - print("-" * 50) - - # Mock response for question generation - mock_api.generate_response.return_value = ''' - { - "questions": [ - "Can you tell me more about these feelings of frustration?", - "How has this been affecting your daily life?" - ] - } - ''' - - questions = question_generator.generate_questions( - initial_classification, - patient_input - ) - - print(f"Generated {len(questions)} questions:") - for i, q in enumerate(questions, 1): - print(f" {i}. {q}") - - assert len(questions) > 0, "Should generate at least one question" - print("✓ Clarifying questions generated") - - # Step 3: Simulate patient answers - print("\nStep 3: Patient Provides Follow-up Answers") - print("-" * 50) - - followup_answers = [ - "I'm angry all the time now. I can't control it anymore.", - "It's affecting everything. I can't sleep, I can't focus, I just feel rage constantly." - ] - - print("Patient answers:") - for i, a in enumerate(followup_answers, 1): - print(f" {i}. {a}") - - # Step 4: Re-evaluate with follow-up - print("\nStep 4: Re-evaluation with Follow-up") - print("-" * 50) - - # Mock response for re-evaluation (escalates to red) - mock_api.generate_response.return_value = ''' - { - "flag_level": "red", - "indicators": ["persistent_anger", "uncontrollable_emotions", "sleep_disruption", "concentration_issues"], - "categories": ["anger", "emotional_distress"], - "confidence": 0.9, - "reasoning": "Follow-up confirms severe distress. Patient reports persistent, uncontrollable anger affecting sleep and daily functioning. Clear indicators for immediate spiritual care referral." - } - ''' - - final_classification = analyzer.re_evaluate_with_followup( - original_input=patient_input, - original_classification=initial_classification, - followup_questions=questions, - followup_answers=followup_answers - ) - - print(f"Final Classification: {final_classification.flag_level}") - print(f"Indicators: {final_classification.indicators}") - print(f"Confidence: {final_classification.confidence}") - print(f"Reasoning: {final_classification.reasoning[:150]}...") - - # Verify final classification - assert final_classification.flag_level in ["red", "none"], "Re-evaluation must be red or none" - assert final_classification.flag_level != "yellow", "Re-evaluation cannot be yellow" - print(f"✓ Re-evaluation returned definitive classification: {final_classification.flag_level}") - - # Step 5: Verify workflow integrity - print("\nStep 5: Workflow Verification") - print("-" * 50) - - print(f"Initial: {initial_classification.flag_level} -> Final: {final_classification.flag_level}") - print(f"Indicators increased: {len(initial_classification.indicators)} -> {len(final_classification.indicators)}") - print(f"Confidence increased: {initial_classification.confidence:.2f} -> {final_classification.confidence:.2f}") - - # Verify the workflow made progress - assert final_classification.flag_level != initial_classification.flag_level, "Classification should change" - print("✓ Workflow successfully resolved ambiguity") - - return final_classification - - -def test_reevaluation_workflow_clears_to_none(): - """Test workflow where re-evaluation clears to no flag.""" - print("\n=== Integration Test: Re-evaluation Clears to None ===") - - # Create mock API - mock_api = Mock() - - # Initial yellow flag - mock_api.generate_response.return_value = ''' - { - "flag_level": "yellow", - "indicators": ["mild_sadness"], - "categories": ["persistent_sadness"], - "confidence": 0.5, - "reasoning": "Patient mentions feeling down but context is unclear" - } - ''' - - analyzer = SpiritualDistressAnalyzer(mock_api) - - # Initial analysis - patient_input = PatientInput( - message="I've been feeling a bit down", - timestamp=datetime.now().isoformat() - ) - - initial_classification = analyzer.analyze_message(patient_input) - print(f"Initial: {initial_classification.flag_level}") - - # Re-evaluation clears to none - mock_api.generate_response.return_value = ''' - { - "flag_level": "none", - "indicators": [], - "categories": [], - "confidence": 0.8, - "reasoning": "Follow-up clarifies this is temporary work stress, not spiritual distress. Patient is coping well." - } - ''' - - followup_questions = ["Can you tell me more about feeling down?"] - followup_answers = ["Oh, it's just work stress. I'm handling it fine, just a busy week."] - - final_classification = analyzer.re_evaluate_with_followup( - original_input=patient_input, - original_classification=initial_classification, - followup_questions=followup_questions, - followup_answers=followup_answers - ) - - print(f"Final: {final_classification.flag_level}") - print(f"Reasoning: {final_classification.reasoning[:100]}...") - - # Verify cleared to none - assert final_classification.flag_level == "none", "Should clear to no flag" - assert len(final_classification.indicators) == 0, "Should have no indicators" - print("✓ Re-evaluation correctly cleared to no flag") - - return final_classification - - -def test_reevaluation_enforces_no_yellow(): - """Test that re-evaluation enforces no yellow flags even if LLM returns one.""" - print("\n=== Integration Test: Re-evaluation Enforces No Yellow ===") - - # Create mock API that incorrectly returns yellow - mock_api = Mock() - - # Initial yellow flag - mock_api.generate_response.return_value = ''' - { - "flag_level": "yellow", - "indicators": ["uncertainty"], - "categories": [], - "confidence": 0.4, - "reasoning": "Patient expresses uncertainty" - } - ''' - - analyzer = SpiritualDistressAnalyzer(mock_api) - - patient_input = PatientInput( - message="I'm not sure how I feel", - timestamp=datetime.now().isoformat() - ) - - initial_classification = analyzer.analyze_message(patient_input) - print(f"Initial: {initial_classification.flag_level}") - - # LLM incorrectly returns yellow in re-evaluation - mock_api.generate_response.return_value = ''' - { - "flag_level": "yellow", - "indicators": ["still_uncertain"], - "categories": [], - "confidence": 0.5, - "reasoning": "Still unclear after follow-up" - } - ''' - - followup_questions = ["Can you describe what you're experiencing?"] - followup_answers = ["I don't know, just feeling off I guess."] - - final_classification = analyzer.re_evaluate_with_followup( - original_input=patient_input, - original_classification=initial_classification, - followup_questions=followup_questions, - followup_answers=followup_answers - ) - - print(f"LLM returned: yellow (invalid)") - print(f"Enforced to: {final_classification.flag_level}") - print(f"Reasoning: {final_classification.reasoning[:150]}...") - - # Verify yellow was converted to red - assert final_classification.flag_level != "yellow", "Yellow should be converted" - assert final_classification.flag_level == "red", "Should escalate to red for safety" - assert "Auto-escalated" in final_classification.reasoning - print("✓ Re-evaluation correctly enforced no yellow flag") - - return final_classification - - -if __name__ == "__main__": - print("Integration Testing: Re-evaluation Workflow") - print("=" * 70) - - try: - # Run integration tests - test_complete_reevaluation_workflow() - test_reevaluation_workflow_clears_to_none() - test_reevaluation_enforces_no_yellow() - - print("\n" + "=" * 70) - print("✓ All integration tests passed!") - print("\nSummary:") - print("- Re-evaluation successfully combines original input with follow-up") - print("- Re-evaluation enforces red or none (never yellow)") - print("- Workflow handles both escalation and clearing scenarios") - print("- Error handling ensures conservative (safe) defaults") - - except AssertionError as e: - print(f"\n✗ Test failed: {e}") - import traceback - traceback.print_exc() - sys.exit(1) - except Exception as e: - print(f"\n✗ Test failed with error: {e}") - import traceback - traceback.print_exc() - sys.exit(1) diff --git a/tests/test_reevaluation_unit.py b/tests/test_reevaluation_unit.py deleted file mode 100644 index 140ac8483489f0ad38a3ee95c023fdf977055c8e..0000000000000000000000000000000000000000 --- a/tests/test_reevaluation_unit.py +++ /dev/null @@ -1,335 +0,0 @@ -""" -Unit tests for re-evaluation logic without requiring AI provider. - -Tests the re_evaluate_with_followup() method logic including: -1. Enforcement of red/none only (no yellow) -2. Handling of mismatched Q&A -3. Error handling -""" - -import os -import sys -from datetime import datetime -from unittest.mock import Mock, patch - -# Add src to path -sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src')) - -from src.core.spiritual_analyzer import SpiritualDistressAnalyzer -from src.core.spiritual_classes import PatientInput, DistressClassification - - -def test_enforce_reevaluation_rules_converts_yellow_to_red(): - """Test that _enforce_reevaluation_rules converts yellow to red.""" - print("\n=== Test: Enforce re-evaluation rules (yellow -> red) ===") - - # Create a mock API - mock_api = Mock() - - # Create analyzer - analyzer = SpiritualDistressAnalyzer(mock_api) - - # Create a classification with yellow flag (not allowed in re-evaluation) - classification = DistressClassification( - flag_level="yellow", - indicators=["test"], - categories=["test"], - confidence=0.5, - reasoning="Test reasoning" - ) - - # Enforce rules - result = analyzer._enforce_reevaluation_rules(classification) - - print(f"Original flag: yellow") - print(f"Enforced flag: {result.flag_level}") - print(f"Reasoning: {result.reasoning}") - - # Verify yellow was converted to red - assert result.flag_level == "red", f"Expected red, got {result.flag_level}" - assert "Auto-escalated to red flag" in result.reasoning - print("✓ Yellow flag correctly converted to red") - - return result - - -def test_enforce_reevaluation_rules_allows_red(): - """Test that _enforce_reevaluation_rules allows red flag.""" - print("\n=== Test: Enforce re-evaluation rules (red allowed) ===") - - # Create a mock API - mock_api = Mock() - - # Create analyzer - analyzer = SpiritualDistressAnalyzer(mock_api) - - # Create a classification with red flag - classification = DistressClassification( - flag_level="red", - indicators=["severe_distress"], - categories=["anger"], - confidence=0.9, - reasoning="Severe distress confirmed" - ) - - # Enforce rules - result = analyzer._enforce_reevaluation_rules(classification) - - print(f"Original flag: red") - print(f"Enforced flag: {result.flag_level}") - - # Verify red was preserved - assert result.flag_level == "red", f"Expected red, got {result.flag_level}" - assert "Auto-escalated" not in result.reasoning - print("✓ Red flag correctly preserved") - - return result - - -def test_enforce_reevaluation_rules_allows_none(): - """Test that _enforce_reevaluation_rules allows no flag.""" - print("\n=== Test: Enforce re-evaluation rules (none allowed) ===") - - # Create a mock API - mock_api = Mock() - - # Create analyzer - analyzer = SpiritualDistressAnalyzer(mock_api) - - # Create a classification with no flag - classification = DistressClassification( - flag_level="none", - indicators=[], - categories=[], - confidence=0.8, - reasoning="No distress detected" - ) - - # Enforce rules - result = analyzer._enforce_reevaluation_rules(classification) - - print(f"Original flag: none") - print(f"Enforced flag: {result.flag_level}") - - # Verify none was preserved - assert result.flag_level == "none", f"Expected none, got {result.flag_level}" - assert "Auto-escalated" not in result.reasoning - print("✓ No flag correctly preserved") - - return result - - -def test_enforce_reevaluation_rules_handles_invalid(): - """Test that _enforce_reevaluation_rules handles invalid flag levels.""" - print("\n=== Test: Enforce re-evaluation rules (invalid -> red) ===") - - # Create a mock API - mock_api = Mock() - - # Create analyzer - analyzer = SpiritualDistressAnalyzer(mock_api) - - # Create a classification with invalid flag - classification = DistressClassification( - flag_level="invalid", - indicators=["test"], - categories=["test"], - confidence=0.5, - reasoning="Test reasoning" - ) - - # Enforce rules - result = analyzer._enforce_reevaluation_rules(classification) - - print(f"Original flag: invalid") - print(f"Enforced flag: {result.flag_level}") - print(f"Reasoning: {result.reasoning}") - - # Verify invalid was converted to red - assert result.flag_level == "red", f"Expected red, got {result.flag_level}" - assert "invalid flag_level" in result.reasoning - print("✓ Invalid flag correctly converted to red") - - return result - - -def test_reevaluation_with_mock_response(): - """Test re-evaluation with mocked LLM response.""" - print("\n=== Test: Re-evaluation with mocked LLM response ===") - - # Create a mock API that returns a valid JSON response - mock_api = Mock() - mock_api.generate_response.return_value = ''' - { - "flag_level": "red", - "indicators": ["persistent_anger", "uncontrollable_emotions"], - "categories": ["anger", "emotional_distress"], - "confidence": 0.85, - "reasoning": "Follow-up confirms severe distress with persistent anger and loss of control" - } - ''' - - # Create analyzer with mocked API - analyzer = SpiritualDistressAnalyzer(mock_api) - - # Create test data - original_input = PatientInput( - message="I've been feeling frustrated", - timestamp=datetime.now().isoformat() - ) - - original_classification = DistressClassification( - flag_level="yellow", - indicators=["frustration"], - categories=["anger"], - confidence=0.6, - reasoning="Ambiguous frustration" - ) - - followup_questions = ["Can you tell me more?"] - followup_answers = ["I'm angry all the time now"] - - # Re-evaluate - result = analyzer.re_evaluate_with_followup( - original_input=original_input, - original_classification=original_classification, - followup_questions=followup_questions, - followup_answers=followup_answers - ) - - print(f"Flag Level: {result.flag_level}") - print(f"Indicators: {result.indicators}") - print(f"Confidence: {result.confidence}") - print(f"Reasoning: {result.reasoning[:100]}...") - - # Verify result - assert result.flag_level == "red" - assert "persistent_anger" in result.indicators - assert result.confidence == 0.85 - print("✓ Re-evaluation correctly processed mocked response") - - # Verify the API was called with correct parameters - assert mock_api.generate_response.called - call_args = mock_api.generate_response.call_args - assert call_args[1]['call_type'] == "SPIRITUAL_DISTRESS_REEVALUATION" - print("✓ API called with correct parameters") - - return result - - -def test_reevaluation_handles_qa_mismatch(): - """Test that re-evaluation handles mismatched Q&A lengths.""" - print("\n=== Test: Re-evaluation handles Q&A mismatch ===") - - # Create a mock API - mock_api = Mock() - mock_api.generate_response.return_value = ''' - { - "flag_level": "none", - "indicators": [], - "categories": [], - "confidence": 0.7, - "reasoning": "Follow-up clarifies no significant distress" - } - ''' - - # Create analyzer - analyzer = SpiritualDistressAnalyzer(mock_api) - - # Create test data with mismatched lengths - original_input = PatientInput( - message="I'm feeling down", - timestamp=datetime.now().isoformat() - ) - - original_classification = DistressClassification( - flag_level="yellow", - indicators=["sadness"], - categories=["persistent_sadness"], - confidence=0.5, - reasoning="Ambiguous sadness" - ) - - # More questions than answers - followup_questions = [ - "Can you tell me more?", - "How long has this been going on?", - "What would help?" - ] - followup_answers = [ - "Just work stress, nothing major" - ] - - # Re-evaluate (should handle gracefully) - result = analyzer.re_evaluate_with_followup( - original_input=original_input, - original_classification=original_classification, - followup_questions=followup_questions, - followup_answers=followup_answers - ) - - print(f"Questions: {len(followup_questions)}") - print(f"Answers: {len(followup_answers)}") - print(f"Flag Level: {result.flag_level}") - - # Verify it handled the mismatch and still returned valid result - assert result.flag_level in ["red", "none"] - print("✓ Re-evaluation handled Q&A mismatch gracefully") - - return result - - -def test_create_safe_reevaluation_classification(): - """Test that error handling creates safe red flag classification.""" - print("\n=== Test: Safe re-evaluation classification on error ===") - - # Create a mock API - mock_api = Mock() - - # Create analyzer - analyzer = SpiritualDistressAnalyzer(mock_api) - - # Create safe classification - result = analyzer._create_safe_reevaluation_classification("Test error message") - - print(f"Flag Level: {result.flag_level}") - print(f"Indicators: {result.indicators}") - print(f"Reasoning: {result.reasoning}") - - # Verify safe defaults - assert result.flag_level == "red", "Safe default should be red flag" - assert "reevaluation_error" in result.indicators - assert "Test error message" in result.reasoning - assert result.confidence == 0.0 - print("✓ Safe classification correctly defaults to red flag") - - return result - - -if __name__ == "__main__": - print("Unit testing re-evaluation logic") - print("=" * 70) - - try: - # Run tests - test_enforce_reevaluation_rules_converts_yellow_to_red() - test_enforce_reevaluation_rules_allows_red() - test_enforce_reevaluation_rules_allows_none() - test_enforce_reevaluation_rules_handles_invalid() - test_reevaluation_with_mock_response() - test_reevaluation_handles_qa_mismatch() - test_create_safe_reevaluation_classification() - - print("\n" + "=" * 70) - print("✓ All unit tests passed!") - - except AssertionError as e: - print(f"\n✗ Test failed: {e}") - import traceback - traceback.print_exc() - sys.exit(1) - except Exception as e: - print(f"\n✗ Test failed with error: {e}") - import traceback - traceback.print_exc() - sys.exit(1) diff --git a/tests/test_referral_language_properties.py b/tests/test_referral_language_properties.py deleted file mode 100644 index fd8ee50d0ac45d090523f2ff33816bb223cd8b01..0000000000000000000000000000000000000000 --- a/tests/test_referral_language_properties.py +++ /dev/null @@ -1,387 +0,0 @@ -# test_referral_language_properties.py -""" -Property-based tests for Referral Generation and Language Support. - -Tests the correctness properties defined in the design document: -- Property 11: Referral Generation on Red -- Property 12: Referral Content Completeness -- Property 13: Language Matching - -Requirements: 6.1, 6.2, 8.1, 8.2, 8.3, 8.4 -""" - -import pytest -from hypothesis import given, strategies as st, settings -from unittest.mock import Mock, patch, MagicMock -from datetime import datetime - -from src.core.spiritual_state import ( - SpiritualState, SpiritualAssessment, SessionSpiritualState, TriageSession -) - - -# ============================================================================= -# Property 11: Referral Generation on Red -# For any RED state confirmation, the system SHALL generate a referral message. -# Validates: Requirements 6.1 -# ============================================================================= - -class TestReferralGenerationOnRed: - """Property 11: Referral Generation on Red tests.""" - - def test_red_state_triggers_referral_generation(self): - """RED state triggers referral generation.""" - from src.core.simplified_medical_app import SimplifiedMedicalApp - - # Mock the app to test referral generation - with patch.object(SimplifiedMedicalApp, '__init__', lambda x: None): - app = SimplifiedMedicalApp() - app.spiritual_state = SessionSpiritualState() - app.clinical_background = Mock() - app.clinical_background.patient_name = "Test Patient" - app._get_conversation_context_str = Mock(return_value="Test context") - app._generate_referral = SimplifiedMedicalApp._generate_referral.__get__(app) - - assessment = SpiritualAssessment( - state=SpiritualState.RED, - indicators=["crisis", "hopelessness"], - confidence=0.9, - reasoning="Severe distress detected" - ) - - referral = app._generate_referral(assessment) - - assert referral is not None - assert len(referral) > 0 - assert "REFERRAL" in referral.upper() - - def test_referral_generated_for_immediate_red(self): - """Referral generated for immediate RED (keyword detection).""" - from src.core.simplified_medical_app import SimplifiedMedicalApp - - with patch.object(SimplifiedMedicalApp, '__init__', lambda x: None): - app = SimplifiedMedicalApp() - app.spiritual_state = SessionSpiritualState() - app.clinical_background = Mock() - app.clinical_background.patient_name = "Test Patient" - app._get_conversation_context_str = Mock(return_value="Test context") - app._generate_referral = SimplifiedMedicalApp._generate_referral.__get__(app) - - # Immediate RED from keyword - assessment = SpiritualAssessment( - state=SpiritualState.RED, - indicators=["suicide"], - confidence=1.0, - reasoning="Red flag keyword detected" - ) - - referral = app._generate_referral(assessment) - - assert "REFERRAL" in referral.upper() - assert "suicide" in referral.lower() - - def test_referral_generated_for_escalated_red(self): - """Referral generated for escalated RED (from triage).""" - from src.core.simplified_medical_app import SimplifiedMedicalApp - - with patch.object(SimplifiedMedicalApp, '__init__', lambda x: None): - app = SimplifiedMedicalApp() - app.spiritual_state = SessionSpiritualState() - app.spiritual_state.triage_session = TriageSession() - app.spiritual_state.triage_session.questions_asked = ["Q1", "Q2"] - app.spiritual_state.triage_session.patient_responses = ["R1", "R2"] - app.clinical_background = Mock() - app.clinical_background.patient_name = "Test Patient" - app._get_conversation_context_str = Mock(return_value="Test context") - app._generate_referral = SimplifiedMedicalApp._generate_referral.__get__(app) - - assessment = SpiritualAssessment( - state=SpiritualState.RED, - indicators=["triage_escalation"], - confidence=0.8, - reasoning="Triage confirmed distress" - ) - - referral = app._generate_referral(assessment) - - assert "REFERRAL" in referral.upper() - # Should include triage context - assert "Q1" in referral or "Triage" in referral - - -# ============================================================================= -# Property 12: Referral Content Completeness -# For any generated referral, the message SHALL contain: patient concerns, -# distress indicators, and conversation context. -# Validates: Requirements 6.2 -# ============================================================================= - -class TestReferralContentCompleteness: - """Property 12: Referral Content Completeness tests.""" - - def test_referral_contains_patient_info(self): - """Referral contains patient information.""" - from src.core.simplified_medical_app import SimplifiedMedicalApp - - with patch.object(SimplifiedMedicalApp, '__init__', lambda x: None): - app = SimplifiedMedicalApp() - app.spiritual_state = SessionSpiritualState() - app.clinical_background = Mock() - app.clinical_background.patient_name = "John Doe" - app._get_conversation_context_str = Mock(return_value="Test context") - app._generate_referral = SimplifiedMedicalApp._generate_referral.__get__(app) - - assessment = SpiritualAssessment( - state=SpiritualState.RED, - indicators=["crisis"], - confidence=0.9, - reasoning="Test" - ) - - referral = app._generate_referral(assessment) - - assert "John Doe" in referral - - def test_referral_contains_distress_indicators(self): - """Referral contains distress indicators.""" - from src.core.simplified_medical_app import SimplifiedMedicalApp - - with patch.object(SimplifiedMedicalApp, '__init__', lambda x: None): - app = SimplifiedMedicalApp() - app.spiritual_state = SessionSpiritualState() - app.clinical_background = Mock() - app.clinical_background.patient_name = "Test Patient" - app._get_conversation_context_str = Mock(return_value="Test context") - app._generate_referral = SimplifiedMedicalApp._generate_referral.__get__(app) - - assessment = SpiritualAssessment( - state=SpiritualState.RED, - indicators=["hopelessness", "isolation", "grief"], - confidence=0.85, - reasoning="Multiple indicators" - ) - - referral = app._generate_referral(assessment) - - assert "hopelessness" in referral.lower() - assert "isolation" in referral.lower() - assert "grief" in referral.lower() - - def test_referral_contains_conversation_context(self): - """Referral contains conversation context.""" - from src.core.simplified_medical_app import SimplifiedMedicalApp - - with patch.object(SimplifiedMedicalApp, '__init__', lambda x: None): - app = SimplifiedMedicalApp() - app.spiritual_state = SessionSpiritualState() - app.clinical_background = Mock() - app.clinical_background.patient_name = "Test Patient" - app._get_conversation_context_str = Mock(return_value="Patient said: I feel very sad") - app._generate_referral = SimplifiedMedicalApp._generate_referral.__get__(app) - - assessment = SpiritualAssessment( - state=SpiritualState.RED, - indicators=["sadness"], - confidence=0.8, - reasoning="Test" - ) - - referral = app._generate_referral(assessment) - - assert "I feel very sad" in referral - - def test_referral_contains_classification_info(self): - """Referral contains classification information.""" - from src.core.simplified_medical_app import SimplifiedMedicalApp - - with patch.object(SimplifiedMedicalApp, '__init__', lambda x: None): - app = SimplifiedMedicalApp() - app.spiritual_state = SessionSpiritualState() - app.clinical_background = Mock() - app.clinical_background.patient_name = "Test Patient" - app._get_conversation_context_str = Mock(return_value="Test context") - app._generate_referral = SimplifiedMedicalApp._generate_referral.__get__(app) - - assessment = SpiritualAssessment( - state=SpiritualState.RED, - indicators=["crisis"], - confidence=0.95, - reasoning="Severe crisis detected" - ) - - referral = app._generate_referral(assessment) - - assert "RED" in referral.upper() - assert "95%" in referral or "0.95" in referral - assert "Severe crisis detected" in referral - - -# ============================================================================= -# Property 13: Language Matching -# For any system response, the language SHALL match the patient's input language. -# Validates: Requirements 8.1, 8.2, 8.3, 8.4 -# ============================================================================= - -class TestLanguageMatching: - """Property 13: Language Matching tests.""" - - def test_detect_english_text(self): - """Detect English text correctly.""" - from src.core.simplified_medical_app import SimplifiedMedicalApp - - with patch.object(SimplifiedMedicalApp, '__init__', lambda x: None): - app = SimplifiedMedicalApp() - app._detect_language = SimplifiedMedicalApp._detect_language.__get__(app) - - assert app._detect_language("Hello, how are you?") == "English" - assert app._detect_language("I have a headache") == "English" - assert app._detect_language("What is the meaning of life?") == "English" - - def test_detect_ukrainian_text(self): - """Detect Ukrainian text correctly.""" - from src.core.simplified_medical_app import SimplifiedMedicalApp - - with patch.object(SimplifiedMedicalApp, '__init__', lambda x: None): - app = SimplifiedMedicalApp() - app._detect_language = SimplifiedMedicalApp._detect_language.__get__(app) - - assert app._detect_language("Привіт, як справи?") == "Ukrainian" - assert app._detect_language("У мене болить голова") == "Ukrainian" - assert app._detect_language("Мені сумно") == "Ukrainian" - - def test_crisis_response_english(self): - """Crisis response in English for English input.""" - from src.core.simplified_medical_app import SimplifiedMedicalApp - - with patch.object(SimplifiedMedicalApp, '__init__', lambda x: None): - app = SimplifiedMedicalApp() - app._generate_crisis_response = SimplifiedMedicalApp._generate_crisis_response.__get__(app) - - assessment = SpiritualAssessment( - state=SpiritualState.RED, - indicators=["crisis"], - confidence=0.9, - reasoning="Test" - ) - - response = app._generate_crisis_response("English", assessment) - - # Should be in English - assert "I hear you" in response or "support" in response.lower() - assert "988" in response # US crisis line - - def test_crisis_response_ukrainian(self): - """Crisis response in Ukrainian for Ukrainian input.""" - from src.core.simplified_medical_app import SimplifiedMedicalApp - - with patch.object(SimplifiedMedicalApp, '__init__', lambda x: None): - app = SimplifiedMedicalApp() - app._generate_crisis_response = SimplifiedMedicalApp._generate_crisis_response.__get__(app) - - assessment = SpiritualAssessment( - state=SpiritualState.RED, - indicators=["crisis"], - confidence=0.9, - reasoning="Test" - ) - - response = app._generate_crisis_response("Ukrainian", assessment) - - # Should be in Ukrainian - assert "Я чую вас" in response or "підтримка" in response.lower() - assert "7333" in response # Ukrainian crisis line - - def test_triage_resolution_english(self): - """Triage resolution message in English.""" - from src.core.simplified_medical_app import SimplifiedMedicalApp - - with patch.object(SimplifiedMedicalApp, '__init__', lambda x: None): - app = SimplifiedMedicalApp() - app.spiritual_state = SessionSpiritualState() - app.spiritual_state.triage_session = TriageSession() - app.spiritual_state.triage_session.patient_responses = ["I'm feeling better"] - app._detect_language = SimplifiedMedicalApp._detect_language.__get__(app) - app._resolve_to_green = SimplifiedMedicalApp._resolve_to_green.__get__(app) - - response = app._resolve_to_green("Patient has support") - - # Should be in English - assert "Thank you" in response or "glad" in response.lower() - - def test_triage_resolution_ukrainian(self): - """Triage resolution message in Ukrainian.""" - from src.core.simplified_medical_app import SimplifiedMedicalApp - - with patch.object(SimplifiedMedicalApp, '__init__', lambda x: None): - app = SimplifiedMedicalApp() - app.spiritual_state = SessionSpiritualState() - # Transition to YELLOW first to create triage session - app.spiritual_state.transition_to(SpiritualState.YELLOW, "test") - app.spiritual_state.triage_session.patient_responses = ["Мені краще, дякую"] - app._detect_language = SimplifiedMedicalApp._detect_language.__get__(app) - app._resolve_to_green = SimplifiedMedicalApp._resolve_to_green.__get__(app) - - response = app._resolve_to_green("Patient has support") - - # Should be in Ukrainian (detected from patient response) - assert "Дякую" in response or "радий" in response.lower() - - def test_fallback_triage_question_english(self): - """Fallback triage question in English.""" - from src.core.simplified_medical_app import SimplifiedMedicalApp - - with patch.object(SimplifiedMedicalApp, '__init__', lambda x: None): - app = SimplifiedMedicalApp() - app._get_fallback_triage_question = SimplifiedMedicalApp._get_fallback_triage_question.__get__(app) - - question = app._get_fallback_triage_question("English") - - assert "How" in question or "feeling" in question.lower() - - def test_fallback_triage_question_ukrainian(self): - """Fallback triage question in Ukrainian.""" - from src.core.simplified_medical_app import SimplifiedMedicalApp - - with patch.object(SimplifiedMedicalApp, '__init__', lambda x: None): - app = SimplifiedMedicalApp() - app._get_fallback_triage_question = SimplifiedMedicalApp._get_fallback_triage_question.__get__(app) - - question = app._get_fallback_triage_question("Ukrainian") - - assert "Як" in question or "почуваєтесь" in question.lower() - - -# ============================================================================= -# Mixed Language Tests -# ============================================================================= - -class TestMixedLanguage: - """Tests for mixed language scenarios.""" - - def test_detect_mixed_defaults_to_dominant(self): - """Mixed text defaults to dominant language.""" - from src.core.simplified_medical_app import SimplifiedMedicalApp - - with patch.object(SimplifiedMedicalApp, '__init__', lambda x: None): - app = SimplifiedMedicalApp() - app._detect_language = SimplifiedMedicalApp._detect_language.__get__(app) - - # Mostly English with some Ukrainian - result = app._detect_language("Hello, як справи today?") - # Should detect based on character ratio - assert result in ["English", "Ukrainian"] - - def test_empty_text_defaults_to_english(self): - """Empty text defaults to English.""" - from src.core.simplified_medical_app import SimplifiedMedicalApp - - with patch.object(SimplifiedMedicalApp, '__init__', lambda x: None): - app = SimplifiedMedicalApp() - app._detect_language = SimplifiedMedicalApp._detect_language.__get__(app) - - assert app._detect_language("") == "English" - assert app._detect_language(" ") == "English" - - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) diff --git a/tests/test_simplified_app_properties.py b/tests/test_simplified_app_properties.py deleted file mode 100644 index 62fba6288a1e6c5cbe2fb659e7fc4a83bd188157..0000000000000000000000000000000000000000 --- a/tests/test_simplified_app_properties.py +++ /dev/null @@ -1,361 +0,0 @@ -# test_simplified_app_properties.py -""" -Property-based tests for Simplified Medical App integration. - -Tests the correctness properties defined in the design document: -- Property 1: Spiritual Monitor Always Invoked -- Property 2: Green State Preservation -- Property 3: Yellow Triggers Triage -- Property 4: Red Triggers Immediate Referral - -Requirements: 2.1, 2.2, 2.3, 2.4 -""" - -import pytest -from hypothesis import given, strategies as st, settings -from unittest.mock import Mock, patch, MagicMock - -from src.core.spiritual_state import ( - SpiritualState, TriageOutcome, SpiritualAssessment, SessionSpiritualState -) -from src.core.spiritual_monitor import SpiritualMonitor - - -# ============================================================================= -# Property 1: Spiritual Monitor Always Invoked -# For any patient message, the Spiritual Monitor SHALL be invoked to classify -# the message before generating a response. -# Validates: Requirements 2.1 -# ============================================================================= - -class TestSpiritualMonitorInvocation: - """Property 1: Spiritual Monitor Always Invoked tests.""" - - def test_monitor_called_for_normal_message(self): - """Monitor is called for normal medical message.""" - # Create mock components - mock_api = Mock() - mock_api.call_spiritual_api = Mock(return_value='{"state": "green", "indicators": [], "confidence": 0.9, "reasoning": "Normal"}') - mock_api.call_medical_api = Mock(return_value="Medical response") - - monitor = SpiritualMonitor(mock_api) - - # Classify a normal message - result = monitor.classify("I have a headache") - - # Monitor should return a result - assert result is not None - assert isinstance(result, SpiritualAssessment) - - def test_monitor_called_for_emotional_message(self): - """Monitor is called for emotional message.""" - mock_api = Mock() - mock_api.call_spiritual_api = Mock(return_value='{"state": "yellow", "indicators": ["sadness"], "confidence": 0.7, "reasoning": "Emotional"}') - - monitor = SpiritualMonitor(mock_api) - - result = monitor.classify("I feel sad today") - - assert result is not None - assert result.state == SpiritualState.YELLOW - - def test_monitor_called_for_crisis_message(self): - """Monitor is called for crisis message (but uses keyword detection).""" - mock_api = Mock() - monitor = SpiritualMonitor(mock_api) - - result = monitor.classify("I want to end my life") - - # Should detect via keywords, not API - assert result is not None - assert result.state == SpiritualState.RED - # API should NOT be called for red flag keywords - mock_api.call_spiritual_api.assert_not_called() - - @given(st.text(min_size=1, max_size=200)) - @settings(max_examples=20) - def test_monitor_always_returns_assessment(self, message): - """Monitor always returns a valid assessment for any message.""" - mock_api = Mock() - mock_api.call_spiritual_api = Mock(return_value='{"state": "green", "indicators": [], "confidence": 0.9, "reasoning": "Normal"}') - - monitor = SpiritualMonitor(mock_api) - - result = monitor.classify(message) - - assert result is not None - assert isinstance(result, SpiritualAssessment) - assert result.state in SpiritualState - - -# ============================================================================= -# Property 2: Green State Preservation -# For any message classified as no-distress (GREEN), the system SHALL remain -# in GREEN state and continue medical dialog. -# Validates: Requirements 2.2 -# ============================================================================= - -class TestGreenStatePreservation: - """Property 2: Green State Preservation tests.""" - - def test_green_classification_preserves_green_state(self): - """GREEN classification keeps session in GREEN state.""" - session = SessionSpiritualState() - assert session.spiritual_state == SpiritualState.GREEN - - # Simulate GREEN classification - state should remain GREEN - # (no transition needed for GREEN -> GREEN) - assert session.spiritual_state == SpiritualState.GREEN - assert not session.is_in_triage() - - def test_green_does_not_create_triage_session(self): - """GREEN state does not create triage session.""" - session = SessionSpiritualState() - - # Stay in GREEN - assert session.triage_session is None - - # Even after explicit transition to GREEN - session.transition_to(SpiritualState.GREEN, "test") - assert session.triage_session is None - - def test_multiple_green_messages_stay_green(self): - """Multiple GREEN messages keep session in GREEN.""" - session = SessionSpiritualState() - - # Simulate multiple GREEN classifications - for i in range(5): - # Each GREEN message should keep state GREEN - assert session.spiritual_state == SpiritualState.GREEN - assert not session.is_in_triage() - - @given(st.integers(min_value=1, max_value=20)) - def test_n_green_messages_preserve_state(self, n): - """N consecutive GREEN messages preserve GREEN state.""" - session = SessionSpiritualState() - - for _ in range(n): - # Simulate GREEN classification - assert session.spiritual_state == SpiritualState.GREEN - - # Still GREEN after N messages - assert session.spiritual_state == SpiritualState.GREEN - - -# ============================================================================= -# Property 3: Yellow Triggers Triage -# For any message classified as potential-distress (YELLOW), the system SHALL -# enter YELLOW state and initiate Soft Triage. -# Validates: Requirements 2.3 -# ============================================================================= - -class TestYellowTriggersTriage: - """Property 3: Yellow Triggers Triage tests.""" - - def test_yellow_classification_triggers_triage(self): - """YELLOW classification triggers triage state.""" - session = SessionSpiritualState() - assert session.spiritual_state == SpiritualState.GREEN - - # Transition to YELLOW - session.transition_to(SpiritualState.YELLOW, "Potential distress detected") - - assert session.spiritual_state == SpiritualState.YELLOW - assert session.is_in_triage() - assert session.triage_session is not None - - def test_yellow_creates_fresh_triage_session(self): - """YELLOW creates a fresh triage session.""" - session = SessionSpiritualState() - - session.transition_to(SpiritualState.YELLOW, "test") - - assert session.triage_session is not None - assert session.triage_session.question_count == 0 - assert len(session.triage_session.questions_asked) == 0 - assert len(session.triage_session.patient_responses) == 0 - - def test_yellow_from_green_logs_transition(self): - """GREEN -> YELLOW transition is logged.""" - session = SessionSpiritualState() - - session.transition_to(SpiritualState.YELLOW, "Sadness detected") - - assert len(session.state_history) > 0 - assert "green -> yellow" in session.state_history[-1].lower() - - def test_yellow_indicator_message_triggers_yellow(self): - """Message with yellow indicators triggers YELLOW classification.""" - mock_api = Mock() - mock_api.call_spiritual_api = Mock(return_value='{"state": "yellow", "indicators": ["sadness"], "confidence": 0.7, "reasoning": "Emotional content"}') - - monitor = SpiritualMonitor(mock_api) - - result = monitor.classify("I feel sad and lonely") - - assert result.state == SpiritualState.YELLOW - - -# ============================================================================= -# Property 4: Red Triggers Immediate Referral -# For any message classified as severe-distress (RED), the system SHALL -# generate a referral immediately without triage. -# Validates: Requirements 2.4 -# ============================================================================= - -class TestRedTriggersReferral: - """Property 4: Red Triggers Immediate Referral tests.""" - - def test_red_classification_triggers_red_state(self): - """RED classification triggers RED state.""" - session = SessionSpiritualState() - - session.transition_to(SpiritualState.RED, "Crisis detected") - - assert session.spiritual_state == SpiritualState.RED - assert not session.is_in_triage() # No triage for RED - - def test_red_skips_triage(self): - """RED state does not create triage session.""" - session = SessionSpiritualState() - - # Direct to RED (not through YELLOW) - session.transition_to(SpiritualState.RED, "Immediate crisis") - - assert session.triage_session is None - - def test_red_from_green_is_immediate(self): - """GREEN -> RED transition is immediate (no YELLOW intermediate).""" - session = SessionSpiritualState() - assert session.spiritual_state == SpiritualState.GREEN - - # Direct transition to RED - session.transition_to(SpiritualState.RED, "Suicide keyword detected") - - assert session.spiritual_state == SpiritualState.RED - # Should have only one transition logged - assert len(session.state_history) == 1 - - def test_red_keyword_triggers_immediate_red(self): - """Red flag keyword triggers immediate RED classification.""" - mock_api = Mock() - monitor = SpiritualMonitor(mock_api) - - result = monitor.classify("I want to kill myself") - - assert result.state == SpiritualState.RED - assert result.confidence == 1.0 # Keyword detection is certain - - @pytest.mark.parametrize("keyword", [ - "suicide", "kill myself", "want to die", "hopeless", "end my life" - ]) - def test_various_red_keywords_trigger_red(self, keyword): - """Various red flag keywords trigger RED.""" - mock_api = Mock() - monitor = SpiritualMonitor(mock_api) - - result = monitor.classify(f"I feel {keyword}") - - assert result.state == SpiritualState.RED - - -# ============================================================================= -# State Transition Flow Tests -# ============================================================================= - -class TestStateTransitionFlows: - """Tests for complete state transition flows.""" - - def test_green_to_yellow_to_green_flow(self): - """GREEN -> YELLOW -> GREEN flow (resolved triage).""" - session = SessionSpiritualState() - - # Start GREEN - assert session.spiritual_state == SpiritualState.GREEN - - # Detect potential distress -> YELLOW - session.transition_to(SpiritualState.YELLOW, "Sadness detected") - assert session.spiritual_state == SpiritualState.YELLOW - assert session.is_in_triage() - - # Triage resolves positively -> GREEN - session.transition_to(SpiritualState.GREEN, "Patient has support") - assert session.spiritual_state == SpiritualState.GREEN - assert not session.is_in_triage() - assert session.triage_session is None - - def test_green_to_yellow_to_red_flow(self): - """GREEN -> YELLOW -> RED flow (escalated triage).""" - session = SessionSpiritualState() - - # Start GREEN - assert session.spiritual_state == SpiritualState.GREEN - - # Detect potential distress -> YELLOW - session.transition_to(SpiritualState.YELLOW, "Distress indicators") - assert session.spiritual_state == SpiritualState.YELLOW - - # Triage confirms severe distress -> RED - session.transition_to(SpiritualState.RED, "Distress confirmed") - assert session.spiritual_state == SpiritualState.RED - assert not session.is_in_triage() - - def test_green_to_red_immediate_flow(self): - """GREEN -> RED immediate flow (crisis keywords).""" - session = SessionSpiritualState() - - # Start GREEN - assert session.spiritual_state == SpiritualState.GREEN - - # Immediate crisis -> RED (skip YELLOW) - session.transition_to(SpiritualState.RED, "Suicide keyword") - assert session.spiritual_state == SpiritualState.RED - - # Should never have been in YELLOW - assert "yellow" not in " ".join(session.state_history).lower() or "red" in session.state_history[-1].lower() - - -# ============================================================================= -# Language Detection Tests -# ============================================================================= - -class TestLanguageDetection: - """Tests for language detection functionality.""" - - def test_detect_english(self): - """Detect English text.""" - from src.core.simplified_medical_app import SimplifiedMedicalApp - - # Create minimal mock to test language detection - with patch.object(SimplifiedMedicalApp, '__init__', lambda x: None): - app = SimplifiedMedicalApp() - app._detect_language = SimplifiedMedicalApp._detect_language.__get__(app) - - assert app._detect_language("Hello, how are you?") == "English" - assert app._detect_language("I have a headache") == "English" - - def test_detect_ukrainian(self): - """Detect Ukrainian text.""" - from src.core.simplified_medical_app import SimplifiedMedicalApp - - with patch.object(SimplifiedMedicalApp, '__init__', lambda x: None): - app = SimplifiedMedicalApp() - app._detect_language = SimplifiedMedicalApp._detect_language.__get__(app) - - assert app._detect_language("Привіт, як справи?") == "Ukrainian" - assert app._detect_language("У мене болить голова") == "Ukrainian" - - def test_detect_empty_defaults_to_english(self): - """Empty text defaults to English.""" - from src.core.simplified_medical_app import SimplifiedMedicalApp - - with patch.object(SimplifiedMedicalApp, '__init__', lambda x: None): - app = SimplifiedMedicalApp() - app._detect_language = SimplifiedMedicalApp._detect_language.__get__(app) - - assert app._detect_language("") == "English" - assert app._detect_language(None) == "English" if hasattr(app._detect_language, '__call__') else True - - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) diff --git a/tests/test_soft_triage_properties.py b/tests/test_soft_triage_properties.py deleted file mode 100644 index e5ee1586e4e622efbc1fcfad6fdcbc6c9c857f01..0000000000000000000000000000000000000000 --- a/tests/test_soft_triage_properties.py +++ /dev/null @@ -1,416 +0,0 @@ -# test_soft_triage_properties.py -""" -Property-based tests for Soft Triage Manager. - -Tests the correctness properties defined in the design document: -- Property 5: Triage Question Limit (max 3) -- Property 6: Triage Binary Outcome (GREEN or RED, never YELLOW) -- Property 7: Triage Timeout Escalation - -Requirements: 3.1, 3.3, 3.6, 7.2 -""" - -import pytest -from hypothesis import given, strategies as st, settings -from unittest.mock import Mock, patch - -from src.core.spiritual_state import ( - SpiritualState, TriageOutcome, TriageSession, SpiritualAssessment -) -from src.core.soft_triage_manager import SoftTriageManager - - -# ============================================================================= -# Property 5: Triage Question Limit -# For any Soft Triage session, the number of clarifying questions SHALL NOT -# exceed 3. -# Validates: Requirements 3.1, 7.2 -# ============================================================================= - -class TestTriageQuestionLimit: - """Property 5: Triage Question Limit tests.""" - - @pytest.fixture - def manager(self): - """Create manager with mocked API client.""" - mock_api = Mock() - mock_api.call_spiritual_api = Mock(return_value="How are you feeling?") - return SoftTriageManager(mock_api) - - def test_max_questions_is_three(self, manager): - """MAX_QUESTIONS constant is 3.""" - assert manager.MAX_QUESTIONS == 3 - - def test_should_force_decision_at_limit(self, manager): - """should_force_decision returns True at question limit.""" - session = TriageSession() - - # Not at limit - assert not manager.should_force_decision(session) - - session.question_count = 1 - assert not manager.should_force_decision(session) - - session.question_count = 2 - assert not manager.should_force_decision(session) - - # At limit - session.question_count = 3 - assert manager.should_force_decision(session) - - @given(st.integers(min_value=0, max_value=10)) - def test_should_force_decision_threshold(self, count): - """should_force_decision is True iff count >= 3.""" - mock_api = Mock() - manager = SoftTriageManager(mock_api) - - session = TriageSession() - session.question_count = count - - expected = count >= 3 - assert manager.should_force_decision(session) == expected - - def test_question_generation_respects_limit(self, manager): - """Question generation works within limit.""" - session = TriageSession() - assessment = SpiritualAssessment( - state=SpiritualState.YELLOW, - indicators=["sadness"], - confidence=0.7, - reasoning="test" - ) - - # Generate questions up to limit - for i in range(3): - question = manager.generate_question( - context="Test context", - patient_language="English", - triage_session=session, - assessment=assessment - ) - assert isinstance(question, str) - assert len(question) > 0 - session.add_exchange(question, f"Response {i}") - - # After 3 questions, should_force_decision is True - assert manager.should_force_decision(session) - - -# ============================================================================= -# Property 6: Triage Binary Outcome -# For any completed Soft Triage session, the outcome SHALL be either GREEN -# (resolved) or RED (escalated), never YELLOW. -# Validates: Requirements 3.3 -# ============================================================================= - -class TestTriageBinaryOutcome: - """Property 6: Triage Binary Outcome tests.""" - - def test_triage_outcome_enum_values(self): - """TriageOutcome has correct values.""" - assert TriageOutcome.RESOLVED_GREEN.value == "resolved_green" - assert TriageOutcome.ESCALATE_RED.value == "escalate_red" - assert TriageOutcome.CONTINUE.value == "continue" - - def test_final_outcomes_are_binary(self): - """Final outcomes (not CONTINUE) are binary: GREEN or RED.""" - final_outcomes = [ - TriageOutcome.RESOLVED_GREEN, - TriageOutcome.ESCALATE_RED - ] - - # CONTINUE is not a final outcome - assert TriageOutcome.CONTINUE not in final_outcomes - - # Only two final outcomes - assert len(final_outcomes) == 2 - - @pytest.fixture - def manager(self): - """Create manager with mocked API client.""" - mock_api = Mock() - return SoftTriageManager(mock_api) - - def test_force_decision_returns_binary_outcome(self, manager): - """_force_decision returns only GREEN or RED, never CONTINUE.""" - session = TriageSession() - session.question_count = 3 - session.questions_asked = ["Q1", "Q2", "Q3"] - session.patient_responses = ["R1", "R2", "R3"] - - outcome, reasoning = manager._force_decision("Final response", session) - - assert outcome in [TriageOutcome.RESOLVED_GREEN, TriageOutcome.ESCALATE_RED] - assert outcome != TriageOutcome.CONTINUE - - def test_force_decision_with_positive_indicators_returns_green(self, manager): - """Force decision with positive indicators returns GREEN.""" - session = TriageSession() - session.question_count = 3 - session.questions_asked = ["Q1", "Q2", "Q3"] - session.patient_responses = [ - "I'm feeling better now", - "My family is very supportive", - "I'm coping okay" - ] - - outcome, reasoning = manager._force_decision("I have good support", session) - - assert outcome == TriageOutcome.RESOLVED_GREEN - - def test_force_decision_without_positive_indicators_returns_red(self, manager): - """Force decision without positive indicators returns RED (conservative).""" - session = TriageSession() - session.question_count = 3 - session.questions_asked = ["Q1", "Q2", "Q3"] - session.patient_responses = [ - "I don't know", - "It's complicated", - "Nothing helps" - ] - - outcome, reasoning = manager._force_decision("I'm not sure", session) - - assert outcome == TriageOutcome.ESCALATE_RED - - -# ============================================================================= -# Property 7: Triage Timeout Escalation -# For any Soft Triage session that reaches 3 exchanges without clear resolution, -# the system SHALL escalate to RED. -# Validates: Requirements 3.6 -# ============================================================================= - -class TestTriageTimeoutEscalation: - """Property 7: Triage Timeout Escalation tests.""" - - @pytest.fixture - def manager(self): - """Create manager with mocked API client.""" - mock_api = Mock() - return SoftTriageManager(mock_api) - - def test_evaluate_at_limit_forces_decision(self, manager): - """evaluate_response at limit forces a decision.""" - session = TriageSession() - session.question_count = 3 - session.questions_asked = ["Q1", "Q2", "Q3"] - session.patient_responses = ["R1", "R2"] # One less than questions - - outcome, reasoning = manager.evaluate_response( - response="I'm not sure", - triage_session=session, - context="Test context" - ) - - # Should force decision, not CONTINUE - assert outcome in [TriageOutcome.RESOLVED_GREEN, TriageOutcome.ESCALATE_RED] - - def test_uncertain_at_limit_escalates_to_red(self, manager): - """Uncertain response at limit escalates to RED.""" - session = TriageSession() - session.question_count = 3 - session.questions_asked = ["Q1", "Q2", "Q3"] - session.patient_responses = ["vague", "unclear", "maybe"] - - outcome, reasoning = manager._force_decision("I don't know", session) - - # Conservative: escalate to RED when uncertain - assert outcome == TriageOutcome.ESCALATE_RED - assert "conservative" in reasoning.lower() or "max questions" in reasoning.lower() - - def test_evaluate_before_limit_can_continue(self, manager): - """evaluate_response before limit can return CONTINUE.""" - # Mock API to return CONTINUE response - manager.api.call_spiritual_api = Mock( - return_value='{"outcome": "continue", "reasoning": "Need more info", "confidence": 0.6}' - ) - - session = TriageSession() - session.question_count = 1 - session.questions_asked = ["Q1"] - session.patient_responses = [] - - outcome, reasoning = manager.evaluate_response( - response="I'm not sure", - triage_session=session, - context="Test context" - ) - - # Before limit, CONTINUE is allowed - assert outcome == TriageOutcome.CONTINUE - - -# ============================================================================= -# Fallback Question Tests -# ============================================================================= - -class TestFallbackQuestions: - """Tests for fallback question generation.""" - - @pytest.fixture - def manager(self): - """Create manager with mocked API client.""" - mock_api = Mock() - return SoftTriageManager(mock_api) - - def test_fallback_questions_english(self, manager): - """Fallback questions exist for English.""" - for i in range(3): - question = manager._get_fallback_question("English", i) - assert isinstance(question, str) - assert len(question) > 10 - - def test_fallback_questions_ukrainian(self, manager): - """Fallback questions exist for Ukrainian.""" - for i in range(3): - question = manager._get_fallback_question("Ukrainian", i) - assert isinstance(question, str) - assert len(question) > 10 - - def test_fallback_questions_unknown_language(self, manager): - """Unknown language falls back to English.""" - question = manager._get_fallback_question("Unknown", 0) - assert isinstance(question, str) - assert len(question) > 10 - - def test_fallback_question_index_bounds(self, manager): - """Fallback question handles out-of-bounds index.""" - # Should not raise, should return last question - question = manager._get_fallback_question("English", 10) - assert isinstance(question, str) - - -# ============================================================================= -# LLM Response Parsing Tests -# ============================================================================= - -class TestEvaluationParsing: - """Tests for evaluation response parsing.""" - - @pytest.fixture - def manager(self): - """Create manager with mocked API client.""" - mock_api = Mock() - return SoftTriageManager(mock_api) - - def test_parse_resolved_green(self, manager): - """Parse RESOLVED_GREEN response.""" - response = '{"outcome": "resolved_green", "reasoning": "Patient coping well", "confidence": 0.8}' - - outcome, reasoning = manager._parse_evaluation_response(response) - - assert outcome == TriageOutcome.RESOLVED_GREEN - assert "coping" in reasoning.lower() - - def test_parse_escalate_red(self, manager): - """Parse ESCALATE_RED response.""" - response = '{"outcome": "escalate_red", "reasoning": "Severe distress", "confidence": 0.9}' - - outcome, reasoning = manager._parse_evaluation_response(response) - - assert outcome == TriageOutcome.ESCALATE_RED - - def test_parse_continue(self, manager): - """Parse CONTINUE response.""" - response = '{"outcome": "continue", "reasoning": "Need more info", "confidence": 0.5}' - - outcome, reasoning = manager._parse_evaluation_response(response) - - assert outcome == TriageOutcome.CONTINUE - - def test_parse_invalid_json_returns_continue(self, manager): - """Invalid JSON returns CONTINUE.""" - response = "This is not valid JSON" - - outcome, reasoning = manager._parse_evaluation_response(response) - - assert outcome == TriageOutcome.CONTINUE - assert "error" in reasoning.lower() - - -# ============================================================================= -# Integration-like Tests -# ============================================================================= - -class TestTriageManagerIntegration: - """Integration-like tests with mocked API.""" - - def test_full_triage_flow_to_green(self): - """Full triage flow resolving to GREEN.""" - mock_api = Mock() - mock_api.call_spiritual_api = Mock(side_effect=[ - "How are you feeling today?", # Question 1 - '{"outcome": "resolved_green", "reasoning": "Patient has support", "confidence": 0.8}' - ]) - - manager = SoftTriageManager(mock_api) - session = TriageSession() - assessment = SpiritualAssessment( - state=SpiritualState.YELLOW, - indicators=["sadness"], - confidence=0.7, - reasoning="test" - ) - - # Generate question - question = manager.generate_question( - context="Patient mentioned feeling sad", - patient_language="English", - triage_session=session, - assessment=assessment - ) - assert isinstance(question, str) - - # Add exchange - session.add_exchange(question, "I'm feeling better, my family is supportive") - - # Evaluate response - outcome, reasoning = manager.evaluate_response( - response="I'm feeling better, my family is supportive", - triage_session=session, - context="Patient mentioned feeling sad" - ) - - assert outcome == TriageOutcome.RESOLVED_GREEN - - def test_full_triage_flow_to_red(self): - """Full triage flow escalating to RED.""" - mock_api = Mock() - mock_api.call_spiritual_api = Mock(side_effect=[ - "How are you feeling today?", - '{"outcome": "escalate_red", "reasoning": "Patient shows severe distress", "confidence": 0.9}' - ]) - - manager = SoftTriageManager(mock_api) - session = TriageSession() - assessment = SpiritualAssessment( - state=SpiritualState.YELLOW, - indicators=["hopelessness"], - confidence=0.8, - reasoning="test" - ) - - # Generate question - question = manager.generate_question( - context="Patient mentioned hopelessness", - patient_language="English", - triage_session=session, - assessment=assessment - ) - - # Add exchange - session.add_exchange(question, "Nothing helps, I feel completely alone") - - # Evaluate response - outcome, reasoning = manager.evaluate_response( - response="Nothing helps, I feel completely alone", - triage_session=session, - context="Patient mentioned hopelessness" - ) - - assert outcome == TriageOutcome.ESCALATE_RED - - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) diff --git a/tests/test_spiritual_assistant.py b/tests/test_spiritual_assistant.py deleted file mode 100644 index 1ee25be4a38aad6614455ccf24ae8938c843646d..0000000000000000000000000000000000000000 --- a/tests/test_spiritual_assistant.py +++ /dev/null @@ -1,325 +0,0 @@ -# test_spiritual_assistant.py -""" -Unit tests for SpiritualAssistant - -Tests the dialog integration of spiritual health assessment. -""" - -import pytest -from unittest.mock import Mock, MagicMock, patch -from datetime import datetime - -from src.core.spiritual_assistant import SpiritualAssistant, create_spiritual_assistant -from src.core.spiritual_classes import DistressClassification, ReferralMessage, PatientInput -from src.core.ai_client import AIClientManager - - -@pytest.fixture -def mock_api(): - """Create mock AI client manager""" - return Mock(spec=AIClientManager) - - -@pytest.fixture -def mock_analyzer(): - """Create mock spiritual distress analyzer""" - return Mock() - - -@pytest.fixture -def mock_referral_generator(): - """Create mock referral generator""" - return Mock() - - -@pytest.fixture -def mock_question_generator(): - """Create mock question generator""" - return Mock() - - -@pytest.fixture -def spiritual_assistant(mock_api, mock_analyzer, mock_referral_generator, mock_question_generator): - """Create SpiritualAssistant with mocked dependencies""" - with patch('src.core.spiritual_assistant.SpiritualDistressAnalyzer', return_value=mock_analyzer), \ - patch('src.core.spiritual_assistant.ReferralMessageGenerator', return_value=mock_referral_generator), \ - patch('src.core.spiritual_assistant.ClarifyingQuestionGenerator', return_value=mock_question_generator): - assistant = SpiritualAssistant(mock_api) - assistant.analyzer = mock_analyzer - assistant.referral_generator = mock_referral_generator - assistant.question_generator = mock_question_generator - return assistant - - -class TestSpiritualAssistantInit: - """Test SpiritualAssistant initialization""" - - def test_init_success(self, mock_api): - """Test successful initialization""" - with patch('src.core.spiritual_assistant.SpiritualDistressAnalyzer'), \ - patch('src.core.spiritual_assistant.ReferralMessageGenerator'), \ - patch('src.core.spiritual_assistant.ClarifyingQuestionGenerator'): - assistant = SpiritualAssistant(mock_api) - assert assistant.api == mock_api - assert assistant.analyzer is not None - assert assistant.referral_generator is not None - assert assistant.question_generator is not None - - -class TestProcessMessageRedFlag: - """Test process_message with red flag""" - - def test_red_flag_generates_referral(self, spiritual_assistant, mock_analyzer, mock_referral_generator): - """Test that red flag generates referral message""" - # Setup - classification = DistressClassification( - flag_level="red", - indicators=["anger_all_the_time", "crying_all_the_time"], - categories=["emotional_distress"], - confidence=0.95, - reasoning="Clear indicators of severe distress" - ) - - referral = ReferralMessage( - patient_concerns="Severe emotional distress", - message_text="Patient needs immediate spiritual care", - context="Red flag assessment" - ) - - mock_analyzer.analyze_message.return_value = classification - mock_referral_generator.generate_referral.return_value = referral - - # Execute - result = spiritual_assistant.process_message( - "I am angry all the time and crying constantly", - [], - Mock() - ) - - # Assert - assert result["action"] == "escalate" - assert result["classification"] == classification - assert result["referral"] == referral - assert "spiritual care team" in result["message"].lower() - assert len(result["questions"]) == 0 - - def test_red_flag_message_format(self, spiritual_assistant, mock_analyzer, mock_referral_generator): - """Test that red flag message is properly formatted""" - # Setup - classification = DistressClassification( - flag_level="red", - indicators=["hopelessness"], - categories=["spiritual_distress"], - confidence=0.90, - reasoning="Hopelessness indicator" - ) - - referral = ReferralMessage( - patient_concerns="Feeling hopeless", - message_text="Referral needed", - context="Assessment" - ) - - mock_analyzer.analyze_message.return_value = classification - mock_referral_generator.generate_referral.return_value = referral - - # Execute - result = spiritual_assistant.process_message("I feel hopeless", [], Mock()) - - # Assert - assert "🕊️" in result["message"] - assert "hopelessness" in result["message"].lower() - assert result["reasoning"].startswith("Red flag detected") - - -class TestProcessMessageYellowFlag: - """Test process_message with yellow flag""" - - def test_yellow_flag_generates_questions(self, spiritual_assistant, mock_analyzer, mock_question_generator): - """Test that yellow flag generates clarifying questions""" - # Setup - classification = DistressClassification( - flag_level="yellow", - indicators=["frustration"], - categories=["emotional_concern"], - confidence=0.70, - reasoning="Potential distress indicators" - ) - - questions = [ - "Can you tell me more about what's been frustrating you?", - "How long have you been feeling this way?" - ] - - mock_analyzer.analyze_message.return_value = classification - mock_question_generator.generate_questions.return_value = questions - - # Execute - result = spiritual_assistant.process_message( - "I've been feeling frustrated lately", - [], - Mock() - ) - - # Assert - assert result["action"] == "continue" - assert result["classification"] == classification - assert result["referral"] is None - assert result["questions"] == questions - assert "understand better" in result["message"].lower() - - def test_yellow_flag_message_includes_questions(self, spiritual_assistant, mock_analyzer, mock_question_generator): - """Test that yellow flag message includes questions""" - # Setup - classification = DistressClassification( - flag_level="yellow", - indicators=["concern"], - categories=["emotional"], - confidence=0.65, - reasoning="Minor concern" - ) - - questions = ["Question 1?", "Question 2?"] - - mock_analyzer.analyze_message.return_value = classification - mock_question_generator.generate_questions.return_value = questions - - # Execute - result = spiritual_assistant.process_message("I'm concerned", [], Mock()) - - # Assert - for question in questions: - assert question in result["message"] - - -class TestProcessMessageNoFlag: - """Test process_message with no flag""" - - def test_no_flag_supportive_response(self, spiritual_assistant, mock_analyzer): - """Test that no flag generates supportive response""" - # Setup - classification = DistressClassification( - flag_level="none", - indicators=[], - categories=[], - confidence=0.85, - reasoning="No distress indicators" - ) - - mock_analyzer.analyze_message.return_value = classification - - # Execute - result = spiritual_assistant.process_message( - "I'm doing well today", - [], - Mock() - ) - - # Assert - assert result["action"] == "continue" - assert result["classification"] == classification - assert result["referral"] is None - assert len(result["questions"]) == 0 - assert "managing well" in result["message"].lower() or "doing well" in result["message"].lower() - - def test_no_flag_offers_support(self, spiritual_assistant, mock_analyzer): - """Test that no flag response still offers support""" - # Setup - classification = DistressClassification( - flag_level="none", - indicators=[], - categories=[], - confidence=0.90, - reasoning="Positive indicators" - ) - - mock_analyzer.analyze_message.return_value = classification - - # Execute - result = spiritual_assistant.process_message("Feeling good", [], Mock()) - - # Assert - assert "spiritual care team" in result["message"].lower() - assert "available" in result["message"].lower() - - -class TestErrorHandling: - """Test error handling""" - - def test_analyzer_error_returns_safe_response(self, spiritual_assistant, mock_analyzer): - """Test that analyzer error returns safe response""" - # Setup - mock_analyzer.analyze_message.side_effect = Exception("API Error") - - # Execute - result = spiritual_assistant.process_message("Test message", [], Mock()) - - # Assert - assert result["action"] == "continue" - assert result["classification"] is None - assert "having a bit of trouble" in result["message"].lower() - assert "Error occurred" in result["reasoning"] - - def test_referral_generator_error_handled(self, spiritual_assistant, mock_analyzer, mock_referral_generator): - """Test that referral generator error is handled""" - # Setup - classification = DistressClassification( - flag_level="red", - indicators=["test"], - categories=["test"], - confidence=0.9, - reasoning="test" - ) - - mock_analyzer.analyze_message.return_value = classification - mock_referral_generator.generate_referral.side_effect = Exception("Referral error") - - # Execute - result = spiritual_assistant.process_message("Test", [], Mock()) - - # Assert - assert result["action"] == "continue" - assert "trouble" in result["message"].lower() - - -class TestConvenienceFunction: - """Test convenience function""" - - def test_create_spiritual_assistant(self, mock_api): - """Test create_spiritual_assistant function""" - with patch('src.core.spiritual_assistant.SpiritualDistressAnalyzer'), \ - patch('src.core.spiritual_assistant.ReferralMessageGenerator'), \ - patch('src.core.spiritual_assistant.ClarifyingQuestionGenerator'): - assistant = create_spiritual_assistant(mock_api) - assert isinstance(assistant, SpiritualAssistant) - assert assistant.api == mock_api - - -class TestMessageFormatting: - """Test message formatting methods""" - - def test_format_indicators_with_list(self, spiritual_assistant): - """Test formatting indicators list""" - indicators = ["anger", "sadness", "hopelessness"] - result = spiritual_assistant._format_indicators(indicators) - - assert "• anger" in result - assert "• sadness" in result - assert "• hopelessness" in result - - def test_format_indicators_empty_list(self, spiritual_assistant): - """Test formatting empty indicators list""" - result = spiritual_assistant._format_indicators([]) - assert "General emotional concerns" in result - - def test_format_indicators_limits_to_five(self, spiritual_assistant): - """Test that formatting limits to 5 indicators""" - indicators = ["1", "2", "3", "4", "5", "6", "7"] - result = spiritual_assistant._format_indicators(indicators) - - # Should only have 5 bullets - assert result.count("•") == 5 - - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) diff --git a/tests/test_spiritual_monitor_properties.py b/tests/test_spiritual_monitor_properties.py deleted file mode 100644 index 6335bd81f31295758ce7782bad56a2ce909a7f39..0000000000000000000000000000000000000000 --- a/tests/test_spiritual_monitor_properties.py +++ /dev/null @@ -1,292 +0,0 @@ -# test_spiritual_monitor_properties.py -""" -Property-based tests for Spiritual Monitor. - -Tests the correctness properties defined in the design document: -- Property 9: Conservative Classification (YELLOW over GREEN when uncertain) -- Property 10: Red Flag Keywords (immediate RED for crisis language) - -Requirements: 5.2, 5.4 -""" - -import pytest -from hypothesis import given, strategies as st, settings, assume -from unittest.mock import Mock, patch - -from src.core.spiritual_state import SpiritualState, SpiritualAssessment -from src.core.spiritual_monitor import ( - SpiritualMonitor, - RED_FLAG_KEYWORDS, - YELLOW_FLAG_INDICATORS -) - - -# ============================================================================= -# Property 10: Red Flag Keywords -# For any message containing death, hopelessness, or suicidal ideation keywords, -# the classification SHALL be RED. -# Validates: Requirements 5.4 -# ============================================================================= - -class TestRedFlagKeywords: - """Property 10: Red Flag Keywords tests.""" - - @pytest.fixture - def monitor(self): - """Create monitor with mocked API client.""" - mock_api = Mock() - return SpiritualMonitor(mock_api) - - @pytest.mark.parametrize("keyword", RED_FLAG_KEYWORDS[:10]) # Test first 10 - def test_red_flag_keyword_triggers_red(self, monitor, keyword): - """Any red flag keyword triggers RED classification.""" - message = f"I feel like {keyword} sometimes" - - result = monitor.classify(message) - - assert result.state == SpiritualState.RED - assert result.confidence == 1.0 - assert keyword in result.indicators - - def test_suicide_keyword_is_red(self, monitor): - """'suicide' keyword triggers RED.""" - result = monitor.classify("I've been thinking about suicide") - assert result.state == SpiritualState.RED - - def test_want_to_die_is_red(self, monitor): - """'want to die' triggers RED.""" - result = monitor.classify("Sometimes I want to die") - assert result.state == SpiritualState.RED - - def test_hopeless_is_red(self, monitor): - """'hopeless' triggers RED.""" - result = monitor.classify("Everything feels hopeless") - assert result.state == SpiritualState.RED - - def test_ukrainian_suicide_keyword_is_red(self, monitor): - """Ukrainian suicide keywords trigger RED.""" - result = monitor.classify("Я думаю про самогубство") - assert result.state == SpiritualState.RED - - def test_ukrainian_hopelessness_is_red(self, monitor): - """Ukrainian hopelessness triggers RED.""" - result = monitor.classify("Все безнадійно, немає надії") - assert result.state == SpiritualState.RED - - def test_case_insensitive_red_flag(self, monitor): - """Red flag detection is case insensitive.""" - result = monitor.classify("I feel HOPELESS and want to END MY LIFE") - assert result.state == SpiritualState.RED - - @given(st.sampled_from(RED_FLAG_KEYWORDS)) - def test_any_red_flag_keyword_triggers_red(self, keyword): - """Property: Any red flag keyword triggers RED.""" - mock_api = Mock() - monitor = SpiritualMonitor(mock_api) - - message = f"Message containing {keyword} in it" - result = monitor.classify(message) - - assert result.state == SpiritualState.RED - assert result.confidence == 1.0 - - -# ============================================================================= -# Property 9: Conservative Classification -# For any ambiguous message, the system SHALL prefer YELLOW over GREEN. -# Validates: Requirements 5.2 -# ============================================================================= - -class TestConservativeClassification: - """Property 9: Conservative Classification tests.""" - - @pytest.fixture - def monitor(self): - """Create monitor with mocked API client.""" - mock_api = Mock() - return SpiritualMonitor(mock_api) - - def test_classification_error_defaults_to_yellow(self, monitor): - """Classification error defaults to YELLOW (conservative).""" - # Mock API to raise exception - monitor.api.call_spiritual_api = Mock(side_effect=Exception("API Error")) - - # Message without red flag keywords - result = monitor.classify("I'm feeling a bit down today") - - assert result.state == SpiritualState.YELLOW - assert "classification_error" in result.indicators - - def test_parse_error_defaults_to_yellow(self, monitor): - """Parse error defaults to YELLOW (conservative).""" - # Mock API to return invalid JSON - monitor.api.call_spiritual_api = Mock(return_value="invalid json response") - - result = monitor.classify("I'm feeling a bit down today") - - # Should be YELLOW due to keyword detection or conservative default - assert result.state == SpiritualState.YELLOW - - def test_yellow_indicator_triggers_yellow(self, monitor): - """Yellow flag indicator triggers YELLOW classification.""" - # Mock API to return invalid response (triggers fallback) - monitor.api.call_spiritual_api = Mock(return_value="invalid") - - result = monitor.classify("I feel sad and lonely today") - - assert result.state == SpiritualState.YELLOW - # Should detect "sad" or "lonely" as indicators - assert any(ind in ["sad", "lonely"] for ind in result.indicators) or "parse_error" in result.indicators - - @pytest.mark.parametrize("indicator", YELLOW_FLAG_INDICATORS[:10]) - def test_yellow_indicators_detected(self, monitor, indicator): - """Yellow flag indicators are detected.""" - # Test the keyword detection directly - indicators = monitor._check_yellow_indicators(f"I feel {indicator}") - assert indicator in indicators or len(indicators) > 0 - - -# ============================================================================= -# Keyword Detection Unit Tests -# ============================================================================= - -class TestKeywordDetection: - """Unit tests for keyword detection methods.""" - - @pytest.fixture - def monitor(self): - """Create monitor with mocked API client.""" - mock_api = Mock() - return SpiritualMonitor(mock_api) - - def test_check_red_flag_keywords_returns_list(self, monitor): - """_check_red_flag_keywords returns list of matched keywords.""" - result = monitor._check_red_flag_keywords("I want to die and feel hopeless") - - assert isinstance(result, list) - assert len(result) >= 1 - - def test_check_red_flag_keywords_returns_none_for_clean(self, monitor): - """_check_red_flag_keywords returns None for clean messages.""" - result = monitor._check_red_flag_keywords("I had a nice day today") - - assert result is None - - def test_check_yellow_indicators_returns_list(self, monitor): - """_check_yellow_indicators returns list of matched indicators.""" - result = monitor._check_yellow_indicators("I feel sad and anxious") - - assert isinstance(result, list) - assert "sad" in result or "anxious" in result - - def test_check_yellow_indicators_empty_for_clean(self, monitor): - """_check_yellow_indicators returns empty list for clean messages.""" - result = monitor._check_yellow_indicators("The weather is nice today") - - assert isinstance(result, list) - assert len(result) == 0 - - -# ============================================================================= -# LLM Response Parsing Tests -# ============================================================================= - -class TestLLMResponseParsing: - """Tests for LLM response parsing.""" - - @pytest.fixture - def monitor(self): - """Create monitor with mocked API client.""" - mock_api = Mock() - return SpiritualMonitor(mock_api) - - def test_parse_valid_green_response(self, monitor): - """Parse valid GREEN response.""" - response = '{"state": "green", "indicators": [], "confidence": 0.9, "reasoning": "Normal message"}' - - result = monitor._parse_classification_response(response, "test message") - - assert result.state == SpiritualState.GREEN - assert result.confidence == 0.9 - - def test_parse_valid_yellow_response(self, monitor): - """Parse valid YELLOW response.""" - response = '{"state": "yellow", "indicators": ["sadness"], "confidence": 0.7, "reasoning": "Potential distress"}' - - result = monitor._parse_classification_response(response, "test message") - - assert result.state == SpiritualState.YELLOW - assert "sadness" in result.indicators - - def test_parse_valid_red_response(self, monitor): - """Parse valid RED response.""" - response = '{"state": "red", "indicators": ["crisis"], "confidence": 0.95, "reasoning": "Severe distress"}' - - result = monitor._parse_classification_response(response, "test message") - - assert result.state == SpiritualState.RED - - def test_parse_json_with_extra_text(self, monitor): - """Parse JSON embedded in extra text.""" - response = 'Here is my analysis: {"state": "yellow", "indicators": ["anxiety"], "confidence": 0.8, "reasoning": "test"} That is my response.' - - result = monitor._parse_classification_response(response, "test message") - - assert result.state == SpiritualState.YELLOW - - def test_parse_invalid_json_falls_back(self, monitor): - """Invalid JSON falls back to keyword detection.""" - response = "This is not valid JSON at all" - - result = monitor._parse_classification_response(response, "I feel sad") - - # Should fall back to keyword detection or conservative default - assert result.state == SpiritualState.YELLOW - - -# ============================================================================= -# Integration-like Tests (with mocked API) -# ============================================================================= - -class TestMonitorIntegration: - """Integration-like tests with mocked API.""" - - @pytest.fixture - def monitor_with_api(self): - """Create monitor with API that returns valid responses.""" - mock_api = Mock() - mock_api.call_spiritual_api = Mock(return_value='{"state": "green", "indicators": [], "confidence": 0.9, "reasoning": "Normal"}') - return SpiritualMonitor(mock_api) - - def test_classify_calls_api_for_non_red_messages(self, monitor_with_api): - """Classify calls API for messages without red flag keywords.""" - result = monitor_with_api.classify("How are you today?") - - # API should be called - monitor_with_api.api.call_spiritual_api.assert_called_once() - assert result.state == SpiritualState.GREEN - - def test_classify_skips_api_for_red_flag_messages(self): - """Classify skips API call for red flag keyword messages.""" - mock_api = Mock() - monitor = SpiritualMonitor(mock_api) - - result = monitor.classify("I want to kill myself") - - # API should NOT be called - keyword detection is sufficient - mock_api.call_spiritual_api.assert_not_called() - assert result.state == SpiritualState.RED - - def test_classify_with_conversation_history(self, monitor_with_api): - """Classify includes conversation history in context.""" - history = ["User: Hello", "Assistant: Hi there", "User: I'm feeling down"] - - result = monitor_with_api.classify("Can we talk?", history) - - # API should be called with history context - call_args = monitor_with_api.api.call_spiritual_api.call_args - assert "Recent conversation" in call_args[1]["user_prompt"] - - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) diff --git a/tests/test_spiritual_state_properties.py b/tests/test_spiritual_state_properties.py deleted file mode 100644 index 4f68a206ed1c9b29b08fd07a91f577c6383e1250..0000000000000000000000000000000000000000 --- a/tests/test_spiritual_state_properties.py +++ /dev/null @@ -1,318 +0,0 @@ -# test_spiritual_state_properties.py -""" -Property-based tests for Spiritual State Machine. - -Tests the correctness properties defined in the design document: -- Property 8: State Validity -- Property 14: Session Reset - -Requirements: 5.1, 7.1, 7.4 -""" - -import pytest -from hypothesis import given, strategies as st, settings, assume - -from src.core.spiritual_state import ( - SpiritualState, - TriageOutcome, - SpiritualAssessment, - TriageSession, - SessionSpiritualState -) - - -# ============================================================================= -# Property 8: State Validity -# For any point in conversation, the spiritual state SHALL be exactly one of: -# GREEN, YELLOW, or RED. -# Validates: Requirements 5.1, 7.1 -# ============================================================================= - -class TestStateValidity: - """Property 8: State Validity tests.""" - - def test_initial_state_is_green(self): - """New session starts in GREEN state.""" - session = SessionSpiritualState() - assert session.spiritual_state == SpiritualState.GREEN - assert session.get_state() == SpiritualState.GREEN - - def test_state_is_always_valid_enum(self): - """State is always a valid SpiritualState enum value.""" - session = SessionSpiritualState() - - # Test all possible states - for state in SpiritualState: - session.transition_to(state, f"Testing {state.value}") - assert session.spiritual_state in SpiritualState - assert isinstance(session.spiritual_state, SpiritualState) - - @given(st.sampled_from(list(SpiritualState))) - def test_transition_maintains_valid_state(self, target_state): - """Any transition results in a valid state.""" - session = SessionSpiritualState() - session.transition_to(target_state, "test transition") - - assert session.spiritual_state == target_state - assert session.spiritual_state in SpiritualState - - @given( - st.lists( - st.sampled_from(list(SpiritualState)), - min_size=1, - max_size=20 - ) - ) - def test_multiple_transitions_maintain_valid_state(self, transitions): - """Multiple transitions always result in valid state.""" - session = SessionSpiritualState() - - for i, state in enumerate(transitions): - session.transition_to(state, f"transition {i}") - assert session.spiritual_state == state - assert session.spiritual_state in SpiritualState - - def test_state_enum_has_exactly_three_values(self): - """SpiritualState enum has exactly GREEN, YELLOW, RED.""" - states = list(SpiritualState) - assert len(states) == 3 - assert SpiritualState.GREEN in states - assert SpiritualState.YELLOW in states - assert SpiritualState.RED in states - - -# ============================================================================= -# Property 14: Session Reset -# For any session end, the spiritual state SHALL reset to GREEN. -# Validates: Requirements 7.4 -# ============================================================================= - -class TestSessionReset: - """Property 14: Session Reset tests.""" - - def test_reset_returns_to_green(self): - """Reset always returns to GREEN state.""" - session = SessionSpiritualState() - - # Start in non-GREEN state - session.transition_to(SpiritualState.YELLOW, "test") - assert session.spiritual_state == SpiritualState.YELLOW - - # Reset - session.reset() - assert session.spiritual_state == SpiritualState.GREEN - - @given(st.sampled_from(list(SpiritualState))) - def test_reset_from_any_state_returns_green(self, initial_state): - """Reset from any state returns to GREEN.""" - session = SessionSpiritualState() - session.transition_to(initial_state, "initial") - - session.reset() - - assert session.spiritual_state == SpiritualState.GREEN - - def test_reset_clears_triage_session(self): - """Reset clears any active triage session.""" - session = SessionSpiritualState() - - # Enter YELLOW state (creates triage session) - session.transition_to(SpiritualState.YELLOW, "test") - assert session.triage_session is not None - - # Reset - session.reset() - assert session.triage_session is None - - def test_reset_clears_last_assessment(self): - """Reset clears last assessment.""" - session = SessionSpiritualState() - - # Set assessment - session.last_assessment = SpiritualAssessment( - state=SpiritualState.YELLOW, - indicators=["test"], - confidence=0.8, - reasoning="test" - ) - - # Reset - session.reset() - assert session.last_assessment is None - - def test_reset_logs_transition(self): - """Reset logs the reset in state history.""" - session = SessionSpiritualState() - session.transition_to(SpiritualState.RED, "test") - - initial_history_len = len(session.state_history) - session.reset() - - assert len(session.state_history) == initial_history_len + 1 - assert "RESET" in session.state_history[-1] - - -# ============================================================================= -# Triage Session Properties -# Property 5: Triage Question Limit (max 3) -# Property 6: Triage Binary Outcome (GREEN or RED, never YELLOW) -# ============================================================================= - -class TestTriageSessionProperties: - """Triage session property tests.""" - - def test_triage_session_starts_at_zero(self): - """New triage session starts with 0 questions.""" - session = TriageSession() - assert session.question_count == 0 - assert len(session.questions_asked) == 0 - assert len(session.patient_responses) == 0 - - def test_add_exchange_increments_count(self): - """Adding exchange increments question count.""" - session = TriageSession() - - session.add_exchange("Question 1?", "Response 1") - assert session.question_count == 1 - - session.add_exchange("Question 2?", "Response 2") - assert session.question_count == 2 - - def test_is_at_limit_after_three_questions(self): - """is_at_limit returns True after 3 questions.""" - session = TriageSession() - - assert not session.is_at_limit() - - session.add_exchange("Q1?", "R1") - assert not session.is_at_limit() - - session.add_exchange("Q2?", "R2") - assert not session.is_at_limit() - - session.add_exchange("Q3?", "R3") - assert session.is_at_limit() - - @given(st.integers(min_value=0, max_value=10)) - def test_is_at_limit_threshold(self, num_questions): - """is_at_limit is True iff question_count >= 3.""" - session = TriageSession() - session.question_count = num_questions - - expected = num_questions >= 3 - assert session.is_at_limit() == expected - - def test_triage_outcome_is_binary(self): - """TriageOutcome has exactly 3 values including CONTINUE.""" - outcomes = list(TriageOutcome) - assert len(outcomes) == 3 - assert TriageOutcome.RESOLVED_GREEN in outcomes - assert TriageOutcome.ESCALATE_RED in outcomes - assert TriageOutcome.CONTINUE in outcomes - - -# ============================================================================= -# State Transition Properties -# ============================================================================= - -class TestStateTransitionProperties: - """State transition property tests.""" - - def test_yellow_creates_triage_session(self): - """Transitioning to YELLOW creates triage session.""" - session = SessionSpiritualState() - assert session.triage_session is None - - session.transition_to(SpiritualState.YELLOW, "test") - assert session.triage_session is not None - assert isinstance(session.triage_session, TriageSession) - - def test_leaving_yellow_clears_triage_session(self): - """Transitioning from YELLOW clears triage session.""" - session = SessionSpiritualState() - - # Enter YELLOW - session.transition_to(SpiritualState.YELLOW, "enter") - assert session.triage_session is not None - - # Exit to GREEN - session.transition_to(SpiritualState.GREEN, "exit") - assert session.triage_session is None - - def test_yellow_to_red_clears_triage_session(self): - """Transitioning from YELLOW to RED clears triage session.""" - session = SessionSpiritualState() - - session.transition_to(SpiritualState.YELLOW, "enter") - assert session.triage_session is not None - - session.transition_to(SpiritualState.RED, "escalate") - assert session.triage_session is None - - def test_is_in_triage_only_in_yellow(self): - """is_in_triage returns True only in YELLOW state.""" - session = SessionSpiritualState() - - assert not session.is_in_triage() # GREEN - - session.transition_to(SpiritualState.YELLOW, "test") - assert session.is_in_triage() - - session.transition_to(SpiritualState.RED, "test") - assert not session.is_in_triage() - - session.transition_to(SpiritualState.GREEN, "test") - assert not session.is_in_triage() - - @given(st.sampled_from(list(SpiritualState))) - def test_is_in_triage_matches_yellow_state(self, state): - """is_in_triage matches whether state is YELLOW.""" - session = SessionSpiritualState() - session.transition_to(state, "test") - - expected = state == SpiritualState.YELLOW - assert session.is_in_triage() == expected - - -# ============================================================================= -# SpiritualAssessment Properties -# ============================================================================= - -class TestSpiritualAssessmentProperties: - """SpiritualAssessment dataclass property tests.""" - - @given( - st.sampled_from(list(SpiritualState)), - st.lists(st.text(min_size=1, max_size=50), max_size=5), - st.floats(min_value=0.0, max_value=1.0), - st.text(max_size=200) - ) - @settings(max_examples=50) - def test_assessment_stores_all_fields(self, state, indicators, confidence, reasoning): - """Assessment correctly stores all provided fields.""" - assume(all(ind.strip() for ind in indicators)) # Non-empty indicators - - assessment = SpiritualAssessment( - state=state, - indicators=indicators, - confidence=confidence, - reasoning=reasoning - ) - - assert assessment.state == state - assert assessment.indicators == indicators - assert assessment.confidence == confidence - assert assessment.reasoning == reasoning - - def test_assessment_default_values(self): - """Assessment has sensible defaults.""" - assessment = SpiritualAssessment(state=SpiritualState.GREEN) - - assert assessment.state == SpiritualState.GREEN - assert assessment.indicators == [] - assert assessment.confidence == 0.0 - assert assessment.reasoning == "" - - -if __name__ == "__main__": - pytest.main([__file__, "-v"])