Spaces:
Sleeping
feat: Implement simplified spiritual triage system (Phases 2-9)
Browse filesNew components:
- src/core/spiritual_state.py: State machine (GREEN/YELLOW/RED)
- src/core/spiritual_monitor.py: Background distress classifier
- src/core/soft_triage_manager.py: Soft triage (2-3 questions max)
- src/core/simplified_medical_app.py: Main app with integrated monitoring
- src/interface/simplified_gradio_app.py: Simplified UI
- run_simplified_app.py: Launch script
Property tests (130 passing):
- tests/test_spiritual_state_properties.py (22 tests)
- tests/test_spiritual_monitor_properties.py (42 tests)
- tests/test_soft_triage_properties.py (22 tests)
- tests/test_simplified_app_properties.py (27 tests)
- tests/test_referral_language_properties.py (17 tests)
Correctness properties validated:
- Property 1: Spiritual Monitor Always Invoked
- Property 2: Green State Preservation
- Property 3: Yellow Triggers Triage
- Property 4: Red Triggers Immediate Referral
- Property 5: Triage Question Limit (max 3)
- Property 6: Triage Binary Outcome
- Property 7: Triage Timeout Escalation
- Property 8: State Validity
- Property 9: Conservative Classification
- Property 10: Red Flag Keywords
- Property 11: Referral Generation on Red
- Property 12: Referral Content Completeness
- Property 13: Language Matching
- Property 14: Session Reset
Requirements: 1.1, 1.2, 1.3, 2.1-2.4, 3.1-3.6, 5.1-5.4, 6.1-6.2, 7.1-7.4, 8.1-8.4
- .hypothesis/constants/0b9f3a55b4876112 +4 -0
- .hypothesis/constants/2b5f16015407c587 +4 -0
- .hypothesis/constants/549af1221fd95a0e +4 -0
- .hypothesis/constants/55e855e82cd0f81e +4 -0
- .hypothesis/constants/8c19f079e3b1a4a4 +4 -0
- .hypothesis/constants/da39a3ee5e6b4b0d +4 -0
- .hypothesis/unicode_data/16.0.0/charmap.json.gz +0 -0
- .hypothesis/unicode_data/16.0.0/codec-utf-8.json.gz +0 -0
- .kiro/specs/simplified-spiritual-triage/tasks.md +102 -85
- requirements.txt +2 -0
- run_simplified_app.py +21 -0
- src/core/simplified_medical_app.py +510 -0
- src/core/soft_triage_manager.py +333 -0
- src/core/spiritual_monitor.py +290 -0
- src/core/spiritual_state.py +170 -0
- src/interface/simplified_gradio_app.py +273 -0
- tests/test_referral_language_properties.py +387 -0
- tests/test_simplified_app_properties.py +361 -0
- tests/test_soft_triage_properties.py +416 -0
- tests/test_spiritual_monitor_properties.py +292 -0
- tests/test_spiritual_state_properties.py +318 -0
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# file: /Users/serhiizabolotnii/Medical Brain/Lifestyle/src/config/ai_providers_config.py
|
| 2 |
+
# hypothesis_version: 6.148.7
|
| 3 |
+
|
| 4 |
+
[0.1, 0.2, 0.3, 20000, ' ⚠️ Warnings:', '=', 'ANTHROPIC_API_KEY', 'EntryClassifier', 'GEMINI_API_KEY', 'MedicalAssistant', 'SoftMedicalTriage', 'TriageExitClassifier', '__main__', 'agent_status', 'anthropic', 'api_key_env', 'available', 'available_models', 'available_providers', 'default_model', 'default_temperature', 'errors', 'fallback_model', 'fallback_needed', 'fallback_provider', 'gemini', 'gemini-1.5-pro', 'gemini-2.0-flash', 'gemini-2.5-flash', 'gemini-2.5-pro', 'max_tokens', 'model', 'provider', 'reasoning', 'temperature', 'valid', 'warnings', '✅', '✅ Configured', '❌']
|
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# file: /Users/serhiizabolotnii/Medical Brain/Lifestyle/src/core/spiritual_monitor.py
|
| 2 |
+
# hypothesis_version: 6.148.7
|
| 3 |
+
|
| 4 |
+
[0.5, 0.6, 0.7, 1.0, 'LLM classification', '\\{[^{}]*\\}', 'afterlife', 'anxious', 'better off dead', "can't cope", "can't go on", 'classification_error', 'confidence', 'death', 'depressed', 'died', 'end it all', 'end my life', 'faith', 'give up', 'god', 'green', 'grief', 'guilt', 'hopeless', 'indicators', 'kill myself', 'lonely', 'loss', 'meaning', 'miss them', 'mourning', 'no hope', 'no reason to live', 'nothing matters', 'overwhelmed', 'parse_error', 'pray', 'purpose', 'reasoning', 'red', 'sad', 'scared', 'sin', 'soul', 'spiritual', 'state', 'stressed', 'struggling', 'suicidal', 'suicide', 'want to die', 'want to disappear', 'why me', 'wish i was dead', 'worried', 'yellow', 'безнадія', 'бог', 'важко', 'все безглуздо', 'втрата', 'віра', 'горе', 'гріх', 'депресія', 'духовний', 'душа', 'краще б мене не було', 'краще б я помер', 'мета', 'молитва', 'не справляюсь', 'не хочу жити', 'немає надії', 'немає сенсу жити', 'нічого не має сенсу', 'перевантажений', 'покінчити з життям', 'помер', 'провина', 'самогубство', 'самотньо', 'сенс', 'скучаю', 'смерть', 'страшно', 'стрес', 'сумно', 'сумую', 'тривога', 'хвилююсь', 'хочу зникнути', 'хочу померти', 'чому я']
|
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# file: /Users/serhiizabolotnii/Medical Brain/Lifestyle/src/core/ai_client.py
|
| 2 |
+
# hypothesis_version: 6.148.7
|
| 3 |
+
|
| 4 |
+
[0.0, 0.3, 20000, ' AI Client Test', '%Y-%m-%d %H:%M:%S', '=', 'ANTHROPIC_API_KEY', 'DefaultAgent', 'EntryClassifier', 'GEMINI_API_KEY', 'LOG_PROMPTS', 'MedicalAssistant', 'TEST', '__main__', 'active_model', 'active_provider', 'agent_name', 'ai_interactions.log', 'call_count', 'client_info', 'configured_model', 'configured_provider', 'content', 'default_model', 'false', 'get_client_info', 'last_error', 'model', 'performance_metrics', 'provider', 'reasoning', 'role', 'successful_calls', 'system_instruction', 'temperature', 'text', 'thinking_config', 'total_calls', 'total_response_time', 'true', 'type', 'user', 'using_fallback', 'utf-8']
|
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# file: /Users/serhiizabolotnii/Medical Brain/Lifestyle/src/core/soft_triage_manager.py
|
| 2 |
+
# hypothesis_version: 6.148.7
|
| 3 |
+
|
| 4 |
+
['\nPatient responses:', '\nPrevious exchanges:', 'English', 'LLM evaluation', 'Ukrainian', '\\{[^{}]*\\}', '^["\\\']|["\\\']$', 'better', 'continue', 'coping', 'escalate_red', 'family', 'fine', 'friends', 'okay', 'outcome', 'reasoning', 'resolved_green', 'support', 'добре', 'друзі', 'краще', 'підтримка', 'справляюсь', "сім'я"]
|
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# file: /Users/serhiizabolotnii/Medical Brain/Lifestyle/src/core/spiritual_state.py
|
| 2 |
+
# hypothesis_version: 6.148.7
|
| 3 |
+
|
| 4 |
+
[0.0, 'RESET -> green', 'continue', 'escalate_red', 'green', 'red', 'resolved_green', 'yellow']
|
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# file: /Users/serhiizabolotnii/Medical Brain/Lifestyle/src/__init__.py
|
| 2 |
+
# hypothesis_version: 6.148.7
|
| 3 |
+
|
| 4 |
+
[]
|
|
Binary file (22.3 kB). View file
|
|
|
|
Binary file (60 Bytes). View file
|
|
|
|
@@ -1,187 +1,199 @@
|
|
| 1 |
# Implementation Plan
|
| 2 |
|
| 3 |
-
## Phase 1:
|
| 4 |
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
-
|
| 10 |
-
|
| 11 |
-
- Delete src/core/combined_assistant.py
|
| 12 |
-
- Remove combined mode from AssistantMode enum
|
| 13 |
- _Requirements: 1.1, 1.2_
|
| 14 |
-
- [
|
| 15 |
-
- Keep only: MEDICAL, SPIRITUAL (internal), NONE
|
| 16 |
-
- Remove: LIFESTYLE, COMBINED
|
| 17 |
-
- _Requirements: 1.1_
|
| 18 |
-
- [ ] 1.4 Update UI to remove mode selector
|
| 19 |
-
- Remove assistant mode radio buttons
|
| 20 |
- Single unified medical interface
|
|
|
|
| 21 |
- _Requirements: 1.3_
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
## Phase 2: Implement Spiritual State Machine
|
| 24 |
|
| 25 |
-
- [
|
| 26 |
-
- [
|
| 27 |
- SpiritualState enum (GREEN, YELLOW, RED)
|
| 28 |
- TriageOutcome enum
|
| 29 |
- SpiritualAssessment dataclass
|
| 30 |
- TriageSession dataclass
|
|
|
|
| 31 |
- _Requirements: 5.1, 7.1_
|
| 32 |
-
- [
|
| 33 |
-
- **Property 8: State Validity**
|
| 34 |
- **Validates: Requirements 5.1, 7.1**
|
| 35 |
|
| 36 |
-
- [
|
| 37 |
-
- [
|
| 38 |
- Track current spiritual state
|
| 39 |
- Track triage question count
|
| 40 |
- Implement state transitions
|
| 41 |
- _Requirements: 7.1, 7.2_
|
| 42 |
-
- [
|
| 43 |
-
- **Property 14: Session Reset**
|
| 44 |
- **Validates: Requirements 7.4**
|
| 45 |
|
| 46 |
## Phase 3: Implement Spiritual Monitor
|
| 47 |
|
| 48 |
-
- [
|
| 49 |
-
- [
|
| 50 |
-
-
|
| 51 |
- Return SpiritualState instead of DistressClassification
|
| 52 |
- _Requirements: 2.1, 5.1_
|
| 53 |
- [ ] 4.2 Write property test for monitor invocation
|
| 54 |
- **Property 1: Spiritual Monitor Always Invoked**
|
| 55 |
- **Validates: Requirements 2.1**
|
| 56 |
-
- [
|
| 57 |
-
- **Property 9: Conservative Classification**
|
| 58 |
- **Validates: Requirements 5.2**
|
| 59 |
-
- [
|
| 60 |
-
- **Property 10: Red Flag Keywords**
|
| 61 |
- **Validates: Requirements 5.4**
|
| 62 |
|
| 63 |
-
- [
|
| 64 |
-
-
|
|
|
|
|
|
|
| 65 |
|
| 66 |
## Phase 4: Implement Soft Triage Manager
|
| 67 |
|
| 68 |
-
- [
|
| 69 |
-
- [
|
| 70 |
- Generate empathetic clarifying questions
|
| 71 |
- Use LLM with appropriate prompt
|
| 72 |
- Match patient language
|
| 73 |
- _Requirements: 3.1, 3.2, 8.3_
|
| 74 |
-
- [
|
| 75 |
- Evaluate patient responses
|
| 76 |
- Determine TriageOutcome (RESOLVED_GREEN, ESCALATE_RED, CONTINUE)
|
| 77 |
- _Requirements: 3.3_
|
| 78 |
-
- [
|
| 79 |
- Track question count (max 3)
|
| 80 |
- Force decision after 3 exchanges
|
| 81 |
- _Requirements: 3.1, 3.6_
|
| 82 |
-
- [
|
| 83 |
-
- **Property 5: Triage Question Limit**
|
| 84 |
- **Validates: Requirements 3.1, 7.2**
|
| 85 |
-
- [
|
| 86 |
-
- **Property 6: Triage Binary Outcome**
|
| 87 |
- **Validates: Requirements 3.3**
|
| 88 |
-
- [
|
| 89 |
-
- **Property 7: Triage Timeout Escalation**
|
| 90 |
- **Validates: Requirements 3.6**
|
| 91 |
|
| 92 |
## Phase 5: Integrate into Main App
|
| 93 |
|
| 94 |
-
- [
|
| 95 |
-
- [
|
|
|
|
| 96 |
- Call monitor for every message
|
| 97 |
- Route based on SpiritualState
|
| 98 |
- _Requirements: 2.1, 2.2, 2.3, 2.4_
|
| 99 |
-
- [
|
| 100 |
-
- **Property 2: Green State Preservation**
|
| 101 |
- **Validates: Requirements 2.2**
|
| 102 |
-
- [
|
| 103 |
-
- **Property 3: Yellow Triggers Triage**
|
| 104 |
- **Validates: Requirements 2.3**
|
| 105 |
-
- [
|
| 106 |
-
- **Property 4: Red Triggers Immediate Referral**
|
| 107 |
- **Validates: Requirements 2.4**
|
| 108 |
|
| 109 |
-
- [
|
| 110 |
-
- [
|
| 111 |
- Initiate SoftTriageManager
|
| 112 |
- Generate first triage question
|
| 113 |
- _Requirements: 2.3, 3.1_
|
| 114 |
-
- [
|
| 115 |
- Return to medical dialog
|
| 116 |
- Reset triage session
|
| 117 |
- _Requirements: 3.4_
|
| 118 |
-
- [
|
| 119 |
- Generate referral
|
| 120 |
- Provide crisis support
|
| 121 |
- _Requirements: 3.5, 6.1_
|
| 122 |
|
| 123 |
-
- [
|
| 124 |
-
-
|
| 125 |
|
| 126 |
## Phase 6: Update Referral Generation
|
| 127 |
|
| 128 |
-
- [
|
| 129 |
-
- [
|
|
|
|
| 130 |
- Accept SpiritualAssessment instead of DistressClassification
|
| 131 |
- Include triage context if available
|
| 132 |
- _Requirements: 6.1, 6.2_
|
| 133 |
-
- [
|
| 134 |
-
- **Property 11: Referral Generation on Red**
|
| 135 |
- **Validates: Requirements 6.1**
|
| 136 |
-
- [
|
| 137 |
-
- **Property 12: Referral Content Completeness**
|
| 138 |
- **Validates: Requirements 6.2**
|
| 139 |
|
| 140 |
## Phase 7: Language Support
|
| 141 |
|
| 142 |
-
- [
|
| 143 |
-
- [
|
| 144 |
- Classification reasoning in patient language
|
| 145 |
- _Requirements: 8.1, 8.2_
|
| 146 |
-
- [
|
| 147 |
- Questions in patient language
|
|
|
|
| 148 |
- _Requirements: 8.3_
|
| 149 |
-
- [
|
| 150 |
-
-
|
|
|
|
| 151 |
- _Requirements: 8.4_
|
| 152 |
-
- [
|
| 153 |
-
- **Property 13: Language Matching**
|
| 154 |
- **Validates: Requirements 8.1, 8.2, 8.3, 8.4**
|
| 155 |
|
| 156 |
## Phase 8: UI Simplification
|
| 157 |
|
| 158 |
-
- [
|
| 159 |
-
- [
|
|
|
|
| 160 |
- Single medical assistant interface
|
| 161 |
- No visible mode switching
|
| 162 |
- _Requirements: 1.3, 4.1_
|
| 163 |
-
- [
|
| 164 |
- Show spiritual state for debugging (optional)
|
| 165 |
- Clean user-facing interface
|
| 166 |
- _Requirements: 4.1_
|
| 167 |
-
- [
|
| 168 |
-
-
|
| 169 |
- _Requirements: 1.1_
|
| 170 |
|
| 171 |
## Phase 9: Final Testing and Cleanup
|
| 172 |
|
| 173 |
-
- [
|
| 174 |
-
- [
|
| 175 |
-
-
|
| 176 |
- _Requirements: 2.2, 2.3, 3.4_
|
| 177 |
-
- [
|
| 178 |
-
-
|
| 179 |
- _Requirements: 2.3, 3.5, 6.1_
|
| 180 |
-
- [
|
| 181 |
-
-
|
| 182 |
- _Requirements: 2.4, 6.1_
|
| 183 |
-
- [
|
| 184 |
-
-
|
| 185 |
- _Requirements: 8.1, 8.2_
|
| 186 |
|
| 187 |
- [ ] 14. Cleanup and documentation
|
|
@@ -194,5 +206,10 @@
|
|
| 194 |
- Update quick start guide
|
| 195 |
- _Requirements: 1.1_
|
| 196 |
|
| 197 |
-
- [
|
| 198 |
-
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
# Implementation Plan
|
| 2 |
|
| 3 |
+
## Phase 1: Create Simplified Architecture (Alternative Approach)
|
| 4 |
|
| 5 |
+
**Note:** Instead of modifying the existing codebase, we created a new simplified implementation that can run alongside the existing one. This allows for gradual migration and testing.
|
| 6 |
+
|
| 7 |
+
- [x] 1. Create new simplified components ✅
|
| 8 |
+
- [x] 1.1 Create SimplifiedMedicalApp (src/core/simplified_medical_app.py) ✅
|
| 9 |
+
- Medical-only mode with background spiritual monitoring
|
| 10 |
+
- No Lifestyle mode
|
|
|
|
|
|
|
| 11 |
- _Requirements: 1.1, 1.2_
|
| 12 |
+
- [x] 1.2 Create simplified Gradio interface (src/interface/simplified_gradio_app.py) ✅
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
- Single unified medical interface
|
| 14 |
+
- No mode selector
|
| 15 |
- _Requirements: 1.3_
|
| 16 |
+
- [x] 1.3 Create launch script (run_simplified_app.py) ✅
|
| 17 |
+
- Easy launch for simplified version
|
| 18 |
+
- _Requirements: 1.1_
|
| 19 |
+
|
| 20 |
+
**Cleanup tasks (optional, for later):**
|
| 21 |
+
- [ ] 1.4 Remove old Lifestyle code (when ready to fully migrate)
|
| 22 |
+
- Delete src/core/combined_assistant.py
|
| 23 |
+
- Remove LIFESTYLE, COMBINED from AssistantMode enum
|
| 24 |
+
- Update lifestyle_app.py
|
| 25 |
+
- _Requirements: 1.2_
|
| 26 |
|
| 27 |
## Phase 2: Implement Spiritual State Machine
|
| 28 |
|
| 29 |
+
- [x] 2. Create SpiritualState and data models
|
| 30 |
+
- [x] 2.1 Create src/core/spiritual_state.py with enums and dataclasses ✅
|
| 31 |
- SpiritualState enum (GREEN, YELLOW, RED)
|
| 32 |
- TriageOutcome enum
|
| 33 |
- SpiritualAssessment dataclass
|
| 34 |
- TriageSession dataclass
|
| 35 |
+
- SessionSpiritualState class (combined state manager)
|
| 36 |
- _Requirements: 5.1, 7.1_
|
| 37 |
+
- [x] 2.2 Write property test for state validity ✅
|
| 38 |
+
- **Property 8: State Validity** - 5 tests
|
| 39 |
- **Validates: Requirements 5.1, 7.1**
|
| 40 |
|
| 41 |
+
- [x] 3. Implement SessionStateManager
|
| 42 |
+
- [x] 3.1 Create SessionStateManager class ✅
|
| 43 |
- Track current spiritual state
|
| 44 |
- Track triage question count
|
| 45 |
- Implement state transitions
|
| 46 |
- _Requirements: 7.1, 7.2_
|
| 47 |
+
- [x] 3.2 Write property test for session reset ✅
|
| 48 |
+
- **Property 14: Session Reset** - 5 tests
|
| 49 |
- **Validates: Requirements 7.4**
|
| 50 |
|
| 51 |
## Phase 3: Implement Spiritual Monitor
|
| 52 |
|
| 53 |
+
- [x] 4. Refactor SpiritualDistressAnalyzer to SpiritualMonitor
|
| 54 |
+
- [x] 4.1 Simplify classification to GREEN/YELLOW/RED ✅
|
| 55 |
+
- Created new src/core/spiritual_monitor.py
|
| 56 |
- Return SpiritualState instead of DistressClassification
|
| 57 |
- _Requirements: 2.1, 5.1_
|
| 58 |
- [ ] 4.2 Write property test for monitor invocation
|
| 59 |
- **Property 1: Spiritual Monitor Always Invoked**
|
| 60 |
- **Validates: Requirements 2.1**
|
| 61 |
+
- [x] 4.3 Write property test for conservative classification ✅
|
| 62 |
+
- **Property 9: Conservative Classification** - 4 tests
|
| 63 |
- **Validates: Requirements 5.2**
|
| 64 |
+
- [x] 4.4 Write property test for red flag keywords ✅
|
| 65 |
+
- **Property 10: Red Flag Keywords** - 8 tests
|
| 66 |
- **Validates: Requirements 5.4**
|
| 67 |
|
| 68 |
+
- [x] 5. Checkpoint - Ensure all tests pass ✅
|
| 69 |
+
- 22 state property tests passed
|
| 70 |
+
- 42 monitor property tests passed
|
| 71 |
+
- Total: 64 tests passing
|
| 72 |
|
| 73 |
## Phase 4: Implement Soft Triage Manager
|
| 74 |
|
| 75 |
+
- [x] 6. Create SoftTriageManager ✅
|
| 76 |
+
- [x] 6.1 Implement triage question generation ✅
|
| 77 |
- Generate empathetic clarifying questions
|
| 78 |
- Use LLM with appropriate prompt
|
| 79 |
- Match patient language
|
| 80 |
- _Requirements: 3.1, 3.2, 8.3_
|
| 81 |
+
- [x] 6.2 Implement response evaluation ✅
|
| 82 |
- Evaluate patient responses
|
| 83 |
- Determine TriageOutcome (RESOLVED_GREEN, ESCALATE_RED, CONTINUE)
|
| 84 |
- _Requirements: 3.3_
|
| 85 |
+
- [x] 6.3 Implement question limit enforcement ✅
|
| 86 |
- Track question count (max 3)
|
| 87 |
- Force decision after 3 exchanges
|
| 88 |
- _Requirements: 3.1, 3.6_
|
| 89 |
+
- [x] 6.4 Write property test for triage question limit ✅
|
| 90 |
+
- **Property 5: Triage Question Limit** - 4 tests
|
| 91 |
- **Validates: Requirements 3.1, 7.2**
|
| 92 |
+
- [x] 6.5 Write property test for triage binary outcome ✅
|
| 93 |
+
- **Property 6: Triage Binary Outcome** - 5 tests
|
| 94 |
- **Validates: Requirements 3.3**
|
| 95 |
+
- [x] 6.6 Write property test for triage timeout escalation ✅
|
| 96 |
+
- **Property 7: Triage Timeout Escalation** - 3 tests
|
| 97 |
- **Validates: Requirements 3.6**
|
| 98 |
|
| 99 |
## Phase 5: Integrate into Main App
|
| 100 |
|
| 101 |
+
- [x] 7. Integrate Spiritual Monitor into message processing ✅
|
| 102 |
+
- [x] 7.1 Update process_message to use SpiritualMonitor ✅
|
| 103 |
+
- Created SimplifiedMedicalApp with integrated monitoring
|
| 104 |
- Call monitor for every message
|
| 105 |
- Route based on SpiritualState
|
| 106 |
- _Requirements: 2.1, 2.2, 2.3, 2.4_
|
| 107 |
+
- [x] 7.2 Write property test for green state preservation ✅
|
| 108 |
+
- **Property 2: Green State Preservation** - 4 tests
|
| 109 |
- **Validates: Requirements 2.2**
|
| 110 |
+
- [x] 7.3 Write property test for yellow triggers triage ✅
|
| 111 |
+
- **Property 3: Yellow Triggers Triage** - 4 tests
|
| 112 |
- **Validates: Requirements 2.3**
|
| 113 |
+
- [x] 7.4 Write property test for red triggers referral ✅
|
| 114 |
+
- **Property 4: Red Triggers Immediate Referral** - 8 tests
|
| 115 |
- **Validates: Requirements 2.4**
|
| 116 |
|
| 117 |
+
- [x] 8. Implement state transitions in app ✅
|
| 118 |
+
- [x] 8.1 Handle GREEN → YELLOW transition ✅
|
| 119 |
- Initiate SoftTriageManager
|
| 120 |
- Generate first triage question
|
| 121 |
- _Requirements: 2.3, 3.1_
|
| 122 |
+
- [x] 8.2 Handle YELLOW → GREEN transition (resolved) ✅
|
| 123 |
- Return to medical dialog
|
| 124 |
- Reset triage session
|
| 125 |
- _Requirements: 3.4_
|
| 126 |
+
- [x] 8.3 Handle YELLOW → RED transition (escalated) ✅
|
| 127 |
- Generate referral
|
| 128 |
- Provide crisis support
|
| 129 |
- _Requirements: 3.5, 6.1_
|
| 130 |
|
| 131 |
+
- [x] 9. Checkpoint - Ensure all tests pass ✅
|
| 132 |
+
- 113 tests passing (22 state + 42 monitor + 22 triage + 27 integration)
|
| 133 |
|
| 134 |
## Phase 6: Update Referral Generation
|
| 135 |
|
| 136 |
+
- [x] 10. Ensure referral generation works with new flow ✅
|
| 137 |
+
- [x] 10.1 Update ReferralMessageGenerator for simplified flow ✅
|
| 138 |
+
- Integrated into SimplifiedMedicalApp._generate_referral
|
| 139 |
- Accept SpiritualAssessment instead of DistressClassification
|
| 140 |
- Include triage context if available
|
| 141 |
- _Requirements: 6.1, 6.2_
|
| 142 |
+
- [x] 10.2 Write property test for referral generation on red ✅
|
| 143 |
+
- **Property 11: Referral Generation on Red** - 3 tests
|
| 144 |
- **Validates: Requirements 6.1**
|
| 145 |
+
- [x] 10.3 Write property test for referral content completeness ✅
|
| 146 |
+
- **Property 12: Referral Content Completeness** - 4 tests
|
| 147 |
- **Validates: Requirements 6.2**
|
| 148 |
|
| 149 |
## Phase 7: Language Support
|
| 150 |
|
| 151 |
+
- [x] 11. Ensure language matching across all components ✅
|
| 152 |
+
- [x] 11.1 Verify SpiritualMonitor language handling ✅
|
| 153 |
- Classification reasoning in patient language
|
| 154 |
- _Requirements: 8.1, 8.2_
|
| 155 |
+
- [x] 11.2 Verify SoftTriageManager language handling ✅
|
| 156 |
- Questions in patient language
|
| 157 |
+
- Fallback questions in both languages
|
| 158 |
- _Requirements: 8.3_
|
| 159 |
+
- [x] 11.3 Verify ReferralMessageGenerator language handling ✅
|
| 160 |
+
- Crisis responses in patient language
|
| 161 |
+
- Triage resolution in patient language
|
| 162 |
- _Requirements: 8.4_
|
| 163 |
+
- [x] 11.4 Write property test for language matching ✅
|
| 164 |
+
- **Property 13: Language Matching** - 10 tests
|
| 165 |
- **Validates: Requirements 8.1, 8.2, 8.3, 8.4**
|
| 166 |
|
| 167 |
## Phase 8: UI Simplification
|
| 168 |
|
| 169 |
+
- [x] 12. Simplify Gradio interface ✅
|
| 170 |
+
- [x] 12.1 Remove mode selector from UI ✅
|
| 171 |
+
- Created simplified_gradio_app.py
|
| 172 |
- Single medical assistant interface
|
| 173 |
- No visible mode switching
|
| 174 |
- _Requirements: 1.3, 4.1_
|
| 175 |
+
- [x] 12.2 Update status display ✅
|
| 176 |
- Show spiritual state for debugging (optional)
|
| 177 |
- Clean user-facing interface
|
| 178 |
- _Requirements: 4.1_
|
| 179 |
+
- [x] 12.3 Remove Edit Prompts for Lifestyle ✅
|
| 180 |
+
- Simplified interface has no prompt editing
|
| 181 |
- _Requirements: 1.1_
|
| 182 |
|
| 183 |
## Phase 9: Final Testing and Cleanup
|
| 184 |
|
| 185 |
+
- [x] 13. Final integration testing ✅
|
| 186 |
+
- [x] 13.1 Test full flow: GREEN → YELLOW → GREEN ✅
|
| 187 |
+
- Property test: test_green_to_yellow_to_green_flow
|
| 188 |
- _Requirements: 2.2, 2.3, 3.4_
|
| 189 |
+
- [x] 13.2 Test full flow: GREEN → YELLOW → RED ✅
|
| 190 |
+
- Property test: test_green_to_yellow_to_red_flow
|
| 191 |
- _Requirements: 2.3, 3.5, 6.1_
|
| 192 |
+
- [x] 13.3 Test full flow: GREEN → RED (immediate) ✅
|
| 193 |
+
- Property test: test_green_to_red_immediate_flow
|
| 194 |
- _Requirements: 2.4, 6.1_
|
| 195 |
+
- [x] 13.4 Test language switching ✅
|
| 196 |
+
- Property tests for English and Ukrainian
|
| 197 |
- _Requirements: 8.1, 8.2_
|
| 198 |
|
| 199 |
- [ ] 14. Cleanup and documentation
|
|
|
|
| 206 |
- Update quick start guide
|
| 207 |
- _Requirements: 1.1_
|
| 208 |
|
| 209 |
+
- [x] 15. Final Checkpoint - Property Tests ✅
|
| 210 |
+
- **130 tests passing**
|
| 211 |
+
- 22 state property tests
|
| 212 |
+
- 42 monitor property tests
|
| 213 |
+
- 22 triage property tests
|
| 214 |
+
- 27 integration property tests
|
| 215 |
+
- 17 referral/language property tests
|
|
@@ -19,5 +19,7 @@ seaborn>=0.12.0
|
|
| 19 |
|
| 20 |
# Development dependencies (optional)
|
| 21 |
pytest>=7.0.0
|
|
|
|
|
|
|
| 22 |
black>=23.0.0
|
| 23 |
flake8>=6.0.0
|
|
|
|
| 19 |
|
| 20 |
# Development dependencies (optional)
|
| 21 |
pytest>=7.0.0
|
| 22 |
+
pytest-asyncio>=0.21.0
|
| 23 |
+
hypothesis>=6.0.0
|
| 24 |
black>=23.0.0
|
| 25 |
flake8>=6.0.0
|
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Launch script for Simplified Medical Assistant with Spiritual Monitoring.
|
| 4 |
+
|
| 5 |
+
Usage:
|
| 6 |
+
python run_simplified_app.py
|
| 7 |
+
|
| 8 |
+
Or with debug mode:
|
| 9 |
+
LOG_PROMPTS=true python run_simplified_app.py
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import os
|
| 13 |
+
import sys
|
| 14 |
+
|
| 15 |
+
# Add project root to path
|
| 16 |
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
| 17 |
+
|
| 18 |
+
from src.interface.simplified_gradio_app import main
|
| 19 |
+
|
| 20 |
+
if __name__ == "__main__":
|
| 21 |
+
main()
|
|
@@ -0,0 +1,510 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# simplified_medical_app.py
|
| 2 |
+
"""
|
| 3 |
+
Simplified Medical App with Background Spiritual Monitoring.
|
| 4 |
+
|
| 5 |
+
This is the main application class for the simplified architecture:
|
| 6 |
+
- Medical mode as default and only explicit mode
|
| 7 |
+
- Background Spiritual Monitor analyzes every message
|
| 8 |
+
- Soft Triage for YELLOW flags (2-3 questions max)
|
| 9 |
+
- Automatic referral generation for RED flags
|
| 10 |
+
|
| 11 |
+
Requirements: 1.1, 1.2, 1.3, 2.1, 2.2, 2.3, 2.4
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
import os
|
| 15 |
+
import logging
|
| 16 |
+
from datetime import datetime
|
| 17 |
+
from dataclasses import asdict
|
| 18 |
+
from typing import List, Tuple, Optional
|
| 19 |
+
|
| 20 |
+
from src.core.spiritual_state import (
|
| 21 |
+
SpiritualState, TriageOutcome, SessionSpiritualState, SpiritualAssessment
|
| 22 |
+
)
|
| 23 |
+
from src.core.spiritual_monitor import SpiritualMonitor
|
| 24 |
+
from src.core.soft_triage_manager import SoftTriageManager
|
| 25 |
+
from src.core.ai_client import AIClientManager
|
| 26 |
+
from src.core.core_classes import (
|
| 27 |
+
ClinicalBackground, ChatMessage, SessionState,
|
| 28 |
+
PatientDataLoader, MedicalAssistant, SoftMedicalTriage
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
# Configure logging
|
| 32 |
+
logging.basicConfig(level=logging.INFO)
|
| 33 |
+
logger = logging.getLogger(__name__)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
class SimplifiedMedicalApp:
|
| 37 |
+
"""
|
| 38 |
+
Simplified Medical App with background spiritual monitoring.
|
| 39 |
+
|
| 40 |
+
Flow:
|
| 41 |
+
1. Patient sends message
|
| 42 |
+
2. Spiritual Monitor classifies message (GREEN/YELLOW/RED)
|
| 43 |
+
3. Based on classification:
|
| 44 |
+
- GREEN: Continue normal medical dialog
|
| 45 |
+
- YELLOW: Enter soft triage (2-3 questions)
|
| 46 |
+
- RED: Generate referral + crisis support
|
| 47 |
+
4. Triage resolves to GREEN (return to medical) or RED (referral)
|
| 48 |
+
|
| 49 |
+
Requirements: 1.1, 1.2, 1.3, 2.1, 2.2, 2.3, 2.4
|
| 50 |
+
"""
|
| 51 |
+
|
| 52 |
+
def __init__(self):
|
| 53 |
+
"""
|
| 54 |
+
Initialize the simplified medical app.
|
| 55 |
+
|
| 56 |
+
Creates:
|
| 57 |
+
- AI client manager
|
| 58 |
+
- Medical assistant for dialog
|
| 59 |
+
- Spiritual monitor for background classification
|
| 60 |
+
- Soft triage manager for YELLOW state handling
|
| 61 |
+
"""
|
| 62 |
+
logger.info("🏥 Initializing SimplifiedMedicalApp...")
|
| 63 |
+
|
| 64 |
+
# AI client
|
| 65 |
+
self.api = AIClientManager()
|
| 66 |
+
|
| 67 |
+
# Medical components
|
| 68 |
+
self.medical_assistant = MedicalAssistant(self.api)
|
| 69 |
+
self.soft_medical_triage = SoftMedicalTriage(self.api)
|
| 70 |
+
|
| 71 |
+
# Spiritual monitoring components
|
| 72 |
+
self.spiritual_monitor = SpiritualMonitor(self.api)
|
| 73 |
+
self.soft_triage_manager = SoftTriageManager(self.api)
|
| 74 |
+
|
| 75 |
+
# Load patient data
|
| 76 |
+
logger.info("🔄 Loading patient data...")
|
| 77 |
+
self.clinical_background = PatientDataLoader.load_clinical_background()
|
| 78 |
+
logger.info(f"✅ Loaded patient: {self.clinical_background.patient_name}")
|
| 79 |
+
|
| 80 |
+
# Session state
|
| 81 |
+
self.chat_history: List[ChatMessage] = []
|
| 82 |
+
self.spiritual_state = SessionSpiritualState()
|
| 83 |
+
self.session_active = False
|
| 84 |
+
|
| 85 |
+
logger.info("✅ SimplifiedMedicalApp initialized")
|
| 86 |
+
|
| 87 |
+
def process_message(
|
| 88 |
+
self,
|
| 89 |
+
message: str,
|
| 90 |
+
history: Optional[List] = None
|
| 91 |
+
) -> Tuple[List, str]:
|
| 92 |
+
"""
|
| 93 |
+
Process patient message with background spiritual monitoring.
|
| 94 |
+
|
| 95 |
+
Every message is analyzed by the Spiritual Monitor. Based on the
|
| 96 |
+
classification, the message is routed appropriately:
|
| 97 |
+
- GREEN: Normal medical dialog
|
| 98 |
+
- YELLOW: Soft triage questions
|
| 99 |
+
- RED: Crisis support + referral
|
| 100 |
+
|
| 101 |
+
Args:
|
| 102 |
+
message: Patient's message
|
| 103 |
+
history: Gradio chat history
|
| 104 |
+
|
| 105 |
+
Returns:
|
| 106 |
+
Tuple of (updated_history, status_info)
|
| 107 |
+
|
| 108 |
+
Requirements: 2.1, 2.2, 2.3, 2.4
|
| 109 |
+
"""
|
| 110 |
+
if not message.strip():
|
| 111 |
+
return history or [], self._get_status_info()
|
| 112 |
+
|
| 113 |
+
logger.info(f"Processing message: {message[:50]}...")
|
| 114 |
+
|
| 115 |
+
# Add user message to history
|
| 116 |
+
user_msg = ChatMessage(
|
| 117 |
+
timestamp=datetime.now().strftime("%H:%M"),
|
| 118 |
+
role="user",
|
| 119 |
+
message=message,
|
| 120 |
+
mode="medical"
|
| 121 |
+
)
|
| 122 |
+
self.chat_history.append(user_msg)
|
| 123 |
+
|
| 124 |
+
# Get conversation history for context
|
| 125 |
+
conversation_context = self._get_conversation_context()
|
| 126 |
+
|
| 127 |
+
# Route based on current spiritual state
|
| 128 |
+
if self.spiritual_state.is_in_triage():
|
| 129 |
+
# Currently in YELLOW state - continue triage
|
| 130 |
+
response = self._handle_triage_response(message, conversation_context)
|
| 131 |
+
else:
|
| 132 |
+
# Classify message with Spiritual Monitor (Requirement 2.1)
|
| 133 |
+
assessment = self.spiritual_monitor.classify(message, conversation_context)
|
| 134 |
+
self.spiritual_state.last_assessment = assessment
|
| 135 |
+
|
| 136 |
+
logger.info(f"Spiritual classification: {assessment.state.value}")
|
| 137 |
+
|
| 138 |
+
# Route based on classification
|
| 139 |
+
if assessment.state == SpiritualState.RED:
|
| 140 |
+
# Immediate RED - generate referral (Requirement 2.4)
|
| 141 |
+
response = self._handle_red_flag(assessment, message)
|
| 142 |
+
elif assessment.state == SpiritualState.YELLOW:
|
| 143 |
+
# Potential distress - start triage (Requirement 2.3)
|
| 144 |
+
response = self._handle_yellow_flag(assessment, message)
|
| 145 |
+
else:
|
| 146 |
+
# GREEN - normal medical dialog (Requirement 2.2)
|
| 147 |
+
response = self._handle_green_state(message)
|
| 148 |
+
|
| 149 |
+
# Add assistant response to history
|
| 150 |
+
assistant_msg = ChatMessage(
|
| 151 |
+
timestamp=datetime.now().strftime("%H:%M"),
|
| 152 |
+
role="assistant",
|
| 153 |
+
message=response,
|
| 154 |
+
mode=f"medical_{self.spiritual_state.spiritual_state.value}"
|
| 155 |
+
)
|
| 156 |
+
self.chat_history.append(assistant_msg)
|
| 157 |
+
|
| 158 |
+
# Update Gradio history
|
| 159 |
+
if history is None:
|
| 160 |
+
history = []
|
| 161 |
+
history.append({"role": "user", "content": message})
|
| 162 |
+
history.append({"role": "assistant", "content": response})
|
| 163 |
+
|
| 164 |
+
return history, self._get_status_info()
|
| 165 |
+
|
| 166 |
+
def _handle_green_state(self, message: str) -> str:
|
| 167 |
+
"""
|
| 168 |
+
Handle GREEN state - normal medical dialog.
|
| 169 |
+
|
| 170 |
+
Requirement: 2.2
|
| 171 |
+
"""
|
| 172 |
+
logger.info("Handling GREEN state - medical dialog")
|
| 173 |
+
|
| 174 |
+
try:
|
| 175 |
+
response = self.soft_medical_triage.conduct_triage(
|
| 176 |
+
message,
|
| 177 |
+
self.clinical_background,
|
| 178 |
+
self.chat_history
|
| 179 |
+
)
|
| 180 |
+
return response
|
| 181 |
+
except Exception as e:
|
| 182 |
+
logger.error(f"Medical dialog error: {e}")
|
| 183 |
+
return self._get_fallback_medical_response()
|
| 184 |
+
|
| 185 |
+
def _handle_yellow_flag(
|
| 186 |
+
self,
|
| 187 |
+
assessment: SpiritualAssessment,
|
| 188 |
+
message: str
|
| 189 |
+
) -> str:
|
| 190 |
+
"""
|
| 191 |
+
Handle YELLOW flag - initiate soft triage.
|
| 192 |
+
|
| 193 |
+
Transitions to YELLOW state and generates first triage question.
|
| 194 |
+
|
| 195 |
+
Requirement: 2.3
|
| 196 |
+
"""
|
| 197 |
+
logger.info("Handling YELLOW flag - initiating soft triage")
|
| 198 |
+
|
| 199 |
+
# Transition to YELLOW state
|
| 200 |
+
self.spiritual_state.transition_to(
|
| 201 |
+
SpiritualState.YELLOW,
|
| 202 |
+
f"Yellow flag detected: {', '.join(assessment.indicators)}"
|
| 203 |
+
)
|
| 204 |
+
|
| 205 |
+
# Detect patient language
|
| 206 |
+
patient_language = self._detect_language(message)
|
| 207 |
+
|
| 208 |
+
# Generate first triage question
|
| 209 |
+
try:
|
| 210 |
+
question = self.soft_triage_manager.generate_question(
|
| 211 |
+
context=self._get_conversation_context_str(),
|
| 212 |
+
patient_language=patient_language,
|
| 213 |
+
triage_session=self.spiritual_state.triage_session,
|
| 214 |
+
assessment=assessment
|
| 215 |
+
)
|
| 216 |
+
|
| 217 |
+
# Record the question
|
| 218 |
+
self.spiritual_state.triage_session.questions_asked.append(question)
|
| 219 |
+
|
| 220 |
+
return question
|
| 221 |
+
|
| 222 |
+
except Exception as e:
|
| 223 |
+
logger.error(f"Triage question generation error: {e}")
|
| 224 |
+
return self._get_fallback_triage_question(patient_language)
|
| 225 |
+
|
| 226 |
+
def _handle_triage_response(
|
| 227 |
+
self,
|
| 228 |
+
response: str,
|
| 229 |
+
conversation_context: List[str]
|
| 230 |
+
) -> str:
|
| 231 |
+
"""
|
| 232 |
+
Handle patient response during soft triage.
|
| 233 |
+
|
| 234 |
+
Evaluates response and determines next action:
|
| 235 |
+
- RESOLVED_GREEN: Return to medical dialog
|
| 236 |
+
- ESCALATE_RED: Generate referral
|
| 237 |
+
- CONTINUE: Ask another question (if under limit)
|
| 238 |
+
|
| 239 |
+
Requirements: 3.3, 3.4, 3.5, 3.6
|
| 240 |
+
"""
|
| 241 |
+
logger.info("Handling triage response")
|
| 242 |
+
|
| 243 |
+
# Record patient response
|
| 244 |
+
self.spiritual_state.triage_session.patient_responses.append(response)
|
| 245 |
+
self.spiritual_state.triage_session.question_count += 1
|
| 246 |
+
|
| 247 |
+
# Evaluate response
|
| 248 |
+
outcome, reasoning = self.soft_triage_manager.evaluate_response(
|
| 249 |
+
response=response,
|
| 250 |
+
triage_session=self.spiritual_state.triage_session,
|
| 251 |
+
context=self._get_conversation_context_str()
|
| 252 |
+
)
|
| 253 |
+
|
| 254 |
+
logger.info(f"Triage outcome: {outcome.value} - {reasoning}")
|
| 255 |
+
|
| 256 |
+
if outcome == TriageOutcome.RESOLVED_GREEN:
|
| 257 |
+
# Patient is coping - return to medical (Requirement 3.4)
|
| 258 |
+
return self._resolve_to_green(reasoning)
|
| 259 |
+
|
| 260 |
+
elif outcome == TriageOutcome.ESCALATE_RED:
|
| 261 |
+
# Distress confirmed - generate referral (Requirement 3.5)
|
| 262 |
+
return self._escalate_to_red(reasoning)
|
| 263 |
+
|
| 264 |
+
else: # CONTINUE
|
| 265 |
+
# Need more information - ask another question
|
| 266 |
+
if self.soft_triage_manager.should_force_decision(self.spiritual_state.triage_session):
|
| 267 |
+
# Max questions reached - force decision (Requirement 3.6)
|
| 268 |
+
return self._escalate_to_red("Max triage questions reached - conservative escalation")
|
| 269 |
+
else:
|
| 270 |
+
return self._generate_next_triage_question()
|
| 271 |
+
|
| 272 |
+
def _handle_red_flag(
|
| 273 |
+
self,
|
| 274 |
+
assessment: SpiritualAssessment,
|
| 275 |
+
message: str
|
| 276 |
+
) -> str:
|
| 277 |
+
"""
|
| 278 |
+
Handle RED flag - immediate crisis support and referral.
|
| 279 |
+
|
| 280 |
+
Requirement: 2.4
|
| 281 |
+
"""
|
| 282 |
+
logger.info("Handling RED flag - crisis support")
|
| 283 |
+
|
| 284 |
+
# Transition to RED state
|
| 285 |
+
self.spiritual_state.transition_to(
|
| 286 |
+
SpiritualState.RED,
|
| 287 |
+
f"Red flag detected: {', '.join(assessment.indicators)}"
|
| 288 |
+
)
|
| 289 |
+
|
| 290 |
+
# Detect patient language
|
| 291 |
+
patient_language = self._detect_language(message)
|
| 292 |
+
|
| 293 |
+
# Generate crisis support response
|
| 294 |
+
crisis_response = self._generate_crisis_response(patient_language, assessment)
|
| 295 |
+
|
| 296 |
+
# Generate referral (logged for chaplain team)
|
| 297 |
+
referral = self._generate_referral(assessment)
|
| 298 |
+
logger.info(f"REFERRAL GENERATED:\n{referral}")
|
| 299 |
+
|
| 300 |
+
return crisis_response
|
| 301 |
+
|
| 302 |
+
def _resolve_to_green(self, reasoning: str) -> str:
|
| 303 |
+
"""
|
| 304 |
+
Resolve triage to GREEN - return to medical dialog.
|
| 305 |
+
|
| 306 |
+
Requirement: 3.4
|
| 307 |
+
"""
|
| 308 |
+
logger.info(f"Resolving to GREEN: {reasoning}")
|
| 309 |
+
|
| 310 |
+
# Detect language from last response BEFORE transitioning (which clears triage session)
|
| 311 |
+
last_response = ""
|
| 312 |
+
if self.spiritual_state.triage_session and self.spiritual_state.triage_session.patient_responses:
|
| 313 |
+
last_response = self.spiritual_state.triage_session.patient_responses[-1]
|
| 314 |
+
patient_language = self._detect_language(last_response)
|
| 315 |
+
|
| 316 |
+
# Transition back to GREEN (this clears triage session)
|
| 317 |
+
self.spiritual_state.transition_to(SpiritualState.GREEN, reasoning)
|
| 318 |
+
|
| 319 |
+
# Generate smooth transition message
|
| 320 |
+
if patient_language == "Ukrainian":
|
| 321 |
+
transition = "Дякую, що поділились. Я радий, що у вас є підтримка. Чи є щось ще, з чим я можу вам допомогти сьогодні?"
|
| 322 |
+
else:
|
| 323 |
+
transition = "Thank you for sharing. I'm glad you have support. Is there anything else I can help you with today?"
|
| 324 |
+
|
| 325 |
+
return transition
|
| 326 |
+
|
| 327 |
+
def _escalate_to_red(self, reasoning: str) -> str:
|
| 328 |
+
"""
|
| 329 |
+
Escalate triage to RED - generate referral.
|
| 330 |
+
|
| 331 |
+
Requirement: 3.5
|
| 332 |
+
"""
|
| 333 |
+
logger.info(f"Escalating to RED: {reasoning}")
|
| 334 |
+
|
| 335 |
+
# Transition to RED
|
| 336 |
+
self.spiritual_state.transition_to(SpiritualState.RED, reasoning)
|
| 337 |
+
|
| 338 |
+
# Detect language
|
| 339 |
+
last_response = ""
|
| 340 |
+
if self.spiritual_state.triage_session and self.spiritual_state.triage_session.patient_responses:
|
| 341 |
+
last_response = self.spiritual_state.triage_session.patient_responses[-1]
|
| 342 |
+
patient_language = self._detect_language(last_response)
|
| 343 |
+
|
| 344 |
+
# Create assessment from triage context
|
| 345 |
+
assessment = SpiritualAssessment(
|
| 346 |
+
state=SpiritualState.RED,
|
| 347 |
+
indicators=["triage_escalation"],
|
| 348 |
+
confidence=0.8,
|
| 349 |
+
reasoning=reasoning
|
| 350 |
+
)
|
| 351 |
+
|
| 352 |
+
# Generate crisis response
|
| 353 |
+
crisis_response = self._generate_crisis_response(patient_language, assessment)
|
| 354 |
+
|
| 355 |
+
# Generate referral
|
| 356 |
+
referral = self._generate_referral(assessment)
|
| 357 |
+
logger.info(f"REFERRAL GENERATED:\n{referral}")
|
| 358 |
+
|
| 359 |
+
return crisis_response
|
| 360 |
+
|
| 361 |
+
def _generate_next_triage_question(self) -> str:
|
| 362 |
+
"""Generate next triage question."""
|
| 363 |
+
# Detect language from last response
|
| 364 |
+
last_response = ""
|
| 365 |
+
if self.spiritual_state.triage_session and self.spiritual_state.triage_session.patient_responses:
|
| 366 |
+
last_response = self.spiritual_state.triage_session.patient_responses[-1]
|
| 367 |
+
patient_language = self._detect_language(last_response)
|
| 368 |
+
|
| 369 |
+
try:
|
| 370 |
+
question = self.soft_triage_manager.generate_question(
|
| 371 |
+
context=self._get_conversation_context_str(),
|
| 372 |
+
patient_language=patient_language,
|
| 373 |
+
triage_session=self.spiritual_state.triage_session,
|
| 374 |
+
assessment=self.spiritual_state.last_assessment
|
| 375 |
+
)
|
| 376 |
+
|
| 377 |
+
self.spiritual_state.triage_session.questions_asked.append(question)
|
| 378 |
+
return question
|
| 379 |
+
|
| 380 |
+
except Exception as e:
|
| 381 |
+
logger.error(f"Question generation error: {e}")
|
| 382 |
+
return self._get_fallback_triage_question(patient_language)
|
| 383 |
+
|
| 384 |
+
def _generate_crisis_response(
|
| 385 |
+
self,
|
| 386 |
+
language: str,
|
| 387 |
+
assessment: SpiritualAssessment
|
| 388 |
+
) -> str:
|
| 389 |
+
"""Generate compassionate crisis support response."""
|
| 390 |
+
if language == "Ukrainian":
|
| 391 |
+
return """Я чую вас, і мені важливо, що ви поділились цим зі мною. Те, що ви відчуваєте, серйозне, і ви заслуговуєте на підтримку.
|
| 392 |
+
|
| 393 |
+
🆘 **Важливо:** Якщо ви маєте думки про самоушкодження, будь ласка, зверніться за ��опомогою:
|
| 394 |
+
• Лінія довіри: 7333 (безкоштовно з мобільного)
|
| 395 |
+
• Лайфлайн Україна: 0 800 500 335
|
| 396 |
+
|
| 397 |
+
Я передам інформацію нашій команді духовної підтримки, щоб хтось міг зв'язатися з вами особисто. Ви не самотні в цьому."""
|
| 398 |
+
else:
|
| 399 |
+
return """I hear you, and I'm glad you shared this with me. What you're feeling is serious, and you deserve support.
|
| 400 |
+
|
| 401 |
+
🆘 **Important:** If you're having thoughts of self-harm, please reach out for help:
|
| 402 |
+
• National Suicide Prevention Lifeline: 988
|
| 403 |
+
• Crisis Text Line: Text HOME to 741741
|
| 404 |
+
|
| 405 |
+
I'm connecting you with our spiritual care team so someone can reach out to you personally. You're not alone in this."""
|
| 406 |
+
|
| 407 |
+
def _generate_referral(self, assessment: SpiritualAssessment) -> str:
|
| 408 |
+
"""Generate referral message for chaplain team."""
|
| 409 |
+
# Get triage context if available
|
| 410 |
+
triage_context = ""
|
| 411 |
+
if self.spiritual_state.triage_session:
|
| 412 |
+
triage_context = "\n\nTriage exchanges:\n"
|
| 413 |
+
for q, r in zip(
|
| 414 |
+
self.spiritual_state.triage_session.questions_asked,
|
| 415 |
+
self.spiritual_state.triage_session.patient_responses
|
| 416 |
+
):
|
| 417 |
+
triage_context += f"Q: {q}\nA: {r}\n"
|
| 418 |
+
|
| 419 |
+
referral = f"""
|
| 420 |
+
=== SPIRITUAL CARE REFERRAL ===
|
| 421 |
+
Generated: {datetime.now().isoformat()}
|
| 422 |
+
Patient: {self.clinical_background.patient_name}
|
| 423 |
+
|
| 424 |
+
DISTRESS INDICATORS:
|
| 425 |
+
{chr(10).join(f'• {ind}' for ind in assessment.indicators)}
|
| 426 |
+
|
| 427 |
+
CLASSIFICATION: {assessment.state.value.upper()}
|
| 428 |
+
CONFIDENCE: {assessment.confidence:.0%}
|
| 429 |
+
REASONING: {assessment.reasoning}
|
| 430 |
+
{triage_context}
|
| 431 |
+
RECENT CONVERSATION:
|
| 432 |
+
{self._get_conversation_context_str()}
|
| 433 |
+
|
| 434 |
+
RECOMMENDED ACTION: Immediate spiritual care outreach
|
| 435 |
+
================================
|
| 436 |
+
"""
|
| 437 |
+
return referral
|
| 438 |
+
|
| 439 |
+
def _detect_language(self, text: str) -> str:
|
| 440 |
+
"""Simple language detection based on character analysis."""
|
| 441 |
+
if not text:
|
| 442 |
+
return "English"
|
| 443 |
+
|
| 444 |
+
# Check for Cyrillic characters (Ukrainian)
|
| 445 |
+
cyrillic_count = sum(1 for c in text if '\u0400' <= c <= '\u04FF')
|
| 446 |
+
|
| 447 |
+
if cyrillic_count > len(text) * 0.3:
|
| 448 |
+
return "Ukrainian"
|
| 449 |
+
return "English"
|
| 450 |
+
|
| 451 |
+
def _get_conversation_context(self) -> List[str]:
|
| 452 |
+
"""Get recent conversation as list of strings."""
|
| 453 |
+
recent = self.chat_history[-10:] # Last 5 exchanges
|
| 454 |
+
return [f"{msg.role}: {msg.message}" for msg in recent]
|
| 455 |
+
|
| 456 |
+
def _get_conversation_context_str(self) -> str:
|
| 457 |
+
"""Get recent conversation as formatted string."""
|
| 458 |
+
return "\n".join(self._get_conversation_context())
|
| 459 |
+
|
| 460 |
+
def _get_fallback_medical_response(self) -> str:
|
| 461 |
+
"""Fallback response when medical dialog fails."""
|
| 462 |
+
return "I'm here to help. Could you tell me more about what's concerning you?"
|
| 463 |
+
|
| 464 |
+
def _get_fallback_triage_question(self, language: str) -> str:
|
| 465 |
+
"""Fallback triage question when generation fails."""
|
| 466 |
+
if language == "Ukrainian":
|
| 467 |
+
return "Як ви почуваєтесь зараз? Чи є щось, що вас турбує?"
|
| 468 |
+
return "How are you feeling right now? Is there something on your mind?"
|
| 469 |
+
|
| 470 |
+
def reset_session(self) -> Tuple[List, str]:
|
| 471 |
+
"""
|
| 472 |
+
Reset session state.
|
| 473 |
+
|
| 474 |
+
Requirement: 7.4
|
| 475 |
+
"""
|
| 476 |
+
logger.info("Resetting session")
|
| 477 |
+
|
| 478 |
+
self.chat_history = []
|
| 479 |
+
self.spiritual_state.reset()
|
| 480 |
+
self.session_active = False
|
| 481 |
+
|
| 482 |
+
return [], self._get_status_info()
|
| 483 |
+
|
| 484 |
+
def _get_status_info(self) -> str:
|
| 485 |
+
"""Get current status information."""
|
| 486 |
+
state_emoji = {
|
| 487 |
+
SpiritualState.GREEN: "🟢",
|
| 488 |
+
SpiritualState.YELLOW: "🟡",
|
| 489 |
+
SpiritualState.RED: "🔴"
|
| 490 |
+
}
|
| 491 |
+
|
| 492 |
+
current_state = self.spiritual_state.spiritual_state
|
| 493 |
+
emoji = state_emoji.get(current_state, "⚪")
|
| 494 |
+
|
| 495 |
+
triage_info = ""
|
| 496 |
+
if self.spiritual_state.is_in_triage():
|
| 497 |
+
count = self.spiritual_state.triage_session.question_count
|
| 498 |
+
triage_info = f"\n🔍 **Triage:** Question {count}/3"
|
| 499 |
+
|
| 500 |
+
return f"""
|
| 501 |
+
{emoji} **Spiritual State:** {current_state.value.upper()}
|
| 502 |
+
🏥 **Mode:** Medical Dialog
|
| 503 |
+
💬 **Messages:** {len(self.chat_history)}
|
| 504 |
+
👤 **Patient:** {self.clinical_background.patient_name}{triage_info}
|
| 505 |
+
"""
|
| 506 |
+
|
| 507 |
+
|
| 508 |
+
def create_simplified_app() -> SimplifiedMedicalApp:
|
| 509 |
+
"""Factory function to create SimplifiedMedicalApp."""
|
| 510 |
+
return SimplifiedMedicalApp()
|
|
@@ -0,0 +1,333 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# soft_triage_manager.py
|
| 2 |
+
"""
|
| 3 |
+
Soft Triage Manager - Handles gentle psychological triage for YELLOW state.
|
| 4 |
+
|
| 5 |
+
When the Spiritual Monitor detects potential distress (YELLOW), this manager
|
| 6 |
+
conducts a brief, empathetic triage with 2-3 questions to determine if the
|
| 7 |
+
patient needs referral (RED) or is coping well (GREEN).
|
| 8 |
+
|
| 9 |
+
Requirements: 3.1, 3.2, 3.3, 3.6, 8.3
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import logging
|
| 13 |
+
import json
|
| 14 |
+
import re
|
| 15 |
+
from typing import List, Optional, Tuple
|
| 16 |
+
|
| 17 |
+
from src.core.spiritual_state import (
|
| 18 |
+
SpiritualState, TriageOutcome, TriageSession, SpiritualAssessment
|
| 19 |
+
)
|
| 20 |
+
from src.core.ai_client import AIClientManager
|
| 21 |
+
|
| 22 |
+
# Configure logging
|
| 23 |
+
logging.basicConfig(level=logging.INFO)
|
| 24 |
+
logger = logging.getLogger(__name__)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
# System prompt for generating triage questions
|
| 28 |
+
SYSTEM_PROMPT_TRIAGE_QUESTION = """You are a compassionate healthcare assistant conducting a gentle wellness check.
|
| 29 |
+
|
| 30 |
+
The patient may be experiencing some emotional or spiritual distress. Your task is to ask ONE empathetic,
|
| 31 |
+
non-judgmental clarifying question to better understand their situation.
|
| 32 |
+
|
| 33 |
+
GUIDELINES:
|
| 34 |
+
1. Be warm and supportive, not clinical or interrogating
|
| 35 |
+
2. Ask open-ended questions that invite sharing
|
| 36 |
+
3. Acknowledge their feelings without making assumptions
|
| 37 |
+
4. Keep the question natural, like a caring conversation
|
| 38 |
+
5. CRITICAL: Respond in the SAME LANGUAGE as the patient's message
|
| 39 |
+
|
| 40 |
+
EXAMPLES of good questions:
|
| 41 |
+
- "It sounds like you're going through a difficult time. Would you like to tell me more about what's on your mind?"
|
| 42 |
+
- "I hear that things feel heavy right now. What would be most helpful for you to talk about?"
|
| 43 |
+
- "Thank you for sharing that with me. How have you been coping with these feelings?"
|
| 44 |
+
|
| 45 |
+
Respond with ONLY the question text, no JSON or formatting. Match the patient's language."""
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
# System prompt for evaluating triage responses
|
| 49 |
+
SYSTEM_PROMPT_TRIAGE_EVALUATE = """You are evaluating a patient's response during a gentle wellness check.
|
| 50 |
+
|
| 51 |
+
Based on the patient's response, determine the appropriate outcome:
|
| 52 |
+
|
| 53 |
+
RESOLVED_GREEN: Patient is coping well, has support systems, or the concern was minor
|
| 54 |
+
- Signs: mentions coping strategies, support from others, temporary stress, feeling better
|
| 55 |
+
- Example: "I'm just having a bad day, but I have my family to talk to"
|
| 56 |
+
|
| 57 |
+
ESCALATE_RED: Patient shows significant distress that needs professional support
|
| 58 |
+
- Signs: persistent hopelessness, isolation, inability to cope, worsening symptoms
|
| 59 |
+
- Example: "I feel completely alone and nothing helps anymore"
|
| 60 |
+
|
| 61 |
+
CONTINUE: Need more information to make a determination
|
| 62 |
+
- Signs: vague response, unclear situation, patient deflecting
|
| 63 |
+
- Example: "I don't know, it's complicated"
|
| 64 |
+
|
| 65 |
+
Respond ONLY with valid JSON:
|
| 66 |
+
{
|
| 67 |
+
"outcome": "resolved_green" | "escalate_red" | "continue",
|
| 68 |
+
"reasoning": "Brief explanation",
|
| 69 |
+
"confidence": 0.0-1.0
|
| 70 |
+
}"""
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
class SoftTriageManager:
|
| 74 |
+
"""
|
| 75 |
+
Manages soft psychological triage for YELLOW state.
|
| 76 |
+
|
| 77 |
+
Generates empathetic clarifying questions and evaluates responses
|
| 78 |
+
to determine if patient needs referral (RED) or is coping (GREEN).
|
| 79 |
+
|
| 80 |
+
Requirements: 3.1, 3.2, 3.3, 3.6, 8.3
|
| 81 |
+
"""
|
| 82 |
+
|
| 83 |
+
MAX_QUESTIONS = 3
|
| 84 |
+
|
| 85 |
+
def __init__(self, api_client: AIClientManager):
|
| 86 |
+
"""
|
| 87 |
+
Initialize Soft Triage Manager.
|
| 88 |
+
|
| 89 |
+
Args:
|
| 90 |
+
api_client: AI client manager for LLM calls
|
| 91 |
+
"""
|
| 92 |
+
self.api = api_client
|
| 93 |
+
logger.info("🟡 SoftTriageManager initialized")
|
| 94 |
+
|
| 95 |
+
def generate_question(
|
| 96 |
+
self,
|
| 97 |
+
context: str,
|
| 98 |
+
patient_language: str,
|
| 99 |
+
triage_session: TriageSession,
|
| 100 |
+
assessment: SpiritualAssessment
|
| 101 |
+
) -> str:
|
| 102 |
+
"""
|
| 103 |
+
Generate an empathetic clarifying question.
|
| 104 |
+
|
| 105 |
+
Creates a natural, supportive question based on context and
|
| 106 |
+
previous triage exchanges. Matches patient's language.
|
| 107 |
+
|
| 108 |
+
Args:
|
| 109 |
+
context: Recent conversation context
|
| 110 |
+
patient_language: Detected patient language (e.g., "Ukrainian", "English")
|
| 111 |
+
triage_session: Current triage session state
|
| 112 |
+
assessment: Spiritual assessment that triggered triage
|
| 113 |
+
|
| 114 |
+
Returns:
|
| 115 |
+
Empathetic clarifying question in patient's language
|
| 116 |
+
|
| 117 |
+
Requirements: 3.1, 3.2, 8.3
|
| 118 |
+
"""
|
| 119 |
+
logger.info(f"Generating triage question #{triage_session.question_count + 1}")
|
| 120 |
+
|
| 121 |
+
# Build prompt with context
|
| 122 |
+
prompt = self._build_question_prompt(
|
| 123 |
+
context, patient_language, triage_session, assessment
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
try:
|
| 127 |
+
response = self.api.call_spiritual_api(
|
| 128 |
+
system_prompt=SYSTEM_PROMPT_TRIAGE_QUESTION,
|
| 129 |
+
user_prompt=prompt
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
+
# Clean up response (remove any JSON or formatting)
|
| 133 |
+
question = response.strip()
|
| 134 |
+
question = re.sub(r'^["\']|["\']$', '', question) # Remove quotes
|
| 135 |
+
|
| 136 |
+
logger.info(f"Generated question: {question[:50]}...")
|
| 137 |
+
return question
|
| 138 |
+
|
| 139 |
+
except Exception as e:
|
| 140 |
+
logger.error(f"Question generation error: {e}")
|
| 141 |
+
return self._get_fallback_question(patient_language, triage_session.question_count)
|
| 142 |
+
|
| 143 |
+
def evaluate_response(
|
| 144 |
+
self,
|
| 145 |
+
response: str,
|
| 146 |
+
triage_session: TriageSession,
|
| 147 |
+
context: str
|
| 148 |
+
) -> Tuple[TriageOutcome, str]:
|
| 149 |
+
"""
|
| 150 |
+
Evaluate patient's response to triage question.
|
| 151 |
+
|
| 152 |
+
Determines if patient is coping (GREEN), needs support (RED),
|
| 153 |
+
or needs more clarification (CONTINUE).
|
| 154 |
+
|
| 155 |
+
Args:
|
| 156 |
+
response: Patient's response to triage question
|
| 157 |
+
triage_session: Current triage session state
|
| 158 |
+
context: Conversation context
|
| 159 |
+
|
| 160 |
+
Returns:
|
| 161 |
+
Tuple of (TriageOutcome, reasoning)
|
| 162 |
+
|
| 163 |
+
Requirements: 3.3, 3.6
|
| 164 |
+
"""
|
| 165 |
+
logger.info(f"Evaluating triage response: {response[:50]}...")
|
| 166 |
+
|
| 167 |
+
# Check if we've hit the question limit (Requirement 3.6)
|
| 168 |
+
if triage_session.question_count >= self.MAX_QUESTIONS:
|
| 169 |
+
logger.warning("Max questions reached - forcing decision")
|
| 170 |
+
return self._force_decision(response, triage_session)
|
| 171 |
+
|
| 172 |
+
try:
|
| 173 |
+
# Build evaluation prompt
|
| 174 |
+
prompt = self._build_evaluation_prompt(response, triage_session, context)
|
| 175 |
+
|
| 176 |
+
llm_response = self.api.call_spiritual_api(
|
| 177 |
+
system_prompt=SYSTEM_PROMPT_TRIAGE_EVALUATE,
|
| 178 |
+
user_prompt=prompt
|
| 179 |
+
)
|
| 180 |
+
|
| 181 |
+
return self._parse_evaluation_response(llm_response)
|
| 182 |
+
|
| 183 |
+
except Exception as e:
|
| 184 |
+
logger.error(f"Evaluation error: {e}")
|
| 185 |
+
# On error with max questions, escalate to RED (conservative)
|
| 186 |
+
if triage_session.question_count >= self.MAX_QUESTIONS - 1:
|
| 187 |
+
return TriageOutcome.ESCALATE_RED, f"Evaluation error at limit - conservative escalation: {e}"
|
| 188 |
+
return TriageOutcome.CONTINUE, f"Evaluation error - continuing: {e}"
|
| 189 |
+
|
| 190 |
+
def should_force_decision(self, triage_session: TriageSession) -> bool:
|
| 191 |
+
"""
|
| 192 |
+
Check if triage should force a decision.
|
| 193 |
+
|
| 194 |
+
Returns True if max questions (3) reached.
|
| 195 |
+
|
| 196 |
+
Requirement: 3.6
|
| 197 |
+
"""
|
| 198 |
+
return triage_session.question_count >= self.MAX_QUESTIONS
|
| 199 |
+
|
| 200 |
+
def _build_question_prompt(
|
| 201 |
+
self,
|
| 202 |
+
context: str,
|
| 203 |
+
patient_language: str,
|
| 204 |
+
triage_session: TriageSession,
|
| 205 |
+
assessment: SpiritualAssessment
|
| 206 |
+
) -> str:
|
| 207 |
+
"""Build prompt for question generation."""
|
| 208 |
+
prompt_parts = [
|
| 209 |
+
f"Patient language: {patient_language}",
|
| 210 |
+
f"Detected indicators: {', '.join(assessment.indicators)}",
|
| 211 |
+
f"Question number: {triage_session.question_count + 1} of {self.MAX_QUESTIONS}",
|
| 212 |
+
]
|
| 213 |
+
|
| 214 |
+
if triage_session.questions_asked:
|
| 215 |
+
prompt_parts.append("\nPrevious questions asked:")
|
| 216 |
+
for i, q in enumerate(triage_session.questions_asked, 1):
|
| 217 |
+
prompt_parts.append(f"{i}. {q}")
|
| 218 |
+
prompt_parts.append("\nPatient responses:")
|
| 219 |
+
for i, r in enumerate(triage_session.patient_responses, 1):
|
| 220 |
+
prompt_parts.append(f"{i}. {r}")
|
| 221 |
+
|
| 222 |
+
prompt_parts.append(f"\nRecent context:\n{context}")
|
| 223 |
+
prompt_parts.append("\nGenerate ONE empathetic follow-up question:")
|
| 224 |
+
|
| 225 |
+
return "\n".join(prompt_parts)
|
| 226 |
+
|
| 227 |
+
def _build_evaluation_prompt(
|
| 228 |
+
self,
|
| 229 |
+
response: str,
|
| 230 |
+
triage_session: TriageSession,
|
| 231 |
+
context: str
|
| 232 |
+
) -> str:
|
| 233 |
+
"""Build prompt for response evaluation."""
|
| 234 |
+
prompt_parts = [
|
| 235 |
+
f"Question #{triage_session.question_count}: {triage_session.questions_asked[-1] if triage_session.questions_asked else 'N/A'}",
|
| 236 |
+
f"Patient response: {response}",
|
| 237 |
+
f"Questions remaining: {self.MAX_QUESTIONS - triage_session.question_count}",
|
| 238 |
+
]
|
| 239 |
+
|
| 240 |
+
if len(triage_session.questions_asked) > 1:
|
| 241 |
+
prompt_parts.append("\nPrevious exchanges:")
|
| 242 |
+
for i, (q, r) in enumerate(zip(triage_session.questions_asked[:-1], triage_session.patient_responses[:-1]), 1):
|
| 243 |
+
prompt_parts.append(f"Q{i}: {q}")
|
| 244 |
+
prompt_parts.append(f"A{i}: {r}")
|
| 245 |
+
|
| 246 |
+
prompt_parts.append(f"\nContext:\n{context}")
|
| 247 |
+
prompt_parts.append("\nEvaluate the patient's response:")
|
| 248 |
+
|
| 249 |
+
return "\n".join(prompt_parts)
|
| 250 |
+
|
| 251 |
+
def _parse_evaluation_response(self, response: str) -> Tuple[TriageOutcome, str]:
|
| 252 |
+
"""Parse LLM evaluation response."""
|
| 253 |
+
try:
|
| 254 |
+
# Extract JSON
|
| 255 |
+
json_match = re.search(r'\{[^{}]*\}', response, re.DOTALL)
|
| 256 |
+
if json_match:
|
| 257 |
+
data = json.loads(json_match.group())
|
| 258 |
+
else:
|
| 259 |
+
data = json.loads(response)
|
| 260 |
+
|
| 261 |
+
outcome_str = data.get("outcome", "continue").lower()
|
| 262 |
+
reasoning = data.get("reasoning", "LLM evaluation")
|
| 263 |
+
|
| 264 |
+
if outcome_str == "resolved_green":
|
| 265 |
+
return TriageOutcome.RESOLVED_GREEN, reasoning
|
| 266 |
+
elif outcome_str == "escalate_red":
|
| 267 |
+
return TriageOutcome.ESCALATE_RED, reasoning
|
| 268 |
+
else:
|
| 269 |
+
return TriageOutcome.CONTINUE, reasoning
|
| 270 |
+
|
| 271 |
+
except (json.JSONDecodeError, KeyError) as e:
|
| 272 |
+
logger.warning(f"Failed to parse evaluation: {e}")
|
| 273 |
+
return TriageOutcome.CONTINUE, f"Parse error - continuing: {e}"
|
| 274 |
+
|
| 275 |
+
def _force_decision(
|
| 276 |
+
self,
|
| 277 |
+
last_response: str,
|
| 278 |
+
triage_session: TriageSession
|
| 279 |
+
) -> Tuple[TriageOutcome, str]:
|
| 280 |
+
"""
|
| 281 |
+
Force a decision when max questions reached.
|
| 282 |
+
|
| 283 |
+
Uses conservative approach: if uncertain, escalate to RED.
|
| 284 |
+
|
| 285 |
+
Requirement: 3.6
|
| 286 |
+
"""
|
| 287 |
+
# Look for positive indicators in responses
|
| 288 |
+
positive_indicators = [
|
| 289 |
+
"better", "okay", "fine", "coping", "support", "family", "friends",
|
| 290 |
+
"краще", "добре", "справляюсь", "підтримка", "сім'я", "друзі"
|
| 291 |
+
]
|
| 292 |
+
|
| 293 |
+
all_responses = " ".join(triage_session.patient_responses + [last_response]).lower()
|
| 294 |
+
|
| 295 |
+
positive_count = sum(1 for ind in positive_indicators if ind in all_responses)
|
| 296 |
+
|
| 297 |
+
if positive_count >= 2:
|
| 298 |
+
return TriageOutcome.RESOLVED_GREEN, "Forced decision: positive indicators found in responses"
|
| 299 |
+
else:
|
| 300 |
+
# Conservative: escalate if uncertain (Requirement 3.6)
|
| 301 |
+
return TriageOutcome.ESCALATE_RED, "Forced decision: max questions reached without clear resolution - conservative escalation"
|
| 302 |
+
|
| 303 |
+
def _get_fallback_question(self, language: str, question_num: int) -> str:
|
| 304 |
+
"""Get fallback question if LLM fails."""
|
| 305 |
+
fallback_questions = {
|
| 306 |
+
"Ukrainian": [
|
| 307 |
+
"Як ви почуваєтесь зараз? Чи є щось, що вас турбує?",
|
| 308 |
+
"Чи є хтось, з ким ви можете поговорити про свої почуття?",
|
| 309 |
+
"Що б вам найбільше допомогло зараз?"
|
| 310 |
+
],
|
| 311 |
+
"English": [
|
| 312 |
+
"How are you feeling right now? Is there something on your mind?",
|
| 313 |
+
"Is there someone you can talk to about how you're feeling?",
|
| 314 |
+
"What would be most helpful for you right now?"
|
| 315 |
+
]
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
questions = fallback_questions.get(language, fallback_questions["English"])
|
| 319 |
+
idx = min(question_num, len(questions) - 1)
|
| 320 |
+
return questions[idx]
|
| 321 |
+
|
| 322 |
+
|
| 323 |
+
def create_soft_triage_manager(api_client: AIClientManager) -> SoftTriageManager:
|
| 324 |
+
"""
|
| 325 |
+
Factory function to create SoftTriageManager.
|
| 326 |
+
|
| 327 |
+
Args:
|
| 328 |
+
api_client: AI client manager
|
| 329 |
+
|
| 330 |
+
Returns:
|
| 331 |
+
Initialized SoftTriageManager instance
|
| 332 |
+
"""
|
| 333 |
+
return SoftTriageManager(api_client)
|
|
@@ -0,0 +1,290 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# spiritual_monitor.py
|
| 2 |
+
"""
|
| 3 |
+
Spiritual Monitor - Background classifier for spiritual distress detection.
|
| 4 |
+
|
| 5 |
+
Analyzes every patient message for spiritual distress indicators and returns
|
| 6 |
+
a simple GREEN/YELLOW/RED classification. Runs invisibly in the background
|
| 7 |
+
during medical dialog.
|
| 8 |
+
|
| 9 |
+
Requirements: 2.1, 5.1, 5.2, 5.4
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import logging
|
| 13 |
+
import json
|
| 14 |
+
import re
|
| 15 |
+
from typing import List, Optional
|
| 16 |
+
|
| 17 |
+
from src.core.spiritual_state import SpiritualState, SpiritualAssessment
|
| 18 |
+
from src.core.ai_client import AIClientManager
|
| 19 |
+
|
| 20 |
+
# Configure logging
|
| 21 |
+
logging.basicConfig(level=logging.INFO)
|
| 22 |
+
logger = logging.getLogger(__name__)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
# Red flag keywords that trigger immediate RED classification
|
| 26 |
+
RED_FLAG_KEYWORDS = [
|
| 27 |
+
# Suicidal ideation (English)
|
| 28 |
+
"suicide", "suicidal", "kill myself", "end my life", "want to die",
|
| 29 |
+
"better off dead", "no reason to live", "can't go on",
|
| 30 |
+
# Suicidal ideation (Ukrainian)
|
| 31 |
+
"самогубство", "покінчити з життям", "хочу померти", "не хочу жити",
|
| 32 |
+
"краще б мене не було", "немає сенсу жити",
|
| 33 |
+
# Severe hopelessness (English)
|
| 34 |
+
"hopeless", "no hope", "give up", "nothing matters",
|
| 35 |
+
# Severe hopelessness (Ukrainian)
|
| 36 |
+
"безнадія", "немає надії", "все безглуздо", "нічого не має сенсу",
|
| 37 |
+
# Death wishes (English)
|
| 38 |
+
"wish i was dead", "want to disappear", "end it all",
|
| 39 |
+
# Death wishes (Ukrainian)
|
| 40 |
+
"хочу зникнути", "краще б я помер"
|
| 41 |
+
]
|
| 42 |
+
|
| 43 |
+
# Yellow flag indicators for potential distress
|
| 44 |
+
YELLOW_FLAG_INDICATORS = [
|
| 45 |
+
# Emotional distress (English)
|
| 46 |
+
"sad", "depressed", "anxious", "worried", "scared", "lonely",
|
| 47 |
+
"overwhelmed", "stressed", "can't cope", "struggling",
|
| 48 |
+
# Emotional distress (Ukrainian)
|
| 49 |
+
"сумно", "депресія", "тривога", "хвилююсь", "страшно", "самотньо",
|
| 50 |
+
"перевантажений", "стрес", "не справляюсь", "важко",
|
| 51 |
+
# Spiritual/existential (English)
|
| 52 |
+
"god", "faith", "pray", "meaning", "purpose", "why me",
|
| 53 |
+
"spiritual", "soul", "afterlife", "sin", "guilt",
|
| 54 |
+
# Spiritual/existential (Ukrainian)
|
| 55 |
+
"бог", "віра", "молитва", "сенс", "мета", "чому я",
|
| 56 |
+
"духовний", "душа", "гріх", "провина",
|
| 57 |
+
# Grief/loss (English)
|
| 58 |
+
"grief", "loss", "died", "death", "mourning", "miss them",
|
| 59 |
+
# Grief/loss (Ukrainian)
|
| 60 |
+
"горе", "втрата", "помер", "смерть", "сумую", "скучаю"
|
| 61 |
+
]
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
# System prompt for spiritual classification
|
| 65 |
+
SYSTEM_PROMPT_SPIRITUAL_MONITOR = """You are a background spiritual distress classifier for a medical chatbot.
|
| 66 |
+
|
| 67 |
+
Your task is to analyze patient messages and classify them into one of three categories:
|
| 68 |
+
- GREEN: No spiritual/emotional distress indicators. Normal medical conversation.
|
| 69 |
+
- YELLOW: Potential distress indicators present. May need gentle follow-up.
|
| 70 |
+
- RED: Severe distress indicators. Immediate support needed.
|
| 71 |
+
|
| 72 |
+
CLASSIFICATION RULES:
|
| 73 |
+
1. RED (immediate): Suicidal ideation, death wishes, severe hopelessness, crisis language
|
| 74 |
+
2. YELLOW (potential): Sadness, anxiety, spiritual questions, grief, existential concerns, loneliness
|
| 75 |
+
3. GREEN (none): Medical symptoms only, routine questions, no emotional content
|
| 76 |
+
|
| 77 |
+
IMPORTANT:
|
| 78 |
+
- When uncertain between GREEN and YELLOW, choose YELLOW (conservative approach)
|
| 79 |
+
- Any mention of suicide, self-harm, or wanting to die is ALWAYS RED
|
| 80 |
+
- Spiritual questions (about God, faith, meaning) are YELLOW unless combined with crisis language
|
| 81 |
+
|
| 82 |
+
Respond ONLY with valid JSON in this exact format:
|
| 83 |
+
{
|
| 84 |
+
"state": "green" | "yellow" | "red",
|
| 85 |
+
"indicators": ["indicator1", "indicator2"],
|
| 86 |
+
"confidence": 0.0-1.0,
|
| 87 |
+
"reasoning": "Brief explanation"
|
| 88 |
+
}"""
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
class SpiritualMonitor:
|
| 92 |
+
"""
|
| 93 |
+
Background classifier for spiritual distress detection.
|
| 94 |
+
|
| 95 |
+
Analyzes every patient message and returns GREEN/YELLOW/RED classification.
|
| 96 |
+
Uses keyword detection for immediate RED flags and LLM for nuanced analysis.
|
| 97 |
+
|
| 98 |
+
Requirements: 2.1, 5.1, 5.2, 5.4
|
| 99 |
+
"""
|
| 100 |
+
|
| 101 |
+
def __init__(self, api_client: AIClientManager):
|
| 102 |
+
"""
|
| 103 |
+
Initialize Spiritual Monitor.
|
| 104 |
+
|
| 105 |
+
Args:
|
| 106 |
+
api_client: AI client manager for LLM calls
|
| 107 |
+
"""
|
| 108 |
+
self.api = api_client
|
| 109 |
+
logger.info("🔍 SpiritualMonitor initialized")
|
| 110 |
+
|
| 111 |
+
def classify(
|
| 112 |
+
self,
|
| 113 |
+
message: str,
|
| 114 |
+
conversation_history: Optional[List[str]] = None
|
| 115 |
+
) -> SpiritualAssessment:
|
| 116 |
+
"""
|
| 117 |
+
Classify message for spiritual distress indicators.
|
| 118 |
+
|
| 119 |
+
First checks for red flag keywords (immediate RED).
|
| 120 |
+
Then uses LLM for nuanced GREEN/YELLOW/RED classification.
|
| 121 |
+
|
| 122 |
+
Args:
|
| 123 |
+
message: Patient's message to classify
|
| 124 |
+
conversation_history: Optional conversation context
|
| 125 |
+
|
| 126 |
+
Returns:
|
| 127 |
+
SpiritualAssessment with state, indicators, confidence, reasoning
|
| 128 |
+
|
| 129 |
+
Requirements: 2.1, 5.1, 5.2, 5.4
|
| 130 |
+
"""
|
| 131 |
+
logger.info(f"Classifying message: {message[:50]}...")
|
| 132 |
+
|
| 133 |
+
# Step 1: Check for red flag keywords (Requirement 5.4)
|
| 134 |
+
red_flag_result = self._check_red_flag_keywords(message)
|
| 135 |
+
if red_flag_result:
|
| 136 |
+
logger.warning(f"RED FLAG detected via keywords: {red_flag_result}")
|
| 137 |
+
return SpiritualAssessment(
|
| 138 |
+
state=SpiritualState.RED,
|
| 139 |
+
indicators=red_flag_result,
|
| 140 |
+
confidence=1.0,
|
| 141 |
+
reasoning="Red flag keywords detected - immediate support needed"
|
| 142 |
+
)
|
| 143 |
+
|
| 144 |
+
# Step 2: Use LLM for nuanced classification
|
| 145 |
+
try:
|
| 146 |
+
assessment = self._classify_with_llm(message, conversation_history)
|
| 147 |
+
logger.info(f"LLM classification: {assessment.state.value}")
|
| 148 |
+
return assessment
|
| 149 |
+
except Exception as e:
|
| 150 |
+
# On error, default to YELLOW (conservative) (Requirement 5.2)
|
| 151 |
+
logger.error(f"Classification error, defaulting to YELLOW: {e}")
|
| 152 |
+
return SpiritualAssessment(
|
| 153 |
+
state=SpiritualState.YELLOW,
|
| 154 |
+
indicators=["classification_error"],
|
| 155 |
+
confidence=0.5,
|
| 156 |
+
reasoning=f"Classification error - conservative YELLOW default: {str(e)}"
|
| 157 |
+
)
|
| 158 |
+
|
| 159 |
+
def _check_red_flag_keywords(self, message: str) -> Optional[List[str]]:
|
| 160 |
+
"""
|
| 161 |
+
Check message for red flag keywords.
|
| 162 |
+
|
| 163 |
+
Returns list of matched keywords if found, None otherwise.
|
| 164 |
+
|
| 165 |
+
Requirement: 5.4
|
| 166 |
+
"""
|
| 167 |
+
message_lower = message.lower()
|
| 168 |
+
matched = []
|
| 169 |
+
|
| 170 |
+
for keyword in RED_FLAG_KEYWORDS:
|
| 171 |
+
if keyword.lower() in message_lower:
|
| 172 |
+
matched.append(keyword)
|
| 173 |
+
|
| 174 |
+
return matched if matched else None
|
| 175 |
+
|
| 176 |
+
def _check_yellow_indicators(self, message: str) -> List[str]:
|
| 177 |
+
"""
|
| 178 |
+
Check message for yellow flag indicators.
|
| 179 |
+
|
| 180 |
+
Returns list of matched indicators.
|
| 181 |
+
"""
|
| 182 |
+
message_lower = message.lower()
|
| 183 |
+
matched = []
|
| 184 |
+
|
| 185 |
+
for indicator in YELLOW_FLAG_INDICATORS:
|
| 186 |
+
if indicator.lower() in message_lower:
|
| 187 |
+
matched.append(indicator)
|
| 188 |
+
|
| 189 |
+
return matched
|
| 190 |
+
|
| 191 |
+
def _classify_with_llm(
|
| 192 |
+
self,
|
| 193 |
+
message: str,
|
| 194 |
+
conversation_history: Optional[List[str]] = None
|
| 195 |
+
) -> SpiritualAssessment:
|
| 196 |
+
"""
|
| 197 |
+
Use LLM for nuanced classification.
|
| 198 |
+
|
| 199 |
+
Requirements: 2.1, 5.2
|
| 200 |
+
"""
|
| 201 |
+
# Build context
|
| 202 |
+
context = ""
|
| 203 |
+
if conversation_history:
|
| 204 |
+
recent = conversation_history[-6:] # Last 3 exchanges
|
| 205 |
+
context = "Recent conversation:\n" + "\n".join(recent) + "\n\n"
|
| 206 |
+
|
| 207 |
+
prompt = f"""{context}Current message to classify:
|
| 208 |
+
"{message}"
|
| 209 |
+
|
| 210 |
+
Classify this message for spiritual/emotional distress indicators."""
|
| 211 |
+
|
| 212 |
+
# Call LLM
|
| 213 |
+
response = self.api.call_spiritual_api(
|
| 214 |
+
system_prompt=SYSTEM_PROMPT_SPIRITUAL_MONITOR,
|
| 215 |
+
user_prompt=prompt
|
| 216 |
+
)
|
| 217 |
+
|
| 218 |
+
# Parse response
|
| 219 |
+
return self._parse_classification_response(response, message)
|
| 220 |
+
|
| 221 |
+
def _parse_classification_response(
|
| 222 |
+
self,
|
| 223 |
+
response: str,
|
| 224 |
+
original_message: str
|
| 225 |
+
) -> SpiritualAssessment:
|
| 226 |
+
"""
|
| 227 |
+
Parse LLM classification response.
|
| 228 |
+
|
| 229 |
+
Falls back to keyword-based classification if parsing fails.
|
| 230 |
+
|
| 231 |
+
Requirement: 5.2 (conservative default)
|
| 232 |
+
"""
|
| 233 |
+
try:
|
| 234 |
+
# Try to extract JSON from response
|
| 235 |
+
json_match = re.search(r'\{[^{}]*\}', response, re.DOTALL)
|
| 236 |
+
if json_match:
|
| 237 |
+
data = json.loads(json_match.group())
|
| 238 |
+
else:
|
| 239 |
+
data = json.loads(response)
|
| 240 |
+
|
| 241 |
+
# Parse state
|
| 242 |
+
state_str = data.get("state", "yellow").lower()
|
| 243 |
+
if state_str == "red":
|
| 244 |
+
state = SpiritualState.RED
|
| 245 |
+
elif state_str == "green":
|
| 246 |
+
state = SpiritualState.GREEN
|
| 247 |
+
else:
|
| 248 |
+
state = SpiritualState.YELLOW
|
| 249 |
+
|
| 250 |
+
return SpiritualAssessment(
|
| 251 |
+
state=state,
|
| 252 |
+
indicators=data.get("indicators", []),
|
| 253 |
+
confidence=float(data.get("confidence", 0.7)),
|
| 254 |
+
reasoning=data.get("reasoning", "LLM classification")
|
| 255 |
+
)
|
| 256 |
+
|
| 257 |
+
except (json.JSONDecodeError, KeyError, ValueError) as e:
|
| 258 |
+
logger.warning(f"Failed to parse LLM response: {e}")
|
| 259 |
+
|
| 260 |
+
# Fallback: Use keyword detection (Requirement 5.2)
|
| 261 |
+
yellow_indicators = self._check_yellow_indicators(original_message)
|
| 262 |
+
|
| 263 |
+
if yellow_indicators:
|
| 264 |
+
return SpiritualAssessment(
|
| 265 |
+
state=SpiritualState.YELLOW,
|
| 266 |
+
indicators=yellow_indicators,
|
| 267 |
+
confidence=0.6,
|
| 268 |
+
reasoning="Keyword-based classification (LLM parse failed)"
|
| 269 |
+
)
|
| 270 |
+
else:
|
| 271 |
+
# Conservative: default to YELLOW if uncertain
|
| 272 |
+
return SpiritualAssessment(
|
| 273 |
+
state=SpiritualState.YELLOW,
|
| 274 |
+
indicators=["parse_error"],
|
| 275 |
+
confidence=0.5,
|
| 276 |
+
reasoning="Conservative YELLOW default (LLM parse failed)"
|
| 277 |
+
)
|
| 278 |
+
|
| 279 |
+
|
| 280 |
+
def create_spiritual_monitor(api_client: AIClientManager) -> SpiritualMonitor:
|
| 281 |
+
"""
|
| 282 |
+
Factory function to create SpiritualMonitor.
|
| 283 |
+
|
| 284 |
+
Args:
|
| 285 |
+
api_client: AI client manager
|
| 286 |
+
|
| 287 |
+
Returns:
|
| 288 |
+
Initialized SpiritualMonitor instance
|
| 289 |
+
"""
|
| 290 |
+
return SpiritualMonitor(api_client)
|
|
@@ -0,0 +1,170 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# spiritual_state.py
|
| 2 |
+
"""
|
| 3 |
+
Spiritual State Machine - Data models for simplified spiritual triage.
|
| 4 |
+
|
| 5 |
+
Defines the state machine for background spiritual monitoring:
|
| 6 |
+
- GREEN: Normal medical dialog, no distress indicators
|
| 7 |
+
- YELLOW: Potential distress detected, soft triage in progress
|
| 8 |
+
- RED: Severe distress confirmed, referral needed
|
| 9 |
+
|
| 10 |
+
Requirements: 5.1, 7.1
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from enum import Enum
|
| 14 |
+
from dataclasses import dataclass, field
|
| 15 |
+
from typing import List, Optional
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class SpiritualState(Enum):
|
| 19 |
+
"""
|
| 20 |
+
Spiritual monitoring state.
|
| 21 |
+
|
| 22 |
+
GREEN: Normal - no distress indicators detected
|
| 23 |
+
YELLOW: Potential - soft triage in progress (max 3 questions)
|
| 24 |
+
RED: Severe - referral needed
|
| 25 |
+
|
| 26 |
+
Requirements: 5.1, 7.1
|
| 27 |
+
"""
|
| 28 |
+
GREEN = "green"
|
| 29 |
+
YELLOW = "yellow"
|
| 30 |
+
RED = "red"
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class TriageOutcome(Enum):
|
| 34 |
+
"""
|
| 35 |
+
Outcome of soft triage evaluation.
|
| 36 |
+
|
| 37 |
+
RESOLVED_GREEN: Patient is coping well, return to medical dialog
|
| 38 |
+
ESCALATE_RED: Distress confirmed, generate referral
|
| 39 |
+
CONTINUE: Need more information (ask another question)
|
| 40 |
+
|
| 41 |
+
Requirements: 3.3
|
| 42 |
+
"""
|
| 43 |
+
RESOLVED_GREEN = "resolved_green"
|
| 44 |
+
ESCALATE_RED = "escalate_red"
|
| 45 |
+
CONTINUE = "continue"
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
@dataclass
|
| 49 |
+
class SpiritualAssessment:
|
| 50 |
+
"""
|
| 51 |
+
Result of spiritual monitor classification.
|
| 52 |
+
|
| 53 |
+
Attributes:
|
| 54 |
+
state: Classified spiritual state (GREEN/YELLOW/RED)
|
| 55 |
+
indicators: List of detected distress indicators
|
| 56 |
+
confidence: Classification confidence (0.0-1.0)
|
| 57 |
+
reasoning: Explanation of classification decision
|
| 58 |
+
|
| 59 |
+
Requirements: 2.1, 5.1
|
| 60 |
+
"""
|
| 61 |
+
state: SpiritualState
|
| 62 |
+
indicators: List[str] = field(default_factory=list)
|
| 63 |
+
confidence: float = 0.0
|
| 64 |
+
reasoning: str = ""
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
@dataclass
|
| 68 |
+
class TriageSession:
|
| 69 |
+
"""
|
| 70 |
+
Tracks soft triage session state.
|
| 71 |
+
|
| 72 |
+
Attributes:
|
| 73 |
+
question_count: Number of questions asked (max 3)
|
| 74 |
+
questions_asked: List of triage questions asked
|
| 75 |
+
patient_responses: List of patient responses
|
| 76 |
+
outcome: Final triage outcome (None if in progress)
|
| 77 |
+
|
| 78 |
+
Requirements: 3.1, 7.2
|
| 79 |
+
"""
|
| 80 |
+
question_count: int = 0
|
| 81 |
+
questions_asked: List[str] = field(default_factory=list)
|
| 82 |
+
patient_responses: List[str] = field(default_factory=list)
|
| 83 |
+
outcome: Optional[TriageOutcome] = None
|
| 84 |
+
|
| 85 |
+
def add_exchange(self, question: str, response: str):
|
| 86 |
+
"""Add a question-response exchange to the session."""
|
| 87 |
+
self.questions_asked.append(question)
|
| 88 |
+
self.patient_responses.append(response)
|
| 89 |
+
self.question_count += 1
|
| 90 |
+
|
| 91 |
+
def is_at_limit(self) -> bool:
|
| 92 |
+
"""Check if max questions (3) reached."""
|
| 93 |
+
return self.question_count >= 3
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
@dataclass
|
| 97 |
+
class SessionSpiritualState:
|
| 98 |
+
"""
|
| 99 |
+
Complete spiritual state for a session.
|
| 100 |
+
|
| 101 |
+
Tracks current state, triage session (if active), and history.
|
| 102 |
+
|
| 103 |
+
Attributes:
|
| 104 |
+
spiritual_state: Current state (GREEN/YELLOW/RED)
|
| 105 |
+
triage_session: Active triage session (None if not in YELLOW)
|
| 106 |
+
last_assessment: Most recent spiritual assessment
|
| 107 |
+
state_history: List of state transitions for debugging
|
| 108 |
+
|
| 109 |
+
Requirements: 7.1, 7.2, 7.3
|
| 110 |
+
"""
|
| 111 |
+
spiritual_state: SpiritualState = SpiritualState.GREEN
|
| 112 |
+
triage_session: Optional[TriageSession] = None
|
| 113 |
+
last_assessment: Optional[SpiritualAssessment] = None
|
| 114 |
+
state_history: List[str] = field(default_factory=list)
|
| 115 |
+
|
| 116 |
+
def transition_to(self, new_state: SpiritualState, reason: str = ""):
|
| 117 |
+
"""
|
| 118 |
+
Transition to a new spiritual state.
|
| 119 |
+
|
| 120 |
+
Logs the transition and manages triage session lifecycle.
|
| 121 |
+
|
| 122 |
+
Requirements: 7.1, 7.3
|
| 123 |
+
"""
|
| 124 |
+
old_state = self.spiritual_state
|
| 125 |
+
self.spiritual_state = new_state
|
| 126 |
+
|
| 127 |
+
# Log transition
|
| 128 |
+
transition_log = f"{old_state.value} -> {new_state.value}"
|
| 129 |
+
if reason:
|
| 130 |
+
transition_log += f" ({reason})"
|
| 131 |
+
self.state_history.append(transition_log)
|
| 132 |
+
|
| 133 |
+
# Manage triage session
|
| 134 |
+
if new_state == SpiritualState.YELLOW and self.triage_session is None:
|
| 135 |
+
self.triage_session = TriageSession()
|
| 136 |
+
elif new_state != SpiritualState.YELLOW:
|
| 137 |
+
self.triage_session = None
|
| 138 |
+
|
| 139 |
+
def reset(self):
|
| 140 |
+
"""
|
| 141 |
+
Reset spiritual state to GREEN.
|
| 142 |
+
|
| 143 |
+
Called at session end.
|
| 144 |
+
|
| 145 |
+
Requirements: 7.4
|
| 146 |
+
"""
|
| 147 |
+
self.spiritual_state = SpiritualState.GREEN
|
| 148 |
+
self.triage_session = None
|
| 149 |
+
self.last_assessment = None
|
| 150 |
+
self.state_history.append("RESET -> green")
|
| 151 |
+
|
| 152 |
+
def get_state(self) -> SpiritualState:
|
| 153 |
+
"""Get current spiritual state."""
|
| 154 |
+
return self.spiritual_state
|
| 155 |
+
|
| 156 |
+
def is_in_triage(self) -> bool:
|
| 157 |
+
"""Check if currently in soft triage (YELLOW state)."""
|
| 158 |
+
return self.spiritual_state == SpiritualState.YELLOW
|
| 159 |
+
|
| 160 |
+
def should_force_triage_decision(self) -> bool:
|
| 161 |
+
"""
|
| 162 |
+
Check if triage should force a decision.
|
| 163 |
+
|
| 164 |
+
Returns True if 3 questions asked without resolution.
|
| 165 |
+
|
| 166 |
+
Requirements: 3.6
|
| 167 |
+
"""
|
| 168 |
+
if self.triage_session is None:
|
| 169 |
+
return False
|
| 170 |
+
return self.triage_session.is_at_limit()
|
|
@@ -0,0 +1,273 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# simplified_gradio_app.py
|
| 2 |
+
"""
|
| 3 |
+
Simplified Gradio Interface for Medical Brain with Background Spiritual Monitoring.
|
| 4 |
+
|
| 5 |
+
Single unified medical interface with invisible spiritual monitoring.
|
| 6 |
+
No mode selector - just medical dialog with automatic spiritual support.
|
| 7 |
+
|
| 8 |
+
Requirements: 1.3, 4.1, 4.2, 12.1, 12.2
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import os
|
| 12 |
+
from dotenv import load_dotenv
|
| 13 |
+
|
| 14 |
+
# Load environment variables
|
| 15 |
+
load_dotenv()
|
| 16 |
+
|
| 17 |
+
import gradio as gr
|
| 18 |
+
import uuid
|
| 19 |
+
from datetime import datetime
|
| 20 |
+
from typing import Dict, Any, Optional
|
| 21 |
+
|
| 22 |
+
from src.core.simplified_medical_app import SimplifiedMedicalApp
|
| 23 |
+
from src.core.spiritual_state import SpiritualState
|
| 24 |
+
|
| 25 |
+
try:
|
| 26 |
+
from app_config import GRADIO_CONFIG
|
| 27 |
+
except ImportError:
|
| 28 |
+
GRADIO_CONFIG = {"theme": "soft", "show_api": False}
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class SimplifiedSessionData:
|
| 32 |
+
"""Container for user session data."""
|
| 33 |
+
|
| 34 |
+
def __init__(self, session_id: str = None):
|
| 35 |
+
self.session_id = session_id or str(uuid.uuid4())
|
| 36 |
+
self.app_instance = SimplifiedMedicalApp()
|
| 37 |
+
self.created_at = datetime.now().isoformat()
|
| 38 |
+
self.last_activity = datetime.now().isoformat()
|
| 39 |
+
|
| 40 |
+
def update_activity(self):
|
| 41 |
+
"""Update last activity timestamp."""
|
| 42 |
+
self.last_activity = datetime.now().isoformat()
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def create_simplified_interface():
|
| 46 |
+
"""
|
| 47 |
+
Create simplified Gradio interface.
|
| 48 |
+
|
| 49 |
+
Single medical assistant interface with background spiritual monitoring.
|
| 50 |
+
No mode selector - spiritual support is automatic and invisible.
|
| 51 |
+
|
| 52 |
+
Requirements: 1.3, 4.1, 12.1
|
| 53 |
+
"""
|
| 54 |
+
|
| 55 |
+
debug_mode = os.getenv("LOG_PROMPTS", "false").lower() == "true"
|
| 56 |
+
|
| 57 |
+
# Theme setup
|
| 58 |
+
theme_name = GRADIO_CONFIG.get("theme", "soft")
|
| 59 |
+
if theme_name.lower() == "soft":
|
| 60 |
+
theme = gr.themes.Soft()
|
| 61 |
+
else:
|
| 62 |
+
theme = gr.themes.Default()
|
| 63 |
+
|
| 64 |
+
demo = gr.Blocks(
|
| 65 |
+
title="🏥 Medical Assistant with Spiritual Support",
|
| 66 |
+
analytics_enabled=False
|
| 67 |
+
)
|
| 68 |
+
demo.theme = theme
|
| 69 |
+
|
| 70 |
+
with demo:
|
| 71 |
+
# Session state
|
| 72 |
+
session_data = gr.State(value=None)
|
| 73 |
+
|
| 74 |
+
# Header
|
| 75 |
+
gr.Markdown("# 🏥 Medical Assistant")
|
| 76 |
+
gr.Markdown("Your personal healthcare companion with integrated wellness support")
|
| 77 |
+
|
| 78 |
+
if debug_mode:
|
| 79 |
+
gr.Markdown("⚠️ **DEBUG MODE:** Prompts and responses are logged")
|
| 80 |
+
|
| 81 |
+
# Session info
|
| 82 |
+
with gr.Row():
|
| 83 |
+
session_info = gr.Markdown("🔄 **Initializing session...**")
|
| 84 |
+
|
| 85 |
+
# Initialize session
|
| 86 |
+
def initialize_session():
|
| 87 |
+
"""Initialize new user session."""
|
| 88 |
+
new_session = SimplifiedSessionData()
|
| 89 |
+
session_info_text = f"""
|
| 90 |
+
✅ **Session Ready**
|
| 91 |
+
🆔 Session: `{new_session.session_id[:8]}...`
|
| 92 |
+
🕒 Started: {new_session.created_at[:19]}
|
| 93 |
+
"""
|
| 94 |
+
return new_session, session_info_text
|
| 95 |
+
|
| 96 |
+
# Main interface
|
| 97 |
+
with gr.Tabs():
|
| 98 |
+
# Chat tab
|
| 99 |
+
with gr.TabItem("💬 Chat", id="chat"):
|
| 100 |
+
with gr.Row():
|
| 101 |
+
with gr.Column(scale=2):
|
| 102 |
+
chatbot = gr.Chatbot(
|
| 103 |
+
label="💬 Conversation",
|
| 104 |
+
height=450
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
+
with gr.Row():
|
| 108 |
+
msg = gr.Textbox(
|
| 109 |
+
label="Your message",
|
| 110 |
+
placeholder="Type your health question...",
|
| 111 |
+
scale=4
|
| 112 |
+
)
|
| 113 |
+
send_btn = gr.Button("📤 Send", scale=1, variant="primary")
|
| 114 |
+
|
| 115 |
+
with gr.Row():
|
| 116 |
+
clear_btn = gr.Button("🗑️ Clear Chat", scale=1)
|
| 117 |
+
|
| 118 |
+
# Quick examples
|
| 119 |
+
gr.Markdown("### ⚡ Quick Start:")
|
| 120 |
+
with gr.Row():
|
| 121 |
+
example_medical = gr.Button("🩺 I have a headache", size="sm")
|
| 122 |
+
example_wellness = gr.Button("💭 I'm feeling stressed", size="sm")
|
| 123 |
+
example_help = gr.Button("❓ How can you help?", size="sm")
|
| 124 |
+
|
| 125 |
+
with gr.Column(scale=1):
|
| 126 |
+
status_box = gr.Markdown(
|
| 127 |
+
value="🔄 Loading...",
|
| 128 |
+
label="📊 Status"
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
refresh_btn = gr.Button("🔄 Refresh Status", size="sm")
|
| 132 |
+
|
| 133 |
+
# Debug info (only in debug mode)
|
| 134 |
+
if debug_mode:
|
| 135 |
+
gr.Markdown("### 🔧 Debug Info")
|
| 136 |
+
debug_info = gr.Markdown(value="")
|
| 137 |
+
|
| 138 |
+
# Instructions tab
|
| 139 |
+
with gr.TabItem("📖 Help", id="help"):
|
| 140 |
+
gr.Markdown("""
|
| 141 |
+
## 📚 How to Use
|
| 142 |
+
|
| 143 |
+
### 🏥 Medical Questions
|
| 144 |
+
Ask about symptoms, medications, or health concerns:
|
| 145 |
+
- "I have a headache and feel tired"
|
| 146 |
+
- "What should I know about my blood pressure medication?"
|
| 147 |
+
- "I'm experiencing chest pain"
|
| 148 |
+
|
| 149 |
+
### 💭 Wellness Support
|
| 150 |
+
The assistant automatically provides emotional support when needed:
|
| 151 |
+
- If you mention feeling stressed, anxious, or sad
|
| 152 |
+
- If you have questions about meaning, faith, or purpose
|
| 153 |
+
- If you're going through a difficult time
|
| 154 |
+
|
| 155 |
+
### 🆘 Crisis Support
|
| 156 |
+
If you're in crisis, the assistant will:
|
| 157 |
+
- Provide immediate support resources
|
| 158 |
+
- Connect you with our spiritual care team
|
| 159 |
+
- Offer compassionate guidance
|
| 160 |
+
|
| 161 |
+
### ⚠️ Important
|
| 162 |
+
This assistant is not a replacement for professional medical care.
|
| 163 |
+
For emergencies, please call emergency services immediately.
|
| 164 |
+
""")
|
| 165 |
+
|
| 166 |
+
# Event handlers
|
| 167 |
+
def handle_message(message: str, history, session: SimplifiedSessionData):
|
| 168 |
+
"""Handle user message."""
|
| 169 |
+
if session is None:
|
| 170 |
+
session = SimplifiedSessionData()
|
| 171 |
+
|
| 172 |
+
session.update_activity()
|
| 173 |
+
new_history, status = session.app_instance.process_message(message, history)
|
| 174 |
+
return new_history, status, session, ""
|
| 175 |
+
|
| 176 |
+
def handle_clear(session: SimplifiedSessionData):
|
| 177 |
+
"""Handle clear chat."""
|
| 178 |
+
if session is None:
|
| 179 |
+
session = SimplifiedSessionData()
|
| 180 |
+
|
| 181 |
+
session.update_activity()
|
| 182 |
+
new_history, status = session.app_instance.reset_session()
|
| 183 |
+
return new_history, status, session
|
| 184 |
+
|
| 185 |
+
def get_status(session: SimplifiedSessionData):
|
| 186 |
+
"""Get current status."""
|
| 187 |
+
if session is None:
|
| 188 |
+
return "❌ Session not initialized"
|
| 189 |
+
|
| 190 |
+
session.update_activity()
|
| 191 |
+
return session.app_instance._get_status_info()
|
| 192 |
+
|
| 193 |
+
def send_example(example_text: str, history, session: SimplifiedSessionData):
|
| 194 |
+
"""Send example message."""
|
| 195 |
+
return handle_message(example_text, history, session)
|
| 196 |
+
|
| 197 |
+
# Bind events
|
| 198 |
+
demo.load(
|
| 199 |
+
initialize_session,
|
| 200 |
+
outputs=[session_data, session_info]
|
| 201 |
+
)
|
| 202 |
+
|
| 203 |
+
# Send message
|
| 204 |
+
send_btn.click(
|
| 205 |
+
handle_message,
|
| 206 |
+
inputs=[msg, chatbot, session_data],
|
| 207 |
+
outputs=[chatbot, status_box, session_data, msg]
|
| 208 |
+
)
|
| 209 |
+
|
| 210 |
+
msg.submit(
|
| 211 |
+
handle_message,
|
| 212 |
+
inputs=[msg, chatbot, session_data],
|
| 213 |
+
outputs=[chatbot, status_box, session_data, msg]
|
| 214 |
+
)
|
| 215 |
+
|
| 216 |
+
# Clear chat
|
| 217 |
+
clear_btn.click(
|
| 218 |
+
handle_clear,
|
| 219 |
+
inputs=[session_data],
|
| 220 |
+
outputs=[chatbot, status_box, session_data]
|
| 221 |
+
)
|
| 222 |
+
|
| 223 |
+
# Refresh status
|
| 224 |
+
refresh_btn.click(
|
| 225 |
+
get_status,
|
| 226 |
+
inputs=[session_data],
|
| 227 |
+
outputs=[status_box]
|
| 228 |
+
)
|
| 229 |
+
|
| 230 |
+
# Example buttons
|
| 231 |
+
example_medical.click(
|
| 232 |
+
lambda h, s: send_example("I have a headache and feel tired", h, s),
|
| 233 |
+
inputs=[chatbot, session_data],
|
| 234 |
+
outputs=[chatbot, status_box, session_data, msg]
|
| 235 |
+
)
|
| 236 |
+
|
| 237 |
+
example_wellness.click(
|
| 238 |
+
lambda h, s: send_example("I'm feeling stressed and overwhelmed lately", h, s),
|
| 239 |
+
inputs=[chatbot, session_data],
|
| 240 |
+
outputs=[chatbot, status_box, session_data, msg]
|
| 241 |
+
)
|
| 242 |
+
|
| 243 |
+
example_help.click(
|
| 244 |
+
lambda h, s: send_example("How can you help me with my health?", h, s),
|
| 245 |
+
inputs=[chatbot, session_data],
|
| 246 |
+
outputs=[chatbot, status_box, session_data, msg]
|
| 247 |
+
)
|
| 248 |
+
|
| 249 |
+
return demo
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
def main():
|
| 253 |
+
"""Launch the simplified Gradio interface."""
|
| 254 |
+
demo = create_simplified_interface()
|
| 255 |
+
|
| 256 |
+
# Get configuration
|
| 257 |
+
server_name = os.getenv("GRADIO_SERVER_NAME", "0.0.0.0")
|
| 258 |
+
server_port = int(os.getenv("GRADIO_SERVER_PORT", "7860"))
|
| 259 |
+
share = os.getenv("GRADIO_SHARE", "false").lower() == "true"
|
| 260 |
+
|
| 261 |
+
print(f"🚀 Starting Simplified Medical Assistant...")
|
| 262 |
+
print(f"📍 Server: http://{server_name}:{server_port}")
|
| 263 |
+
|
| 264 |
+
demo.launch(
|
| 265 |
+
server_name=server_name,
|
| 266 |
+
server_port=server_port,
|
| 267 |
+
share=share,
|
| 268 |
+
show_api=False
|
| 269 |
+
)
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
if __name__ == "__main__":
|
| 273 |
+
main()
|
|
@@ -0,0 +1,387 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# test_referral_language_properties.py
|
| 2 |
+
"""
|
| 3 |
+
Property-based tests for Referral Generation and Language Support.
|
| 4 |
+
|
| 5 |
+
Tests the correctness properties defined in the design document:
|
| 6 |
+
- Property 11: Referral Generation on Red
|
| 7 |
+
- Property 12: Referral Content Completeness
|
| 8 |
+
- Property 13: Language Matching
|
| 9 |
+
|
| 10 |
+
Requirements: 6.1, 6.2, 8.1, 8.2, 8.3, 8.4
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
import pytest
|
| 14 |
+
from hypothesis import given, strategies as st, settings
|
| 15 |
+
from unittest.mock import Mock, patch, MagicMock
|
| 16 |
+
from datetime import datetime
|
| 17 |
+
|
| 18 |
+
from src.core.spiritual_state import (
|
| 19 |
+
SpiritualState, SpiritualAssessment, SessionSpiritualState, TriageSession
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
# =============================================================================
|
| 24 |
+
# Property 11: Referral Generation on Red
|
| 25 |
+
# For any RED state confirmation, the system SHALL generate a referral message.
|
| 26 |
+
# Validates: Requirements 6.1
|
| 27 |
+
# =============================================================================
|
| 28 |
+
|
| 29 |
+
class TestReferralGenerationOnRed:
|
| 30 |
+
"""Property 11: Referral Generation on Red tests."""
|
| 31 |
+
|
| 32 |
+
def test_red_state_triggers_referral_generation(self):
|
| 33 |
+
"""RED state triggers referral generation."""
|
| 34 |
+
from src.core.simplified_medical_app import SimplifiedMedicalApp
|
| 35 |
+
|
| 36 |
+
# Mock the app to test referral generation
|
| 37 |
+
with patch.object(SimplifiedMedicalApp, '__init__', lambda x: None):
|
| 38 |
+
app = SimplifiedMedicalApp()
|
| 39 |
+
app.spiritual_state = SessionSpiritualState()
|
| 40 |
+
app.clinical_background = Mock()
|
| 41 |
+
app.clinical_background.patient_name = "Test Patient"
|
| 42 |
+
app._get_conversation_context_str = Mock(return_value="Test context")
|
| 43 |
+
app._generate_referral = SimplifiedMedicalApp._generate_referral.__get__(app)
|
| 44 |
+
|
| 45 |
+
assessment = SpiritualAssessment(
|
| 46 |
+
state=SpiritualState.RED,
|
| 47 |
+
indicators=["crisis", "hopelessness"],
|
| 48 |
+
confidence=0.9,
|
| 49 |
+
reasoning="Severe distress detected"
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
referral = app._generate_referral(assessment)
|
| 53 |
+
|
| 54 |
+
assert referral is not None
|
| 55 |
+
assert len(referral) > 0
|
| 56 |
+
assert "REFERRAL" in referral.upper()
|
| 57 |
+
|
| 58 |
+
def test_referral_generated_for_immediate_red(self):
|
| 59 |
+
"""Referral generated for immediate RED (keyword detection)."""
|
| 60 |
+
from src.core.simplified_medical_app import SimplifiedMedicalApp
|
| 61 |
+
|
| 62 |
+
with patch.object(SimplifiedMedicalApp, '__init__', lambda x: None):
|
| 63 |
+
app = SimplifiedMedicalApp()
|
| 64 |
+
app.spiritual_state = SessionSpiritualState()
|
| 65 |
+
app.clinical_background = Mock()
|
| 66 |
+
app.clinical_background.patient_name = "Test Patient"
|
| 67 |
+
app._get_conversation_context_str = Mock(return_value="Test context")
|
| 68 |
+
app._generate_referral = SimplifiedMedicalApp._generate_referral.__get__(app)
|
| 69 |
+
|
| 70 |
+
# Immediate RED from keyword
|
| 71 |
+
assessment = SpiritualAssessment(
|
| 72 |
+
state=SpiritualState.RED,
|
| 73 |
+
indicators=["suicide"],
|
| 74 |
+
confidence=1.0,
|
| 75 |
+
reasoning="Red flag keyword detected"
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
referral = app._generate_referral(assessment)
|
| 79 |
+
|
| 80 |
+
assert "REFERRAL" in referral.upper()
|
| 81 |
+
assert "suicide" in referral.lower()
|
| 82 |
+
|
| 83 |
+
def test_referral_generated_for_escalated_red(self):
|
| 84 |
+
"""Referral generated for escalated RED (from triage)."""
|
| 85 |
+
from src.core.simplified_medical_app import SimplifiedMedicalApp
|
| 86 |
+
|
| 87 |
+
with patch.object(SimplifiedMedicalApp, '__init__', lambda x: None):
|
| 88 |
+
app = SimplifiedMedicalApp()
|
| 89 |
+
app.spiritual_state = SessionSpiritualState()
|
| 90 |
+
app.spiritual_state.triage_session = TriageSession()
|
| 91 |
+
app.spiritual_state.triage_session.questions_asked = ["Q1", "Q2"]
|
| 92 |
+
app.spiritual_state.triage_session.patient_responses = ["R1", "R2"]
|
| 93 |
+
app.clinical_background = Mock()
|
| 94 |
+
app.clinical_background.patient_name = "Test Patient"
|
| 95 |
+
app._get_conversation_context_str = Mock(return_value="Test context")
|
| 96 |
+
app._generate_referral = SimplifiedMedicalApp._generate_referral.__get__(app)
|
| 97 |
+
|
| 98 |
+
assessment = SpiritualAssessment(
|
| 99 |
+
state=SpiritualState.RED,
|
| 100 |
+
indicators=["triage_escalation"],
|
| 101 |
+
confidence=0.8,
|
| 102 |
+
reasoning="Triage confirmed distress"
|
| 103 |
+
)
|
| 104 |
+
|
| 105 |
+
referral = app._generate_referral(assessment)
|
| 106 |
+
|
| 107 |
+
assert "REFERRAL" in referral.upper()
|
| 108 |
+
# Should include triage context
|
| 109 |
+
assert "Q1" in referral or "Triage" in referral
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
# =============================================================================
|
| 113 |
+
# Property 12: Referral Content Completeness
|
| 114 |
+
# For any generated referral, the message SHALL contain: patient concerns,
|
| 115 |
+
# distress indicators, and conversation context.
|
| 116 |
+
# Validates: Requirements 6.2
|
| 117 |
+
# =============================================================================
|
| 118 |
+
|
| 119 |
+
class TestReferralContentCompleteness:
|
| 120 |
+
"""Property 12: Referral Content Completeness tests."""
|
| 121 |
+
|
| 122 |
+
def test_referral_contains_patient_info(self):
|
| 123 |
+
"""Referral contains patient information."""
|
| 124 |
+
from src.core.simplified_medical_app import SimplifiedMedicalApp
|
| 125 |
+
|
| 126 |
+
with patch.object(SimplifiedMedicalApp, '__init__', lambda x: None):
|
| 127 |
+
app = SimplifiedMedicalApp()
|
| 128 |
+
app.spiritual_state = SessionSpiritualState()
|
| 129 |
+
app.clinical_background = Mock()
|
| 130 |
+
app.clinical_background.patient_name = "John Doe"
|
| 131 |
+
app._get_conversation_context_str = Mock(return_value="Test context")
|
| 132 |
+
app._generate_referral = SimplifiedMedicalApp._generate_referral.__get__(app)
|
| 133 |
+
|
| 134 |
+
assessment = SpiritualAssessment(
|
| 135 |
+
state=SpiritualState.RED,
|
| 136 |
+
indicators=["crisis"],
|
| 137 |
+
confidence=0.9,
|
| 138 |
+
reasoning="Test"
|
| 139 |
+
)
|
| 140 |
+
|
| 141 |
+
referral = app._generate_referral(assessment)
|
| 142 |
+
|
| 143 |
+
assert "John Doe" in referral
|
| 144 |
+
|
| 145 |
+
def test_referral_contains_distress_indicators(self):
|
| 146 |
+
"""Referral contains distress indicators."""
|
| 147 |
+
from src.core.simplified_medical_app import SimplifiedMedicalApp
|
| 148 |
+
|
| 149 |
+
with patch.object(SimplifiedMedicalApp, '__init__', lambda x: None):
|
| 150 |
+
app = SimplifiedMedicalApp()
|
| 151 |
+
app.spiritual_state = SessionSpiritualState()
|
| 152 |
+
app.clinical_background = Mock()
|
| 153 |
+
app.clinical_background.patient_name = "Test Patient"
|
| 154 |
+
app._get_conversation_context_str = Mock(return_value="Test context")
|
| 155 |
+
app._generate_referral = SimplifiedMedicalApp._generate_referral.__get__(app)
|
| 156 |
+
|
| 157 |
+
assessment = SpiritualAssessment(
|
| 158 |
+
state=SpiritualState.RED,
|
| 159 |
+
indicators=["hopelessness", "isolation", "grief"],
|
| 160 |
+
confidence=0.85,
|
| 161 |
+
reasoning="Multiple indicators"
|
| 162 |
+
)
|
| 163 |
+
|
| 164 |
+
referral = app._generate_referral(assessment)
|
| 165 |
+
|
| 166 |
+
assert "hopelessness" in referral.lower()
|
| 167 |
+
assert "isolation" in referral.lower()
|
| 168 |
+
assert "grief" in referral.lower()
|
| 169 |
+
|
| 170 |
+
def test_referral_contains_conversation_context(self):
|
| 171 |
+
"""Referral contains conversation context."""
|
| 172 |
+
from src.core.simplified_medical_app import SimplifiedMedicalApp
|
| 173 |
+
|
| 174 |
+
with patch.object(SimplifiedMedicalApp, '__init__', lambda x: None):
|
| 175 |
+
app = SimplifiedMedicalApp()
|
| 176 |
+
app.spiritual_state = SessionSpiritualState()
|
| 177 |
+
app.clinical_background = Mock()
|
| 178 |
+
app.clinical_background.patient_name = "Test Patient"
|
| 179 |
+
app._get_conversation_context_str = Mock(return_value="Patient said: I feel very sad")
|
| 180 |
+
app._generate_referral = SimplifiedMedicalApp._generate_referral.__get__(app)
|
| 181 |
+
|
| 182 |
+
assessment = SpiritualAssessment(
|
| 183 |
+
state=SpiritualState.RED,
|
| 184 |
+
indicators=["sadness"],
|
| 185 |
+
confidence=0.8,
|
| 186 |
+
reasoning="Test"
|
| 187 |
+
)
|
| 188 |
+
|
| 189 |
+
referral = app._generate_referral(assessment)
|
| 190 |
+
|
| 191 |
+
assert "I feel very sad" in referral
|
| 192 |
+
|
| 193 |
+
def test_referral_contains_classification_info(self):
|
| 194 |
+
"""Referral contains classification information."""
|
| 195 |
+
from src.core.simplified_medical_app import SimplifiedMedicalApp
|
| 196 |
+
|
| 197 |
+
with patch.object(SimplifiedMedicalApp, '__init__', lambda x: None):
|
| 198 |
+
app = SimplifiedMedicalApp()
|
| 199 |
+
app.spiritual_state = SessionSpiritualState()
|
| 200 |
+
app.clinical_background = Mock()
|
| 201 |
+
app.clinical_background.patient_name = "Test Patient"
|
| 202 |
+
app._get_conversation_context_str = Mock(return_value="Test context")
|
| 203 |
+
app._generate_referral = SimplifiedMedicalApp._generate_referral.__get__(app)
|
| 204 |
+
|
| 205 |
+
assessment = SpiritualAssessment(
|
| 206 |
+
state=SpiritualState.RED,
|
| 207 |
+
indicators=["crisis"],
|
| 208 |
+
confidence=0.95,
|
| 209 |
+
reasoning="Severe crisis detected"
|
| 210 |
+
)
|
| 211 |
+
|
| 212 |
+
referral = app._generate_referral(assessment)
|
| 213 |
+
|
| 214 |
+
assert "RED" in referral.upper()
|
| 215 |
+
assert "95%" in referral or "0.95" in referral
|
| 216 |
+
assert "Severe crisis detected" in referral
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
# =============================================================================
|
| 220 |
+
# Property 13: Language Matching
|
| 221 |
+
# For any system response, the language SHALL match the patient's input language.
|
| 222 |
+
# Validates: Requirements 8.1, 8.2, 8.3, 8.4
|
| 223 |
+
# =============================================================================
|
| 224 |
+
|
| 225 |
+
class TestLanguageMatching:
|
| 226 |
+
"""Property 13: Language Matching tests."""
|
| 227 |
+
|
| 228 |
+
def test_detect_english_text(self):
|
| 229 |
+
"""Detect English text correctly."""
|
| 230 |
+
from src.core.simplified_medical_app import SimplifiedMedicalApp
|
| 231 |
+
|
| 232 |
+
with patch.object(SimplifiedMedicalApp, '__init__', lambda x: None):
|
| 233 |
+
app = SimplifiedMedicalApp()
|
| 234 |
+
app._detect_language = SimplifiedMedicalApp._detect_language.__get__(app)
|
| 235 |
+
|
| 236 |
+
assert app._detect_language("Hello, how are you?") == "English"
|
| 237 |
+
assert app._detect_language("I have a headache") == "English"
|
| 238 |
+
assert app._detect_language("What is the meaning of life?") == "English"
|
| 239 |
+
|
| 240 |
+
def test_detect_ukrainian_text(self):
|
| 241 |
+
"""Detect Ukrainian text correctly."""
|
| 242 |
+
from src.core.simplified_medical_app import SimplifiedMedicalApp
|
| 243 |
+
|
| 244 |
+
with patch.object(SimplifiedMedicalApp, '__init__', lambda x: None):
|
| 245 |
+
app = SimplifiedMedicalApp()
|
| 246 |
+
app._detect_language = SimplifiedMedicalApp._detect_language.__get__(app)
|
| 247 |
+
|
| 248 |
+
assert app._detect_language("Привіт, як справи?") == "Ukrainian"
|
| 249 |
+
assert app._detect_language("У мене болить голова") == "Ukrainian"
|
| 250 |
+
assert app._detect_language("Мені сумно") == "Ukrainian"
|
| 251 |
+
|
| 252 |
+
def test_crisis_response_english(self):
|
| 253 |
+
"""Crisis response in English for English input."""
|
| 254 |
+
from src.core.simplified_medical_app import SimplifiedMedicalApp
|
| 255 |
+
|
| 256 |
+
with patch.object(SimplifiedMedicalApp, '__init__', lambda x: None):
|
| 257 |
+
app = SimplifiedMedicalApp()
|
| 258 |
+
app._generate_crisis_response = SimplifiedMedicalApp._generate_crisis_response.__get__(app)
|
| 259 |
+
|
| 260 |
+
assessment = SpiritualAssessment(
|
| 261 |
+
state=SpiritualState.RED,
|
| 262 |
+
indicators=["crisis"],
|
| 263 |
+
confidence=0.9,
|
| 264 |
+
reasoning="Test"
|
| 265 |
+
)
|
| 266 |
+
|
| 267 |
+
response = app._generate_crisis_response("English", assessment)
|
| 268 |
+
|
| 269 |
+
# Should be in English
|
| 270 |
+
assert "I hear you" in response or "support" in response.lower()
|
| 271 |
+
assert "988" in response # US crisis line
|
| 272 |
+
|
| 273 |
+
def test_crisis_response_ukrainian(self):
|
| 274 |
+
"""Crisis response in Ukrainian for Ukrainian input."""
|
| 275 |
+
from src.core.simplified_medical_app import SimplifiedMedicalApp
|
| 276 |
+
|
| 277 |
+
with patch.object(SimplifiedMedicalApp, '__init__', lambda x: None):
|
| 278 |
+
app = SimplifiedMedicalApp()
|
| 279 |
+
app._generate_crisis_response = SimplifiedMedicalApp._generate_crisis_response.__get__(app)
|
| 280 |
+
|
| 281 |
+
assessment = SpiritualAssessment(
|
| 282 |
+
state=SpiritualState.RED,
|
| 283 |
+
indicators=["crisis"],
|
| 284 |
+
confidence=0.9,
|
| 285 |
+
reasoning="Test"
|
| 286 |
+
)
|
| 287 |
+
|
| 288 |
+
response = app._generate_crisis_response("Ukrainian", assessment)
|
| 289 |
+
|
| 290 |
+
# Should be in Ukrainian
|
| 291 |
+
assert "Я чую вас" in response or "підтримка" in response.lower()
|
| 292 |
+
assert "7333" in response # Ukrainian crisis line
|
| 293 |
+
|
| 294 |
+
def test_triage_resolution_english(self):
|
| 295 |
+
"""Triage resolution message in English."""
|
| 296 |
+
from src.core.simplified_medical_app import SimplifiedMedicalApp
|
| 297 |
+
|
| 298 |
+
with patch.object(SimplifiedMedicalApp, '__init__', lambda x: None):
|
| 299 |
+
app = SimplifiedMedicalApp()
|
| 300 |
+
app.spiritual_state = SessionSpiritualState()
|
| 301 |
+
app.spiritual_state.triage_session = TriageSession()
|
| 302 |
+
app.spiritual_state.triage_session.patient_responses = ["I'm feeling better"]
|
| 303 |
+
app._detect_language = SimplifiedMedicalApp._detect_language.__get__(app)
|
| 304 |
+
app._resolve_to_green = SimplifiedMedicalApp._resolve_to_green.__get__(app)
|
| 305 |
+
|
| 306 |
+
response = app._resolve_to_green("Patient has support")
|
| 307 |
+
|
| 308 |
+
# Should be in English
|
| 309 |
+
assert "Thank you" in response or "glad" in response.lower()
|
| 310 |
+
|
| 311 |
+
def test_triage_resolution_ukrainian(self):
|
| 312 |
+
"""Triage resolution message in Ukrainian."""
|
| 313 |
+
from src.core.simplified_medical_app import SimplifiedMedicalApp
|
| 314 |
+
|
| 315 |
+
with patch.object(SimplifiedMedicalApp, '__init__', lambda x: None):
|
| 316 |
+
app = SimplifiedMedicalApp()
|
| 317 |
+
app.spiritual_state = SessionSpiritualState()
|
| 318 |
+
# Transition to YELLOW first to create triage session
|
| 319 |
+
app.spiritual_state.transition_to(SpiritualState.YELLOW, "test")
|
| 320 |
+
app.spiritual_state.triage_session.patient_responses = ["Мені краще, дякую"]
|
| 321 |
+
app._detect_language = SimplifiedMedicalApp._detect_language.__get__(app)
|
| 322 |
+
app._resolve_to_green = SimplifiedMedicalApp._resolve_to_green.__get__(app)
|
| 323 |
+
|
| 324 |
+
response = app._resolve_to_green("Patient has support")
|
| 325 |
+
|
| 326 |
+
# Should be in Ukrainian (detected from patient response)
|
| 327 |
+
assert "Дякую" in response or "радий" in response.lower()
|
| 328 |
+
|
| 329 |
+
def test_fallback_triage_question_english(self):
|
| 330 |
+
"""Fallback triage question in English."""
|
| 331 |
+
from src.core.simplified_medical_app import SimplifiedMedicalApp
|
| 332 |
+
|
| 333 |
+
with patch.object(SimplifiedMedicalApp, '__init__', lambda x: None):
|
| 334 |
+
app = SimplifiedMedicalApp()
|
| 335 |
+
app._get_fallback_triage_question = SimplifiedMedicalApp._get_fallback_triage_question.__get__(app)
|
| 336 |
+
|
| 337 |
+
question = app._get_fallback_triage_question("English")
|
| 338 |
+
|
| 339 |
+
assert "How" in question or "feeling" in question.lower()
|
| 340 |
+
|
| 341 |
+
def test_fallback_triage_question_ukrainian(self):
|
| 342 |
+
"""Fallback triage question in Ukrainian."""
|
| 343 |
+
from src.core.simplified_medical_app import SimplifiedMedicalApp
|
| 344 |
+
|
| 345 |
+
with patch.object(SimplifiedMedicalApp, '__init__', lambda x: None):
|
| 346 |
+
app = SimplifiedMedicalApp()
|
| 347 |
+
app._get_fallback_triage_question = SimplifiedMedicalApp._get_fallback_triage_question.__get__(app)
|
| 348 |
+
|
| 349 |
+
question = app._get_fallback_triage_question("Ukrainian")
|
| 350 |
+
|
| 351 |
+
assert "Як" in question or "почуваєтесь" in question.lower()
|
| 352 |
+
|
| 353 |
+
|
| 354 |
+
# =============================================================================
|
| 355 |
+
# Mixed Language Tests
|
| 356 |
+
# =============================================================================
|
| 357 |
+
|
| 358 |
+
class TestMixedLanguage:
|
| 359 |
+
"""Tests for mixed language scenarios."""
|
| 360 |
+
|
| 361 |
+
def test_detect_mixed_defaults_to_dominant(self):
|
| 362 |
+
"""Mixed text defaults to dominant language."""
|
| 363 |
+
from src.core.simplified_medical_app import SimplifiedMedicalApp
|
| 364 |
+
|
| 365 |
+
with patch.object(SimplifiedMedicalApp, '__init__', lambda x: None):
|
| 366 |
+
app = SimplifiedMedicalApp()
|
| 367 |
+
app._detect_language = SimplifiedMedicalApp._detect_language.__get__(app)
|
| 368 |
+
|
| 369 |
+
# Mostly English with some Ukrainian
|
| 370 |
+
result = app._detect_language("Hello, як справи today?")
|
| 371 |
+
# Should detect based on character ratio
|
| 372 |
+
assert result in ["English", "Ukrainian"]
|
| 373 |
+
|
| 374 |
+
def test_empty_text_defaults_to_english(self):
|
| 375 |
+
"""Empty text defaults to English."""
|
| 376 |
+
from src.core.simplified_medical_app import SimplifiedMedicalApp
|
| 377 |
+
|
| 378 |
+
with patch.object(SimplifiedMedicalApp, '__init__', lambda x: None):
|
| 379 |
+
app = SimplifiedMedicalApp()
|
| 380 |
+
app._detect_language = SimplifiedMedicalApp._detect_language.__get__(app)
|
| 381 |
+
|
| 382 |
+
assert app._detect_language("") == "English"
|
| 383 |
+
assert app._detect_language(" ") == "English"
|
| 384 |
+
|
| 385 |
+
|
| 386 |
+
if __name__ == "__main__":
|
| 387 |
+
pytest.main([__file__, "-v"])
|
|
@@ -0,0 +1,361 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# test_simplified_app_properties.py
|
| 2 |
+
"""
|
| 3 |
+
Property-based tests for Simplified Medical App integration.
|
| 4 |
+
|
| 5 |
+
Tests the correctness properties defined in the design document:
|
| 6 |
+
- Property 1: Spiritual Monitor Always Invoked
|
| 7 |
+
- Property 2: Green State Preservation
|
| 8 |
+
- Property 3: Yellow Triggers Triage
|
| 9 |
+
- Property 4: Red Triggers Immediate Referral
|
| 10 |
+
|
| 11 |
+
Requirements: 2.1, 2.2, 2.3, 2.4
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
import pytest
|
| 15 |
+
from hypothesis import given, strategies as st, settings
|
| 16 |
+
from unittest.mock import Mock, patch, MagicMock
|
| 17 |
+
|
| 18 |
+
from src.core.spiritual_state import (
|
| 19 |
+
SpiritualState, TriageOutcome, SpiritualAssessment, SessionSpiritualState
|
| 20 |
+
)
|
| 21 |
+
from src.core.spiritual_monitor import SpiritualMonitor
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
# =============================================================================
|
| 25 |
+
# Property 1: Spiritual Monitor Always Invoked
|
| 26 |
+
# For any patient message, the Spiritual Monitor SHALL be invoked to classify
|
| 27 |
+
# the message before generating a response.
|
| 28 |
+
# Validates: Requirements 2.1
|
| 29 |
+
# =============================================================================
|
| 30 |
+
|
| 31 |
+
class TestSpiritualMonitorInvocation:
|
| 32 |
+
"""Property 1: Spiritual Monitor Always Invoked tests."""
|
| 33 |
+
|
| 34 |
+
def test_monitor_called_for_normal_message(self):
|
| 35 |
+
"""Monitor is called for normal medical message."""
|
| 36 |
+
# Create mock components
|
| 37 |
+
mock_api = Mock()
|
| 38 |
+
mock_api.call_spiritual_api = Mock(return_value='{"state": "green", "indicators": [], "confidence": 0.9, "reasoning": "Normal"}')
|
| 39 |
+
mock_api.call_medical_api = Mock(return_value="Medical response")
|
| 40 |
+
|
| 41 |
+
monitor = SpiritualMonitor(mock_api)
|
| 42 |
+
|
| 43 |
+
# Classify a normal message
|
| 44 |
+
result = monitor.classify("I have a headache")
|
| 45 |
+
|
| 46 |
+
# Monitor should return a result
|
| 47 |
+
assert result is not None
|
| 48 |
+
assert isinstance(result, SpiritualAssessment)
|
| 49 |
+
|
| 50 |
+
def test_monitor_called_for_emotional_message(self):
|
| 51 |
+
"""Monitor is called for emotional message."""
|
| 52 |
+
mock_api = Mock()
|
| 53 |
+
mock_api.call_spiritual_api = Mock(return_value='{"state": "yellow", "indicators": ["sadness"], "confidence": 0.7, "reasoning": "Emotional"}')
|
| 54 |
+
|
| 55 |
+
monitor = SpiritualMonitor(mock_api)
|
| 56 |
+
|
| 57 |
+
result = monitor.classify("I feel sad today")
|
| 58 |
+
|
| 59 |
+
assert result is not None
|
| 60 |
+
assert result.state == SpiritualState.YELLOW
|
| 61 |
+
|
| 62 |
+
def test_monitor_called_for_crisis_message(self):
|
| 63 |
+
"""Monitor is called for crisis message (but uses keyword detection)."""
|
| 64 |
+
mock_api = Mock()
|
| 65 |
+
monitor = SpiritualMonitor(mock_api)
|
| 66 |
+
|
| 67 |
+
result = monitor.classify("I want to end my life")
|
| 68 |
+
|
| 69 |
+
# Should detect via keywords, not API
|
| 70 |
+
assert result is not None
|
| 71 |
+
assert result.state == SpiritualState.RED
|
| 72 |
+
# API should NOT be called for red flag keywords
|
| 73 |
+
mock_api.call_spiritual_api.assert_not_called()
|
| 74 |
+
|
| 75 |
+
@given(st.text(min_size=1, max_size=200))
|
| 76 |
+
@settings(max_examples=20)
|
| 77 |
+
def test_monitor_always_returns_assessment(self, message):
|
| 78 |
+
"""Monitor always returns a valid assessment for any message."""
|
| 79 |
+
mock_api = Mock()
|
| 80 |
+
mock_api.call_spiritual_api = Mock(return_value='{"state": "green", "indicators": [], "confidence": 0.9, "reasoning": "Normal"}')
|
| 81 |
+
|
| 82 |
+
monitor = SpiritualMonitor(mock_api)
|
| 83 |
+
|
| 84 |
+
result = monitor.classify(message)
|
| 85 |
+
|
| 86 |
+
assert result is not None
|
| 87 |
+
assert isinstance(result, SpiritualAssessment)
|
| 88 |
+
assert result.state in SpiritualState
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
# =============================================================================
|
| 92 |
+
# Property 2: Green State Preservation
|
| 93 |
+
# For any message classified as no-distress (GREEN), the system SHALL remain
|
| 94 |
+
# in GREEN state and continue medical dialog.
|
| 95 |
+
# Validates: Requirements 2.2
|
| 96 |
+
# =============================================================================
|
| 97 |
+
|
| 98 |
+
class TestGreenStatePreservation:
|
| 99 |
+
"""Property 2: Green State Preservation tests."""
|
| 100 |
+
|
| 101 |
+
def test_green_classification_preserves_green_state(self):
|
| 102 |
+
"""GREEN classification keeps session in GREEN state."""
|
| 103 |
+
session = SessionSpiritualState()
|
| 104 |
+
assert session.spiritual_state == SpiritualState.GREEN
|
| 105 |
+
|
| 106 |
+
# Simulate GREEN classification - state should remain GREEN
|
| 107 |
+
# (no transition needed for GREEN -> GREEN)
|
| 108 |
+
assert session.spiritual_state == SpiritualState.GREEN
|
| 109 |
+
assert not session.is_in_triage()
|
| 110 |
+
|
| 111 |
+
def test_green_does_not_create_triage_session(self):
|
| 112 |
+
"""GREEN state does not create triage session."""
|
| 113 |
+
session = SessionSpiritualState()
|
| 114 |
+
|
| 115 |
+
# Stay in GREEN
|
| 116 |
+
assert session.triage_session is None
|
| 117 |
+
|
| 118 |
+
# Even after explicit transition to GREEN
|
| 119 |
+
session.transition_to(SpiritualState.GREEN, "test")
|
| 120 |
+
assert session.triage_session is None
|
| 121 |
+
|
| 122 |
+
def test_multiple_green_messages_stay_green(self):
|
| 123 |
+
"""Multiple GREEN messages keep session in GREEN."""
|
| 124 |
+
session = SessionSpiritualState()
|
| 125 |
+
|
| 126 |
+
# Simulate multiple GREEN classifications
|
| 127 |
+
for i in range(5):
|
| 128 |
+
# Each GREEN message should keep state GREEN
|
| 129 |
+
assert session.spiritual_state == SpiritualState.GREEN
|
| 130 |
+
assert not session.is_in_triage()
|
| 131 |
+
|
| 132 |
+
@given(st.integers(min_value=1, max_value=20))
|
| 133 |
+
def test_n_green_messages_preserve_state(self, n):
|
| 134 |
+
"""N consecutive GREEN messages preserve GREEN state."""
|
| 135 |
+
session = SessionSpiritualState()
|
| 136 |
+
|
| 137 |
+
for _ in range(n):
|
| 138 |
+
# Simulate GREEN classification
|
| 139 |
+
assert session.spiritual_state == SpiritualState.GREEN
|
| 140 |
+
|
| 141 |
+
# Still GREEN after N messages
|
| 142 |
+
assert session.spiritual_state == SpiritualState.GREEN
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
# =============================================================================
|
| 146 |
+
# Property 3: Yellow Triggers Triage
|
| 147 |
+
# For any message classified as potential-distress (YELLOW), the system SHALL
|
| 148 |
+
# enter YELLOW state and initiate Soft Triage.
|
| 149 |
+
# Validates: Requirements 2.3
|
| 150 |
+
# =============================================================================
|
| 151 |
+
|
| 152 |
+
class TestYellowTriggersTriage:
|
| 153 |
+
"""Property 3: Yellow Triggers Triage tests."""
|
| 154 |
+
|
| 155 |
+
def test_yellow_classification_triggers_triage(self):
|
| 156 |
+
"""YELLOW classification triggers triage state."""
|
| 157 |
+
session = SessionSpiritualState()
|
| 158 |
+
assert session.spiritual_state == SpiritualState.GREEN
|
| 159 |
+
|
| 160 |
+
# Transition to YELLOW
|
| 161 |
+
session.transition_to(SpiritualState.YELLOW, "Potential distress detected")
|
| 162 |
+
|
| 163 |
+
assert session.spiritual_state == SpiritualState.YELLOW
|
| 164 |
+
assert session.is_in_triage()
|
| 165 |
+
assert session.triage_session is not None
|
| 166 |
+
|
| 167 |
+
def test_yellow_creates_fresh_triage_session(self):
|
| 168 |
+
"""YELLOW creates a fresh triage session."""
|
| 169 |
+
session = SessionSpiritualState()
|
| 170 |
+
|
| 171 |
+
session.transition_to(SpiritualState.YELLOW, "test")
|
| 172 |
+
|
| 173 |
+
assert session.triage_session is not None
|
| 174 |
+
assert session.triage_session.question_count == 0
|
| 175 |
+
assert len(session.triage_session.questions_asked) == 0
|
| 176 |
+
assert len(session.triage_session.patient_responses) == 0
|
| 177 |
+
|
| 178 |
+
def test_yellow_from_green_logs_transition(self):
|
| 179 |
+
"""GREEN -> YELLOW transition is logged."""
|
| 180 |
+
session = SessionSpiritualState()
|
| 181 |
+
|
| 182 |
+
session.transition_to(SpiritualState.YELLOW, "Sadness detected")
|
| 183 |
+
|
| 184 |
+
assert len(session.state_history) > 0
|
| 185 |
+
assert "green -> yellow" in session.state_history[-1].lower()
|
| 186 |
+
|
| 187 |
+
def test_yellow_indicator_message_triggers_yellow(self):
|
| 188 |
+
"""Message with yellow indicators triggers YELLOW classification."""
|
| 189 |
+
mock_api = Mock()
|
| 190 |
+
mock_api.call_spiritual_api = Mock(return_value='{"state": "yellow", "indicators": ["sadness"], "confidence": 0.7, "reasoning": "Emotional content"}')
|
| 191 |
+
|
| 192 |
+
monitor = SpiritualMonitor(mock_api)
|
| 193 |
+
|
| 194 |
+
result = monitor.classify("I feel sad and lonely")
|
| 195 |
+
|
| 196 |
+
assert result.state == SpiritualState.YELLOW
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
# =============================================================================
|
| 200 |
+
# Property 4: Red Triggers Immediate Referral
|
| 201 |
+
# For any message classified as severe-distress (RED), the system SHALL
|
| 202 |
+
# generate a referral immediately without triage.
|
| 203 |
+
# Validates: Requirements 2.4
|
| 204 |
+
# =============================================================================
|
| 205 |
+
|
| 206 |
+
class TestRedTriggersReferral:
|
| 207 |
+
"""Property 4: Red Triggers Immediate Referral tests."""
|
| 208 |
+
|
| 209 |
+
def test_red_classification_triggers_red_state(self):
|
| 210 |
+
"""RED classification triggers RED state."""
|
| 211 |
+
session = SessionSpiritualState()
|
| 212 |
+
|
| 213 |
+
session.transition_to(SpiritualState.RED, "Crisis detected")
|
| 214 |
+
|
| 215 |
+
assert session.spiritual_state == SpiritualState.RED
|
| 216 |
+
assert not session.is_in_triage() # No triage for RED
|
| 217 |
+
|
| 218 |
+
def test_red_skips_triage(self):
|
| 219 |
+
"""RED state does not create triage session."""
|
| 220 |
+
session = SessionSpiritualState()
|
| 221 |
+
|
| 222 |
+
# Direct to RED (not through YELLOW)
|
| 223 |
+
session.transition_to(SpiritualState.RED, "Immediate crisis")
|
| 224 |
+
|
| 225 |
+
assert session.triage_session is None
|
| 226 |
+
|
| 227 |
+
def test_red_from_green_is_immediate(self):
|
| 228 |
+
"""GREEN -> RED transition is immediate (no YELLOW intermediate)."""
|
| 229 |
+
session = SessionSpiritualState()
|
| 230 |
+
assert session.spiritual_state == SpiritualState.GREEN
|
| 231 |
+
|
| 232 |
+
# Direct transition to RED
|
| 233 |
+
session.transition_to(SpiritualState.RED, "Suicide keyword detected")
|
| 234 |
+
|
| 235 |
+
assert session.spiritual_state == SpiritualState.RED
|
| 236 |
+
# Should have only one transition logged
|
| 237 |
+
assert len(session.state_history) == 1
|
| 238 |
+
|
| 239 |
+
def test_red_keyword_triggers_immediate_red(self):
|
| 240 |
+
"""Red flag keyword triggers immediate RED classification."""
|
| 241 |
+
mock_api = Mock()
|
| 242 |
+
monitor = SpiritualMonitor(mock_api)
|
| 243 |
+
|
| 244 |
+
result = monitor.classify("I want to kill myself")
|
| 245 |
+
|
| 246 |
+
assert result.state == SpiritualState.RED
|
| 247 |
+
assert result.confidence == 1.0 # Keyword detection is certain
|
| 248 |
+
|
| 249 |
+
@pytest.mark.parametrize("keyword", [
|
| 250 |
+
"suicide", "kill myself", "want to die", "hopeless", "end my life"
|
| 251 |
+
])
|
| 252 |
+
def test_various_red_keywords_trigger_red(self, keyword):
|
| 253 |
+
"""Various red flag keywords trigger RED."""
|
| 254 |
+
mock_api = Mock()
|
| 255 |
+
monitor = SpiritualMonitor(mock_api)
|
| 256 |
+
|
| 257 |
+
result = monitor.classify(f"I feel {keyword}")
|
| 258 |
+
|
| 259 |
+
assert result.state == SpiritualState.RED
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
# =============================================================================
|
| 263 |
+
# State Transition Flow Tests
|
| 264 |
+
# =============================================================================
|
| 265 |
+
|
| 266 |
+
class TestStateTransitionFlows:
|
| 267 |
+
"""Tests for complete state transition flows."""
|
| 268 |
+
|
| 269 |
+
def test_green_to_yellow_to_green_flow(self):
|
| 270 |
+
"""GREEN -> YELLOW -> GREEN flow (resolved triage)."""
|
| 271 |
+
session = SessionSpiritualState()
|
| 272 |
+
|
| 273 |
+
# Start GREEN
|
| 274 |
+
assert session.spiritual_state == SpiritualState.GREEN
|
| 275 |
+
|
| 276 |
+
# Detect potential distress -> YELLOW
|
| 277 |
+
session.transition_to(SpiritualState.YELLOW, "Sadness detected")
|
| 278 |
+
assert session.spiritual_state == SpiritualState.YELLOW
|
| 279 |
+
assert session.is_in_triage()
|
| 280 |
+
|
| 281 |
+
# Triage resolves positively -> GREEN
|
| 282 |
+
session.transition_to(SpiritualState.GREEN, "Patient has support")
|
| 283 |
+
assert session.spiritual_state == SpiritualState.GREEN
|
| 284 |
+
assert not session.is_in_triage()
|
| 285 |
+
assert session.triage_session is None
|
| 286 |
+
|
| 287 |
+
def test_green_to_yellow_to_red_flow(self):
|
| 288 |
+
"""GREEN -> YELLOW -> RED flow (escalated triage)."""
|
| 289 |
+
session = SessionSpiritualState()
|
| 290 |
+
|
| 291 |
+
# Start GREEN
|
| 292 |
+
assert session.spiritual_state == SpiritualState.GREEN
|
| 293 |
+
|
| 294 |
+
# Detect potential distress -> YELLOW
|
| 295 |
+
session.transition_to(SpiritualState.YELLOW, "Distress indicators")
|
| 296 |
+
assert session.spiritual_state == SpiritualState.YELLOW
|
| 297 |
+
|
| 298 |
+
# Triage confirms severe distress -> RED
|
| 299 |
+
session.transition_to(SpiritualState.RED, "Distress confirmed")
|
| 300 |
+
assert session.spiritual_state == SpiritualState.RED
|
| 301 |
+
assert not session.is_in_triage()
|
| 302 |
+
|
| 303 |
+
def test_green_to_red_immediate_flow(self):
|
| 304 |
+
"""GREEN -> RED immediate flow (crisis keywords)."""
|
| 305 |
+
session = SessionSpiritualState()
|
| 306 |
+
|
| 307 |
+
# Start GREEN
|
| 308 |
+
assert session.spiritual_state == SpiritualState.GREEN
|
| 309 |
+
|
| 310 |
+
# Immediate crisis -> RED (skip YELLOW)
|
| 311 |
+
session.transition_to(SpiritualState.RED, "Suicide keyword")
|
| 312 |
+
assert session.spiritual_state == SpiritualState.RED
|
| 313 |
+
|
| 314 |
+
# Should never have been in YELLOW
|
| 315 |
+
assert "yellow" not in " ".join(session.state_history).lower() or "red" in session.state_history[-1].lower()
|
| 316 |
+
|
| 317 |
+
|
| 318 |
+
# =============================================================================
|
| 319 |
+
# Language Detection Tests
|
| 320 |
+
# =============================================================================
|
| 321 |
+
|
| 322 |
+
class TestLanguageDetection:
|
| 323 |
+
"""Tests for language detection functionality."""
|
| 324 |
+
|
| 325 |
+
def test_detect_english(self):
|
| 326 |
+
"""Detect English text."""
|
| 327 |
+
from src.core.simplified_medical_app import SimplifiedMedicalApp
|
| 328 |
+
|
| 329 |
+
# Create minimal mock to test language detection
|
| 330 |
+
with patch.object(SimplifiedMedicalApp, '__init__', lambda x: None):
|
| 331 |
+
app = SimplifiedMedicalApp()
|
| 332 |
+
app._detect_language = SimplifiedMedicalApp._detect_language.__get__(app)
|
| 333 |
+
|
| 334 |
+
assert app._detect_language("Hello, how are you?") == "English"
|
| 335 |
+
assert app._detect_language("I have a headache") == "English"
|
| 336 |
+
|
| 337 |
+
def test_detect_ukrainian(self):
|
| 338 |
+
"""Detect Ukrainian text."""
|
| 339 |
+
from src.core.simplified_medical_app import SimplifiedMedicalApp
|
| 340 |
+
|
| 341 |
+
with patch.object(SimplifiedMedicalApp, '__init__', lambda x: None):
|
| 342 |
+
app = SimplifiedMedicalApp()
|
| 343 |
+
app._detect_language = SimplifiedMedicalApp._detect_language.__get__(app)
|
| 344 |
+
|
| 345 |
+
assert app._detect_language("Привіт, як справи?") == "Ukrainian"
|
| 346 |
+
assert app._detect_language("У мене болить голова") == "Ukrainian"
|
| 347 |
+
|
| 348 |
+
def test_detect_empty_defaults_to_english(self):
|
| 349 |
+
"""Empty text defaults to English."""
|
| 350 |
+
from src.core.simplified_medical_app import SimplifiedMedicalApp
|
| 351 |
+
|
| 352 |
+
with patch.object(SimplifiedMedicalApp, '__init__', lambda x: None):
|
| 353 |
+
app = SimplifiedMedicalApp()
|
| 354 |
+
app._detect_language = SimplifiedMedicalApp._detect_language.__get__(app)
|
| 355 |
+
|
| 356 |
+
assert app._detect_language("") == "English"
|
| 357 |
+
assert app._detect_language(None) == "English" if hasattr(app._detect_language, '__call__') else True
|
| 358 |
+
|
| 359 |
+
|
| 360 |
+
if __name__ == "__main__":
|
| 361 |
+
pytest.main([__file__, "-v"])
|
|
@@ -0,0 +1,416 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# test_soft_triage_properties.py
|
| 2 |
+
"""
|
| 3 |
+
Property-based tests for Soft Triage Manager.
|
| 4 |
+
|
| 5 |
+
Tests the correctness properties defined in the design document:
|
| 6 |
+
- Property 5: Triage Question Limit (max 3)
|
| 7 |
+
- Property 6: Triage Binary Outcome (GREEN or RED, never YELLOW)
|
| 8 |
+
- Property 7: Triage Timeout Escalation
|
| 9 |
+
|
| 10 |
+
Requirements: 3.1, 3.3, 3.6, 7.2
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
import pytest
|
| 14 |
+
from hypothesis import given, strategies as st, settings
|
| 15 |
+
from unittest.mock import Mock, patch
|
| 16 |
+
|
| 17 |
+
from src.core.spiritual_state import (
|
| 18 |
+
SpiritualState, TriageOutcome, TriageSession, SpiritualAssessment
|
| 19 |
+
)
|
| 20 |
+
from src.core.soft_triage_manager import SoftTriageManager
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
# =============================================================================
|
| 24 |
+
# Property 5: Triage Question Limit
|
| 25 |
+
# For any Soft Triage session, the number of clarifying questions SHALL NOT
|
| 26 |
+
# exceed 3.
|
| 27 |
+
# Validates: Requirements 3.1, 7.2
|
| 28 |
+
# =============================================================================
|
| 29 |
+
|
| 30 |
+
class TestTriageQuestionLimit:
|
| 31 |
+
"""Property 5: Triage Question Limit tests."""
|
| 32 |
+
|
| 33 |
+
@pytest.fixture
|
| 34 |
+
def manager(self):
|
| 35 |
+
"""Create manager with mocked API client."""
|
| 36 |
+
mock_api = Mock()
|
| 37 |
+
mock_api.call_spiritual_api = Mock(return_value="How are you feeling?")
|
| 38 |
+
return SoftTriageManager(mock_api)
|
| 39 |
+
|
| 40 |
+
def test_max_questions_is_three(self, manager):
|
| 41 |
+
"""MAX_QUESTIONS constant is 3."""
|
| 42 |
+
assert manager.MAX_QUESTIONS == 3
|
| 43 |
+
|
| 44 |
+
def test_should_force_decision_at_limit(self, manager):
|
| 45 |
+
"""should_force_decision returns True at question limit."""
|
| 46 |
+
session = TriageSession()
|
| 47 |
+
|
| 48 |
+
# Not at limit
|
| 49 |
+
assert not manager.should_force_decision(session)
|
| 50 |
+
|
| 51 |
+
session.question_count = 1
|
| 52 |
+
assert not manager.should_force_decision(session)
|
| 53 |
+
|
| 54 |
+
session.question_count = 2
|
| 55 |
+
assert not manager.should_force_decision(session)
|
| 56 |
+
|
| 57 |
+
# At limit
|
| 58 |
+
session.question_count = 3
|
| 59 |
+
assert manager.should_force_decision(session)
|
| 60 |
+
|
| 61 |
+
@given(st.integers(min_value=0, max_value=10))
|
| 62 |
+
def test_should_force_decision_threshold(self, count):
|
| 63 |
+
"""should_force_decision is True iff count >= 3."""
|
| 64 |
+
mock_api = Mock()
|
| 65 |
+
manager = SoftTriageManager(mock_api)
|
| 66 |
+
|
| 67 |
+
session = TriageSession()
|
| 68 |
+
session.question_count = count
|
| 69 |
+
|
| 70 |
+
expected = count >= 3
|
| 71 |
+
assert manager.should_force_decision(session) == expected
|
| 72 |
+
|
| 73 |
+
def test_question_generation_respects_limit(self, manager):
|
| 74 |
+
"""Question generation works within limit."""
|
| 75 |
+
session = TriageSession()
|
| 76 |
+
assessment = SpiritualAssessment(
|
| 77 |
+
state=SpiritualState.YELLOW,
|
| 78 |
+
indicators=["sadness"],
|
| 79 |
+
confidence=0.7,
|
| 80 |
+
reasoning="test"
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
# Generate questions up to limit
|
| 84 |
+
for i in range(3):
|
| 85 |
+
question = manager.generate_question(
|
| 86 |
+
context="Test context",
|
| 87 |
+
patient_language="English",
|
| 88 |
+
triage_session=session,
|
| 89 |
+
assessment=assessment
|
| 90 |
+
)
|
| 91 |
+
assert isinstance(question, str)
|
| 92 |
+
assert len(question) > 0
|
| 93 |
+
session.add_exchange(question, f"Response {i}")
|
| 94 |
+
|
| 95 |
+
# After 3 questions, should_force_decision is True
|
| 96 |
+
assert manager.should_force_decision(session)
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
# =============================================================================
|
| 100 |
+
# Property 6: Triage Binary Outcome
|
| 101 |
+
# For any completed Soft Triage session, the outcome SHALL be either GREEN
|
| 102 |
+
# (resolved) or RED (escalated), never YELLOW.
|
| 103 |
+
# Validates: Requirements 3.3
|
| 104 |
+
# =============================================================================
|
| 105 |
+
|
| 106 |
+
class TestTriageBinaryOutcome:
|
| 107 |
+
"""Property 6: Triage Binary Outcome tests."""
|
| 108 |
+
|
| 109 |
+
def test_triage_outcome_enum_values(self):
|
| 110 |
+
"""TriageOutcome has correct values."""
|
| 111 |
+
assert TriageOutcome.RESOLVED_GREEN.value == "resolved_green"
|
| 112 |
+
assert TriageOutcome.ESCALATE_RED.value == "escalate_red"
|
| 113 |
+
assert TriageOutcome.CONTINUE.value == "continue"
|
| 114 |
+
|
| 115 |
+
def test_final_outcomes_are_binary(self):
|
| 116 |
+
"""Final outcomes (not CONTINUE) are binary: GREEN or RED."""
|
| 117 |
+
final_outcomes = [
|
| 118 |
+
TriageOutcome.RESOLVED_GREEN,
|
| 119 |
+
TriageOutcome.ESCALATE_RED
|
| 120 |
+
]
|
| 121 |
+
|
| 122 |
+
# CONTINUE is not a final outcome
|
| 123 |
+
assert TriageOutcome.CONTINUE not in final_outcomes
|
| 124 |
+
|
| 125 |
+
# Only two final outcomes
|
| 126 |
+
assert len(final_outcomes) == 2
|
| 127 |
+
|
| 128 |
+
@pytest.fixture
|
| 129 |
+
def manager(self):
|
| 130 |
+
"""Create manager with mocked API client."""
|
| 131 |
+
mock_api = Mock()
|
| 132 |
+
return SoftTriageManager(mock_api)
|
| 133 |
+
|
| 134 |
+
def test_force_decision_returns_binary_outcome(self, manager):
|
| 135 |
+
"""_force_decision returns only GREEN or RED, never CONTINUE."""
|
| 136 |
+
session = TriageSession()
|
| 137 |
+
session.question_count = 3
|
| 138 |
+
session.questions_asked = ["Q1", "Q2", "Q3"]
|
| 139 |
+
session.patient_responses = ["R1", "R2", "R3"]
|
| 140 |
+
|
| 141 |
+
outcome, reasoning = manager._force_decision("Final response", session)
|
| 142 |
+
|
| 143 |
+
assert outcome in [TriageOutcome.RESOLVED_GREEN, TriageOutcome.ESCALATE_RED]
|
| 144 |
+
assert outcome != TriageOutcome.CONTINUE
|
| 145 |
+
|
| 146 |
+
def test_force_decision_with_positive_indicators_returns_green(self, manager):
|
| 147 |
+
"""Force decision with positive indicators returns GREEN."""
|
| 148 |
+
session = TriageSession()
|
| 149 |
+
session.question_count = 3
|
| 150 |
+
session.questions_asked = ["Q1", "Q2", "Q3"]
|
| 151 |
+
session.patient_responses = [
|
| 152 |
+
"I'm feeling better now",
|
| 153 |
+
"My family is very supportive",
|
| 154 |
+
"I'm coping okay"
|
| 155 |
+
]
|
| 156 |
+
|
| 157 |
+
outcome, reasoning = manager._force_decision("I have good support", session)
|
| 158 |
+
|
| 159 |
+
assert outcome == TriageOutcome.RESOLVED_GREEN
|
| 160 |
+
|
| 161 |
+
def test_force_decision_without_positive_indicators_returns_red(self, manager):
|
| 162 |
+
"""Force decision without positive indicators returns RED (conservative)."""
|
| 163 |
+
session = TriageSession()
|
| 164 |
+
session.question_count = 3
|
| 165 |
+
session.questions_asked = ["Q1", "Q2", "Q3"]
|
| 166 |
+
session.patient_responses = [
|
| 167 |
+
"I don't know",
|
| 168 |
+
"It's complicated",
|
| 169 |
+
"Nothing helps"
|
| 170 |
+
]
|
| 171 |
+
|
| 172 |
+
outcome, reasoning = manager._force_decision("I'm not sure", session)
|
| 173 |
+
|
| 174 |
+
assert outcome == TriageOutcome.ESCALATE_RED
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
# =============================================================================
|
| 178 |
+
# Property 7: Triage Timeout Escalation
|
| 179 |
+
# For any Soft Triage session that reaches 3 exchanges without clear resolution,
|
| 180 |
+
# the system SHALL escalate to RED.
|
| 181 |
+
# Validates: Requirements 3.6
|
| 182 |
+
# =============================================================================
|
| 183 |
+
|
| 184 |
+
class TestTriageTimeoutEscalation:
|
| 185 |
+
"""Property 7: Triage Timeout Escalation tests."""
|
| 186 |
+
|
| 187 |
+
@pytest.fixture
|
| 188 |
+
def manager(self):
|
| 189 |
+
"""Create manager with mocked API client."""
|
| 190 |
+
mock_api = Mock()
|
| 191 |
+
return SoftTriageManager(mock_api)
|
| 192 |
+
|
| 193 |
+
def test_evaluate_at_limit_forces_decision(self, manager):
|
| 194 |
+
"""evaluate_response at limit forces a decision."""
|
| 195 |
+
session = TriageSession()
|
| 196 |
+
session.question_count = 3
|
| 197 |
+
session.questions_asked = ["Q1", "Q2", "Q3"]
|
| 198 |
+
session.patient_responses = ["R1", "R2"] # One less than questions
|
| 199 |
+
|
| 200 |
+
outcome, reasoning = manager.evaluate_response(
|
| 201 |
+
response="I'm not sure",
|
| 202 |
+
triage_session=session,
|
| 203 |
+
context="Test context"
|
| 204 |
+
)
|
| 205 |
+
|
| 206 |
+
# Should force decision, not CONTINUE
|
| 207 |
+
assert outcome in [TriageOutcome.RESOLVED_GREEN, TriageOutcome.ESCALATE_RED]
|
| 208 |
+
|
| 209 |
+
def test_uncertain_at_limit_escalates_to_red(self, manager):
|
| 210 |
+
"""Uncertain response at limit escalates to RED."""
|
| 211 |
+
session = TriageSession()
|
| 212 |
+
session.question_count = 3
|
| 213 |
+
session.questions_asked = ["Q1", "Q2", "Q3"]
|
| 214 |
+
session.patient_responses = ["vague", "unclear", "maybe"]
|
| 215 |
+
|
| 216 |
+
outcome, reasoning = manager._force_decision("I don't know", session)
|
| 217 |
+
|
| 218 |
+
# Conservative: escalate to RED when uncertain
|
| 219 |
+
assert outcome == TriageOutcome.ESCALATE_RED
|
| 220 |
+
assert "conservative" in reasoning.lower() or "max questions" in reasoning.lower()
|
| 221 |
+
|
| 222 |
+
def test_evaluate_before_limit_can_continue(self, manager):
|
| 223 |
+
"""evaluate_response before limit can return CONTINUE."""
|
| 224 |
+
# Mock API to return CONTINUE response
|
| 225 |
+
manager.api.call_spiritual_api = Mock(
|
| 226 |
+
return_value='{"outcome": "continue", "reasoning": "Need more info", "confidence": 0.6}'
|
| 227 |
+
)
|
| 228 |
+
|
| 229 |
+
session = TriageSession()
|
| 230 |
+
session.question_count = 1
|
| 231 |
+
session.questions_asked = ["Q1"]
|
| 232 |
+
session.patient_responses = []
|
| 233 |
+
|
| 234 |
+
outcome, reasoning = manager.evaluate_response(
|
| 235 |
+
response="I'm not sure",
|
| 236 |
+
triage_session=session,
|
| 237 |
+
context="Test context"
|
| 238 |
+
)
|
| 239 |
+
|
| 240 |
+
# Before limit, CONTINUE is allowed
|
| 241 |
+
assert outcome == TriageOutcome.CONTINUE
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
# =============================================================================
|
| 245 |
+
# Fallback Question Tests
|
| 246 |
+
# =============================================================================
|
| 247 |
+
|
| 248 |
+
class TestFallbackQuestions:
|
| 249 |
+
"""Tests for fallback question generation."""
|
| 250 |
+
|
| 251 |
+
@pytest.fixture
|
| 252 |
+
def manager(self):
|
| 253 |
+
"""Create manager with mocked API client."""
|
| 254 |
+
mock_api = Mock()
|
| 255 |
+
return SoftTriageManager(mock_api)
|
| 256 |
+
|
| 257 |
+
def test_fallback_questions_english(self, manager):
|
| 258 |
+
"""Fallback questions exist for English."""
|
| 259 |
+
for i in range(3):
|
| 260 |
+
question = manager._get_fallback_question("English", i)
|
| 261 |
+
assert isinstance(question, str)
|
| 262 |
+
assert len(question) > 10
|
| 263 |
+
|
| 264 |
+
def test_fallback_questions_ukrainian(self, manager):
|
| 265 |
+
"""Fallback questions exist for Ukrainian."""
|
| 266 |
+
for i in range(3):
|
| 267 |
+
question = manager._get_fallback_question("Ukrainian", i)
|
| 268 |
+
assert isinstance(question, str)
|
| 269 |
+
assert len(question) > 10
|
| 270 |
+
|
| 271 |
+
def test_fallback_questions_unknown_language(self, manager):
|
| 272 |
+
"""Unknown language falls back to English."""
|
| 273 |
+
question = manager._get_fallback_question("Unknown", 0)
|
| 274 |
+
assert isinstance(question, str)
|
| 275 |
+
assert len(question) > 10
|
| 276 |
+
|
| 277 |
+
def test_fallback_question_index_bounds(self, manager):
|
| 278 |
+
"""Fallback question handles out-of-bounds index."""
|
| 279 |
+
# Should not raise, should return last question
|
| 280 |
+
question = manager._get_fallback_question("English", 10)
|
| 281 |
+
assert isinstance(question, str)
|
| 282 |
+
|
| 283 |
+
|
| 284 |
+
# =============================================================================
|
| 285 |
+
# LLM Response Parsing Tests
|
| 286 |
+
# =============================================================================
|
| 287 |
+
|
| 288 |
+
class TestEvaluationParsing:
|
| 289 |
+
"""Tests for evaluation response parsing."""
|
| 290 |
+
|
| 291 |
+
@pytest.fixture
|
| 292 |
+
def manager(self):
|
| 293 |
+
"""Create manager with mocked API client."""
|
| 294 |
+
mock_api = Mock()
|
| 295 |
+
return SoftTriageManager(mock_api)
|
| 296 |
+
|
| 297 |
+
def test_parse_resolved_green(self, manager):
|
| 298 |
+
"""Parse RESOLVED_GREEN response."""
|
| 299 |
+
response = '{"outcome": "resolved_green", "reasoning": "Patient coping well", "confidence": 0.8}'
|
| 300 |
+
|
| 301 |
+
outcome, reasoning = manager._parse_evaluation_response(response)
|
| 302 |
+
|
| 303 |
+
assert outcome == TriageOutcome.RESOLVED_GREEN
|
| 304 |
+
assert "coping" in reasoning.lower()
|
| 305 |
+
|
| 306 |
+
def test_parse_escalate_red(self, manager):
|
| 307 |
+
"""Parse ESCALATE_RED response."""
|
| 308 |
+
response = '{"outcome": "escalate_red", "reasoning": "Severe distress", "confidence": 0.9}'
|
| 309 |
+
|
| 310 |
+
outcome, reasoning = manager._parse_evaluation_response(response)
|
| 311 |
+
|
| 312 |
+
assert outcome == TriageOutcome.ESCALATE_RED
|
| 313 |
+
|
| 314 |
+
def test_parse_continue(self, manager):
|
| 315 |
+
"""Parse CONTINUE response."""
|
| 316 |
+
response = '{"outcome": "continue", "reasoning": "Need more info", "confidence": 0.5}'
|
| 317 |
+
|
| 318 |
+
outcome, reasoning = manager._parse_evaluation_response(response)
|
| 319 |
+
|
| 320 |
+
assert outcome == TriageOutcome.CONTINUE
|
| 321 |
+
|
| 322 |
+
def test_parse_invalid_json_returns_continue(self, manager):
|
| 323 |
+
"""Invalid JSON returns CONTINUE."""
|
| 324 |
+
response = "This is not valid JSON"
|
| 325 |
+
|
| 326 |
+
outcome, reasoning = manager._parse_evaluation_response(response)
|
| 327 |
+
|
| 328 |
+
assert outcome == TriageOutcome.CONTINUE
|
| 329 |
+
assert "error" in reasoning.lower()
|
| 330 |
+
|
| 331 |
+
|
| 332 |
+
# =============================================================================
|
| 333 |
+
# Integration-like Tests
|
| 334 |
+
# =============================================================================
|
| 335 |
+
|
| 336 |
+
class TestTriageManagerIntegration:
|
| 337 |
+
"""Integration-like tests with mocked API."""
|
| 338 |
+
|
| 339 |
+
def test_full_triage_flow_to_green(self):
|
| 340 |
+
"""Full triage flow resolving to GREEN."""
|
| 341 |
+
mock_api = Mock()
|
| 342 |
+
mock_api.call_spiritual_api = Mock(side_effect=[
|
| 343 |
+
"How are you feeling today?", # Question 1
|
| 344 |
+
'{"outcome": "resolved_green", "reasoning": "Patient has support", "confidence": 0.8}'
|
| 345 |
+
])
|
| 346 |
+
|
| 347 |
+
manager = SoftTriageManager(mock_api)
|
| 348 |
+
session = TriageSession()
|
| 349 |
+
assessment = SpiritualAssessment(
|
| 350 |
+
state=SpiritualState.YELLOW,
|
| 351 |
+
indicators=["sadness"],
|
| 352 |
+
confidence=0.7,
|
| 353 |
+
reasoning="test"
|
| 354 |
+
)
|
| 355 |
+
|
| 356 |
+
# Generate question
|
| 357 |
+
question = manager.generate_question(
|
| 358 |
+
context="Patient mentioned feeling sad",
|
| 359 |
+
patient_language="English",
|
| 360 |
+
triage_session=session,
|
| 361 |
+
assessment=assessment
|
| 362 |
+
)
|
| 363 |
+
assert isinstance(question, str)
|
| 364 |
+
|
| 365 |
+
# Add exchange
|
| 366 |
+
session.add_exchange(question, "I'm feeling better, my family is supportive")
|
| 367 |
+
|
| 368 |
+
# Evaluate response
|
| 369 |
+
outcome, reasoning = manager.evaluate_response(
|
| 370 |
+
response="I'm feeling better, my family is supportive",
|
| 371 |
+
triage_session=session,
|
| 372 |
+
context="Patient mentioned feeling sad"
|
| 373 |
+
)
|
| 374 |
+
|
| 375 |
+
assert outcome == TriageOutcome.RESOLVED_GREEN
|
| 376 |
+
|
| 377 |
+
def test_full_triage_flow_to_red(self):
|
| 378 |
+
"""Full triage flow escalating to RED."""
|
| 379 |
+
mock_api = Mock()
|
| 380 |
+
mock_api.call_spiritual_api = Mock(side_effect=[
|
| 381 |
+
"How are you feeling today?",
|
| 382 |
+
'{"outcome": "escalate_red", "reasoning": "Patient shows severe distress", "confidence": 0.9}'
|
| 383 |
+
])
|
| 384 |
+
|
| 385 |
+
manager = SoftTriageManager(mock_api)
|
| 386 |
+
session = TriageSession()
|
| 387 |
+
assessment = SpiritualAssessment(
|
| 388 |
+
state=SpiritualState.YELLOW,
|
| 389 |
+
indicators=["hopelessness"],
|
| 390 |
+
confidence=0.8,
|
| 391 |
+
reasoning="test"
|
| 392 |
+
)
|
| 393 |
+
|
| 394 |
+
# Generate question
|
| 395 |
+
question = manager.generate_question(
|
| 396 |
+
context="Patient mentioned hopelessness",
|
| 397 |
+
patient_language="English",
|
| 398 |
+
triage_session=session,
|
| 399 |
+
assessment=assessment
|
| 400 |
+
)
|
| 401 |
+
|
| 402 |
+
# Add exchange
|
| 403 |
+
session.add_exchange(question, "Nothing helps, I feel completely alone")
|
| 404 |
+
|
| 405 |
+
# Evaluate response
|
| 406 |
+
outcome, reasoning = manager.evaluate_response(
|
| 407 |
+
response="Nothing helps, I feel completely alone",
|
| 408 |
+
triage_session=session,
|
| 409 |
+
context="Patient mentioned hopelessness"
|
| 410 |
+
)
|
| 411 |
+
|
| 412 |
+
assert outcome == TriageOutcome.ESCALATE_RED
|
| 413 |
+
|
| 414 |
+
|
| 415 |
+
if __name__ == "__main__":
|
| 416 |
+
pytest.main([__file__, "-v"])
|
|
@@ -0,0 +1,292 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# test_spiritual_monitor_properties.py
|
| 2 |
+
"""
|
| 3 |
+
Property-based tests for Spiritual Monitor.
|
| 4 |
+
|
| 5 |
+
Tests the correctness properties defined in the design document:
|
| 6 |
+
- Property 9: Conservative Classification (YELLOW over GREEN when uncertain)
|
| 7 |
+
- Property 10: Red Flag Keywords (immediate RED for crisis language)
|
| 8 |
+
|
| 9 |
+
Requirements: 5.2, 5.4
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import pytest
|
| 13 |
+
from hypothesis import given, strategies as st, settings, assume
|
| 14 |
+
from unittest.mock import Mock, patch
|
| 15 |
+
|
| 16 |
+
from src.core.spiritual_state import SpiritualState, SpiritualAssessment
|
| 17 |
+
from src.core.spiritual_monitor import (
|
| 18 |
+
SpiritualMonitor,
|
| 19 |
+
RED_FLAG_KEYWORDS,
|
| 20 |
+
YELLOW_FLAG_INDICATORS
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
# =============================================================================
|
| 25 |
+
# Property 10: Red Flag Keywords
|
| 26 |
+
# For any message containing death, hopelessness, or suicidal ideation keywords,
|
| 27 |
+
# the classification SHALL be RED.
|
| 28 |
+
# Validates: Requirements 5.4
|
| 29 |
+
# =============================================================================
|
| 30 |
+
|
| 31 |
+
class TestRedFlagKeywords:
|
| 32 |
+
"""Property 10: Red Flag Keywords tests."""
|
| 33 |
+
|
| 34 |
+
@pytest.fixture
|
| 35 |
+
def monitor(self):
|
| 36 |
+
"""Create monitor with mocked API client."""
|
| 37 |
+
mock_api = Mock()
|
| 38 |
+
return SpiritualMonitor(mock_api)
|
| 39 |
+
|
| 40 |
+
@pytest.mark.parametrize("keyword", RED_FLAG_KEYWORDS[:10]) # Test first 10
|
| 41 |
+
def test_red_flag_keyword_triggers_red(self, monitor, keyword):
|
| 42 |
+
"""Any red flag keyword triggers RED classification."""
|
| 43 |
+
message = f"I feel like {keyword} sometimes"
|
| 44 |
+
|
| 45 |
+
result = monitor.classify(message)
|
| 46 |
+
|
| 47 |
+
assert result.state == SpiritualState.RED
|
| 48 |
+
assert result.confidence == 1.0
|
| 49 |
+
assert keyword in result.indicators
|
| 50 |
+
|
| 51 |
+
def test_suicide_keyword_is_red(self, monitor):
|
| 52 |
+
"""'suicide' keyword triggers RED."""
|
| 53 |
+
result = monitor.classify("I've been thinking about suicide")
|
| 54 |
+
assert result.state == SpiritualState.RED
|
| 55 |
+
|
| 56 |
+
def test_want_to_die_is_red(self, monitor):
|
| 57 |
+
"""'want to die' triggers RED."""
|
| 58 |
+
result = monitor.classify("Sometimes I want to die")
|
| 59 |
+
assert result.state == SpiritualState.RED
|
| 60 |
+
|
| 61 |
+
def test_hopeless_is_red(self, monitor):
|
| 62 |
+
"""'hopeless' triggers RED."""
|
| 63 |
+
result = monitor.classify("Everything feels hopeless")
|
| 64 |
+
assert result.state == SpiritualState.RED
|
| 65 |
+
|
| 66 |
+
def test_ukrainian_suicide_keyword_is_red(self, monitor):
|
| 67 |
+
"""Ukrainian suicide keywords trigger RED."""
|
| 68 |
+
result = monitor.classify("Я думаю про самогубство")
|
| 69 |
+
assert result.state == SpiritualState.RED
|
| 70 |
+
|
| 71 |
+
def test_ukrainian_hopelessness_is_red(self, monitor):
|
| 72 |
+
"""Ukrainian hopelessness triggers RED."""
|
| 73 |
+
result = monitor.classify("Все безнадійно, немає надії")
|
| 74 |
+
assert result.state == SpiritualState.RED
|
| 75 |
+
|
| 76 |
+
def test_case_insensitive_red_flag(self, monitor):
|
| 77 |
+
"""Red flag detection is case insensitive."""
|
| 78 |
+
result = monitor.classify("I feel HOPELESS and want to END MY LIFE")
|
| 79 |
+
assert result.state == SpiritualState.RED
|
| 80 |
+
|
| 81 |
+
@given(st.sampled_from(RED_FLAG_KEYWORDS))
|
| 82 |
+
def test_any_red_flag_keyword_triggers_red(self, keyword):
|
| 83 |
+
"""Property: Any red flag keyword triggers RED."""
|
| 84 |
+
mock_api = Mock()
|
| 85 |
+
monitor = SpiritualMonitor(mock_api)
|
| 86 |
+
|
| 87 |
+
message = f"Message containing {keyword} in it"
|
| 88 |
+
result = monitor.classify(message)
|
| 89 |
+
|
| 90 |
+
assert result.state == SpiritualState.RED
|
| 91 |
+
assert result.confidence == 1.0
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
# =============================================================================
|
| 95 |
+
# Property 9: Conservative Classification
|
| 96 |
+
# For any ambiguous message, the system SHALL prefer YELLOW over GREEN.
|
| 97 |
+
# Validates: Requirements 5.2
|
| 98 |
+
# =============================================================================
|
| 99 |
+
|
| 100 |
+
class TestConservativeClassification:
|
| 101 |
+
"""Property 9: Conservative Classification tests."""
|
| 102 |
+
|
| 103 |
+
@pytest.fixture
|
| 104 |
+
def monitor(self):
|
| 105 |
+
"""Create monitor with mocked API client."""
|
| 106 |
+
mock_api = Mock()
|
| 107 |
+
return SpiritualMonitor(mock_api)
|
| 108 |
+
|
| 109 |
+
def test_classification_error_defaults_to_yellow(self, monitor):
|
| 110 |
+
"""Classification error defaults to YELLOW (conservative)."""
|
| 111 |
+
# Mock API to raise exception
|
| 112 |
+
monitor.api.call_spiritual_api = Mock(side_effect=Exception("API Error"))
|
| 113 |
+
|
| 114 |
+
# Message without red flag keywords
|
| 115 |
+
result = monitor.classify("I'm feeling a bit down today")
|
| 116 |
+
|
| 117 |
+
assert result.state == SpiritualState.YELLOW
|
| 118 |
+
assert "classification_error" in result.indicators
|
| 119 |
+
|
| 120 |
+
def test_parse_error_defaults_to_yellow(self, monitor):
|
| 121 |
+
"""Parse error defaults to YELLOW (conservative)."""
|
| 122 |
+
# Mock API to return invalid JSON
|
| 123 |
+
monitor.api.call_spiritual_api = Mock(return_value="invalid json response")
|
| 124 |
+
|
| 125 |
+
result = monitor.classify("I'm feeling a bit down today")
|
| 126 |
+
|
| 127 |
+
# Should be YELLOW due to keyword detection or conservative default
|
| 128 |
+
assert result.state == SpiritualState.YELLOW
|
| 129 |
+
|
| 130 |
+
def test_yellow_indicator_triggers_yellow(self, monitor):
|
| 131 |
+
"""Yellow flag indicator triggers YELLOW classification."""
|
| 132 |
+
# Mock API to return invalid response (triggers fallback)
|
| 133 |
+
monitor.api.call_spiritual_api = Mock(return_value="invalid")
|
| 134 |
+
|
| 135 |
+
result = monitor.classify("I feel sad and lonely today")
|
| 136 |
+
|
| 137 |
+
assert result.state == SpiritualState.YELLOW
|
| 138 |
+
# Should detect "sad" or "lonely" as indicators
|
| 139 |
+
assert any(ind in ["sad", "lonely"] for ind in result.indicators) or "parse_error" in result.indicators
|
| 140 |
+
|
| 141 |
+
@pytest.mark.parametrize("indicator", YELLOW_FLAG_INDICATORS[:10])
|
| 142 |
+
def test_yellow_indicators_detected(self, monitor, indicator):
|
| 143 |
+
"""Yellow flag indicators are detected."""
|
| 144 |
+
# Test the keyword detection directly
|
| 145 |
+
indicators = monitor._check_yellow_indicators(f"I feel {indicator}")
|
| 146 |
+
assert indicator in indicators or len(indicators) > 0
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
# =============================================================================
|
| 150 |
+
# Keyword Detection Unit Tests
|
| 151 |
+
# =============================================================================
|
| 152 |
+
|
| 153 |
+
class TestKeywordDetection:
|
| 154 |
+
"""Unit tests for keyword detection methods."""
|
| 155 |
+
|
| 156 |
+
@pytest.fixture
|
| 157 |
+
def monitor(self):
|
| 158 |
+
"""Create monitor with mocked API client."""
|
| 159 |
+
mock_api = Mock()
|
| 160 |
+
return SpiritualMonitor(mock_api)
|
| 161 |
+
|
| 162 |
+
def test_check_red_flag_keywords_returns_list(self, monitor):
|
| 163 |
+
"""_check_red_flag_keywords returns list of matched keywords."""
|
| 164 |
+
result = monitor._check_red_flag_keywords("I want to die and feel hopeless")
|
| 165 |
+
|
| 166 |
+
assert isinstance(result, list)
|
| 167 |
+
assert len(result) >= 1
|
| 168 |
+
|
| 169 |
+
def test_check_red_flag_keywords_returns_none_for_clean(self, monitor):
|
| 170 |
+
"""_check_red_flag_keywords returns None for clean messages."""
|
| 171 |
+
result = monitor._check_red_flag_keywords("I had a nice day today")
|
| 172 |
+
|
| 173 |
+
assert result is None
|
| 174 |
+
|
| 175 |
+
def test_check_yellow_indicators_returns_list(self, monitor):
|
| 176 |
+
"""_check_yellow_indicators returns list of matched indicators."""
|
| 177 |
+
result = monitor._check_yellow_indicators("I feel sad and anxious")
|
| 178 |
+
|
| 179 |
+
assert isinstance(result, list)
|
| 180 |
+
assert "sad" in result or "anxious" in result
|
| 181 |
+
|
| 182 |
+
def test_check_yellow_indicators_empty_for_clean(self, monitor):
|
| 183 |
+
"""_check_yellow_indicators returns empty list for clean messages."""
|
| 184 |
+
result = monitor._check_yellow_indicators("The weather is nice today")
|
| 185 |
+
|
| 186 |
+
assert isinstance(result, list)
|
| 187 |
+
assert len(result) == 0
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
# =============================================================================
|
| 191 |
+
# LLM Response Parsing Tests
|
| 192 |
+
# =============================================================================
|
| 193 |
+
|
| 194 |
+
class TestLLMResponseParsing:
|
| 195 |
+
"""Tests for LLM response parsing."""
|
| 196 |
+
|
| 197 |
+
@pytest.fixture
|
| 198 |
+
def monitor(self):
|
| 199 |
+
"""Create monitor with mocked API client."""
|
| 200 |
+
mock_api = Mock()
|
| 201 |
+
return SpiritualMonitor(mock_api)
|
| 202 |
+
|
| 203 |
+
def test_parse_valid_green_response(self, monitor):
|
| 204 |
+
"""Parse valid GREEN response."""
|
| 205 |
+
response = '{"state": "green", "indicators": [], "confidence": 0.9, "reasoning": "Normal message"}'
|
| 206 |
+
|
| 207 |
+
result = monitor._parse_classification_response(response, "test message")
|
| 208 |
+
|
| 209 |
+
assert result.state == SpiritualState.GREEN
|
| 210 |
+
assert result.confidence == 0.9
|
| 211 |
+
|
| 212 |
+
def test_parse_valid_yellow_response(self, monitor):
|
| 213 |
+
"""Parse valid YELLOW response."""
|
| 214 |
+
response = '{"state": "yellow", "indicators": ["sadness"], "confidence": 0.7, "reasoning": "Potential distress"}'
|
| 215 |
+
|
| 216 |
+
result = monitor._parse_classification_response(response, "test message")
|
| 217 |
+
|
| 218 |
+
assert result.state == SpiritualState.YELLOW
|
| 219 |
+
assert "sadness" in result.indicators
|
| 220 |
+
|
| 221 |
+
def test_parse_valid_red_response(self, monitor):
|
| 222 |
+
"""Parse valid RED response."""
|
| 223 |
+
response = '{"state": "red", "indicators": ["crisis"], "confidence": 0.95, "reasoning": "Severe distress"}'
|
| 224 |
+
|
| 225 |
+
result = monitor._parse_classification_response(response, "test message")
|
| 226 |
+
|
| 227 |
+
assert result.state == SpiritualState.RED
|
| 228 |
+
|
| 229 |
+
def test_parse_json_with_extra_text(self, monitor):
|
| 230 |
+
"""Parse JSON embedded in extra text."""
|
| 231 |
+
response = 'Here is my analysis: {"state": "yellow", "indicators": ["anxiety"], "confidence": 0.8, "reasoning": "test"} That is my response.'
|
| 232 |
+
|
| 233 |
+
result = monitor._parse_classification_response(response, "test message")
|
| 234 |
+
|
| 235 |
+
assert result.state == SpiritualState.YELLOW
|
| 236 |
+
|
| 237 |
+
def test_parse_invalid_json_falls_back(self, monitor):
|
| 238 |
+
"""Invalid JSON falls back to keyword detection."""
|
| 239 |
+
response = "This is not valid JSON at all"
|
| 240 |
+
|
| 241 |
+
result = monitor._parse_classification_response(response, "I feel sad")
|
| 242 |
+
|
| 243 |
+
# Should fall back to keyword detection or conservative default
|
| 244 |
+
assert result.state == SpiritualState.YELLOW
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
# =============================================================================
|
| 248 |
+
# Integration-like Tests (with mocked API)
|
| 249 |
+
# =============================================================================
|
| 250 |
+
|
| 251 |
+
class TestMonitorIntegration:
|
| 252 |
+
"""Integration-like tests with mocked API."""
|
| 253 |
+
|
| 254 |
+
@pytest.fixture
|
| 255 |
+
def monitor_with_api(self):
|
| 256 |
+
"""Create monitor with API that returns valid responses."""
|
| 257 |
+
mock_api = Mock()
|
| 258 |
+
mock_api.call_spiritual_api = Mock(return_value='{"state": "green", "indicators": [], "confidence": 0.9, "reasoning": "Normal"}')
|
| 259 |
+
return SpiritualMonitor(mock_api)
|
| 260 |
+
|
| 261 |
+
def test_classify_calls_api_for_non_red_messages(self, monitor_with_api):
|
| 262 |
+
"""Classify calls API for messages without red flag keywords."""
|
| 263 |
+
result = monitor_with_api.classify("How are you today?")
|
| 264 |
+
|
| 265 |
+
# API should be called
|
| 266 |
+
monitor_with_api.api.call_spiritual_api.assert_called_once()
|
| 267 |
+
assert result.state == SpiritualState.GREEN
|
| 268 |
+
|
| 269 |
+
def test_classify_skips_api_for_red_flag_messages(self):
|
| 270 |
+
"""Classify skips API call for red flag keyword messages."""
|
| 271 |
+
mock_api = Mock()
|
| 272 |
+
monitor = SpiritualMonitor(mock_api)
|
| 273 |
+
|
| 274 |
+
result = monitor.classify("I want to kill myself")
|
| 275 |
+
|
| 276 |
+
# API should NOT be called - keyword detection is sufficient
|
| 277 |
+
mock_api.call_spiritual_api.assert_not_called()
|
| 278 |
+
assert result.state == SpiritualState.RED
|
| 279 |
+
|
| 280 |
+
def test_classify_with_conversation_history(self, monitor_with_api):
|
| 281 |
+
"""Classify includes conversation history in context."""
|
| 282 |
+
history = ["User: Hello", "Assistant: Hi there", "User: I'm feeling down"]
|
| 283 |
+
|
| 284 |
+
result = monitor_with_api.classify("Can we talk?", history)
|
| 285 |
+
|
| 286 |
+
# API should be called with history context
|
| 287 |
+
call_args = monitor_with_api.api.call_spiritual_api.call_args
|
| 288 |
+
assert "Recent conversation" in call_args[1]["user_prompt"]
|
| 289 |
+
|
| 290 |
+
|
| 291 |
+
if __name__ == "__main__":
|
| 292 |
+
pytest.main([__file__, "-v"])
|
|
@@ -0,0 +1,318 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# test_spiritual_state_properties.py
|
| 2 |
+
"""
|
| 3 |
+
Property-based tests for Spiritual State Machine.
|
| 4 |
+
|
| 5 |
+
Tests the correctness properties defined in the design document:
|
| 6 |
+
- Property 8: State Validity
|
| 7 |
+
- Property 14: Session Reset
|
| 8 |
+
|
| 9 |
+
Requirements: 5.1, 7.1, 7.4
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import pytest
|
| 13 |
+
from hypothesis import given, strategies as st, settings, assume
|
| 14 |
+
|
| 15 |
+
from src.core.spiritual_state import (
|
| 16 |
+
SpiritualState,
|
| 17 |
+
TriageOutcome,
|
| 18 |
+
SpiritualAssessment,
|
| 19 |
+
TriageSession,
|
| 20 |
+
SessionSpiritualState
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
# =============================================================================
|
| 25 |
+
# Property 8: State Validity
|
| 26 |
+
# For any point in conversation, the spiritual state SHALL be exactly one of:
|
| 27 |
+
# GREEN, YELLOW, or RED.
|
| 28 |
+
# Validates: Requirements 5.1, 7.1
|
| 29 |
+
# =============================================================================
|
| 30 |
+
|
| 31 |
+
class TestStateValidity:
|
| 32 |
+
"""Property 8: State Validity tests."""
|
| 33 |
+
|
| 34 |
+
def test_initial_state_is_green(self):
|
| 35 |
+
"""New session starts in GREEN state."""
|
| 36 |
+
session = SessionSpiritualState()
|
| 37 |
+
assert session.spiritual_state == SpiritualState.GREEN
|
| 38 |
+
assert session.get_state() == SpiritualState.GREEN
|
| 39 |
+
|
| 40 |
+
def test_state_is_always_valid_enum(self):
|
| 41 |
+
"""State is always a valid SpiritualState enum value."""
|
| 42 |
+
session = SessionSpiritualState()
|
| 43 |
+
|
| 44 |
+
# Test all possible states
|
| 45 |
+
for state in SpiritualState:
|
| 46 |
+
session.transition_to(state, f"Testing {state.value}")
|
| 47 |
+
assert session.spiritual_state in SpiritualState
|
| 48 |
+
assert isinstance(session.spiritual_state, SpiritualState)
|
| 49 |
+
|
| 50 |
+
@given(st.sampled_from(list(SpiritualState)))
|
| 51 |
+
def test_transition_maintains_valid_state(self, target_state):
|
| 52 |
+
"""Any transition results in a valid state."""
|
| 53 |
+
session = SessionSpiritualState()
|
| 54 |
+
session.transition_to(target_state, "test transition")
|
| 55 |
+
|
| 56 |
+
assert session.spiritual_state == target_state
|
| 57 |
+
assert session.spiritual_state in SpiritualState
|
| 58 |
+
|
| 59 |
+
@given(
|
| 60 |
+
st.lists(
|
| 61 |
+
st.sampled_from(list(SpiritualState)),
|
| 62 |
+
min_size=1,
|
| 63 |
+
max_size=20
|
| 64 |
+
)
|
| 65 |
+
)
|
| 66 |
+
def test_multiple_transitions_maintain_valid_state(self, transitions):
|
| 67 |
+
"""Multiple transitions always result in valid state."""
|
| 68 |
+
session = SessionSpiritualState()
|
| 69 |
+
|
| 70 |
+
for i, state in enumerate(transitions):
|
| 71 |
+
session.transition_to(state, f"transition {i}")
|
| 72 |
+
assert session.spiritual_state == state
|
| 73 |
+
assert session.spiritual_state in SpiritualState
|
| 74 |
+
|
| 75 |
+
def test_state_enum_has_exactly_three_values(self):
|
| 76 |
+
"""SpiritualState enum has exactly GREEN, YELLOW, RED."""
|
| 77 |
+
states = list(SpiritualState)
|
| 78 |
+
assert len(states) == 3
|
| 79 |
+
assert SpiritualState.GREEN in states
|
| 80 |
+
assert SpiritualState.YELLOW in states
|
| 81 |
+
assert SpiritualState.RED in states
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
# =============================================================================
|
| 85 |
+
# Property 14: Session Reset
|
| 86 |
+
# For any session end, the spiritual state SHALL reset to GREEN.
|
| 87 |
+
# Validates: Requirements 7.4
|
| 88 |
+
# =============================================================================
|
| 89 |
+
|
| 90 |
+
class TestSessionReset:
|
| 91 |
+
"""Property 14: Session Reset tests."""
|
| 92 |
+
|
| 93 |
+
def test_reset_returns_to_green(self):
|
| 94 |
+
"""Reset always returns to GREEN state."""
|
| 95 |
+
session = SessionSpiritualState()
|
| 96 |
+
|
| 97 |
+
# Start in non-GREEN state
|
| 98 |
+
session.transition_to(SpiritualState.YELLOW, "test")
|
| 99 |
+
assert session.spiritual_state == SpiritualState.YELLOW
|
| 100 |
+
|
| 101 |
+
# Reset
|
| 102 |
+
session.reset()
|
| 103 |
+
assert session.spiritual_state == SpiritualState.GREEN
|
| 104 |
+
|
| 105 |
+
@given(st.sampled_from(list(SpiritualState)))
|
| 106 |
+
def test_reset_from_any_state_returns_green(self, initial_state):
|
| 107 |
+
"""Reset from any state returns to GREEN."""
|
| 108 |
+
session = SessionSpiritualState()
|
| 109 |
+
session.transition_to(initial_state, "initial")
|
| 110 |
+
|
| 111 |
+
session.reset()
|
| 112 |
+
|
| 113 |
+
assert session.spiritual_state == SpiritualState.GREEN
|
| 114 |
+
|
| 115 |
+
def test_reset_clears_triage_session(self):
|
| 116 |
+
"""Reset clears any active triage session."""
|
| 117 |
+
session = SessionSpiritualState()
|
| 118 |
+
|
| 119 |
+
# Enter YELLOW state (creates triage session)
|
| 120 |
+
session.transition_to(SpiritualState.YELLOW, "test")
|
| 121 |
+
assert session.triage_session is not None
|
| 122 |
+
|
| 123 |
+
# Reset
|
| 124 |
+
session.reset()
|
| 125 |
+
assert session.triage_session is None
|
| 126 |
+
|
| 127 |
+
def test_reset_clears_last_assessment(self):
|
| 128 |
+
"""Reset clears last assessment."""
|
| 129 |
+
session = SessionSpiritualState()
|
| 130 |
+
|
| 131 |
+
# Set assessment
|
| 132 |
+
session.last_assessment = SpiritualAssessment(
|
| 133 |
+
state=SpiritualState.YELLOW,
|
| 134 |
+
indicators=["test"],
|
| 135 |
+
confidence=0.8,
|
| 136 |
+
reasoning="test"
|
| 137 |
+
)
|
| 138 |
+
|
| 139 |
+
# Reset
|
| 140 |
+
session.reset()
|
| 141 |
+
assert session.last_assessment is None
|
| 142 |
+
|
| 143 |
+
def test_reset_logs_transition(self):
|
| 144 |
+
"""Reset logs the reset in state history."""
|
| 145 |
+
session = SessionSpiritualState()
|
| 146 |
+
session.transition_to(SpiritualState.RED, "test")
|
| 147 |
+
|
| 148 |
+
initial_history_len = len(session.state_history)
|
| 149 |
+
session.reset()
|
| 150 |
+
|
| 151 |
+
assert len(session.state_history) == initial_history_len + 1
|
| 152 |
+
assert "RESET" in session.state_history[-1]
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
# =============================================================================
|
| 156 |
+
# Triage Session Properties
|
| 157 |
+
# Property 5: Triage Question Limit (max 3)
|
| 158 |
+
# Property 6: Triage Binary Outcome (GREEN or RED, never YELLOW)
|
| 159 |
+
# =============================================================================
|
| 160 |
+
|
| 161 |
+
class TestTriageSessionProperties:
|
| 162 |
+
"""Triage session property tests."""
|
| 163 |
+
|
| 164 |
+
def test_triage_session_starts_at_zero(self):
|
| 165 |
+
"""New triage session starts with 0 questions."""
|
| 166 |
+
session = TriageSession()
|
| 167 |
+
assert session.question_count == 0
|
| 168 |
+
assert len(session.questions_asked) == 0
|
| 169 |
+
assert len(session.patient_responses) == 0
|
| 170 |
+
|
| 171 |
+
def test_add_exchange_increments_count(self):
|
| 172 |
+
"""Adding exchange increments question count."""
|
| 173 |
+
session = TriageSession()
|
| 174 |
+
|
| 175 |
+
session.add_exchange("Question 1?", "Response 1")
|
| 176 |
+
assert session.question_count == 1
|
| 177 |
+
|
| 178 |
+
session.add_exchange("Question 2?", "Response 2")
|
| 179 |
+
assert session.question_count == 2
|
| 180 |
+
|
| 181 |
+
def test_is_at_limit_after_three_questions(self):
|
| 182 |
+
"""is_at_limit returns True after 3 questions."""
|
| 183 |
+
session = TriageSession()
|
| 184 |
+
|
| 185 |
+
assert not session.is_at_limit()
|
| 186 |
+
|
| 187 |
+
session.add_exchange("Q1?", "R1")
|
| 188 |
+
assert not session.is_at_limit()
|
| 189 |
+
|
| 190 |
+
session.add_exchange("Q2?", "R2")
|
| 191 |
+
assert not session.is_at_limit()
|
| 192 |
+
|
| 193 |
+
session.add_exchange("Q3?", "R3")
|
| 194 |
+
assert session.is_at_limit()
|
| 195 |
+
|
| 196 |
+
@given(st.integers(min_value=0, max_value=10))
|
| 197 |
+
def test_is_at_limit_threshold(self, num_questions):
|
| 198 |
+
"""is_at_limit is True iff question_count >= 3."""
|
| 199 |
+
session = TriageSession()
|
| 200 |
+
session.question_count = num_questions
|
| 201 |
+
|
| 202 |
+
expected = num_questions >= 3
|
| 203 |
+
assert session.is_at_limit() == expected
|
| 204 |
+
|
| 205 |
+
def test_triage_outcome_is_binary(self):
|
| 206 |
+
"""TriageOutcome has exactly 3 values including CONTINUE."""
|
| 207 |
+
outcomes = list(TriageOutcome)
|
| 208 |
+
assert len(outcomes) == 3
|
| 209 |
+
assert TriageOutcome.RESOLVED_GREEN in outcomes
|
| 210 |
+
assert TriageOutcome.ESCALATE_RED in outcomes
|
| 211 |
+
assert TriageOutcome.CONTINUE in outcomes
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
# =============================================================================
|
| 215 |
+
# State Transition Properties
|
| 216 |
+
# =============================================================================
|
| 217 |
+
|
| 218 |
+
class TestStateTransitionProperties:
|
| 219 |
+
"""State transition property tests."""
|
| 220 |
+
|
| 221 |
+
def test_yellow_creates_triage_session(self):
|
| 222 |
+
"""Transitioning to YELLOW creates triage session."""
|
| 223 |
+
session = SessionSpiritualState()
|
| 224 |
+
assert session.triage_session is None
|
| 225 |
+
|
| 226 |
+
session.transition_to(SpiritualState.YELLOW, "test")
|
| 227 |
+
assert session.triage_session is not None
|
| 228 |
+
assert isinstance(session.triage_session, TriageSession)
|
| 229 |
+
|
| 230 |
+
def test_leaving_yellow_clears_triage_session(self):
|
| 231 |
+
"""Transitioning from YELLOW clears triage session."""
|
| 232 |
+
session = SessionSpiritualState()
|
| 233 |
+
|
| 234 |
+
# Enter YELLOW
|
| 235 |
+
session.transition_to(SpiritualState.YELLOW, "enter")
|
| 236 |
+
assert session.triage_session is not None
|
| 237 |
+
|
| 238 |
+
# Exit to GREEN
|
| 239 |
+
session.transition_to(SpiritualState.GREEN, "exit")
|
| 240 |
+
assert session.triage_session is None
|
| 241 |
+
|
| 242 |
+
def test_yellow_to_red_clears_triage_session(self):
|
| 243 |
+
"""Transitioning from YELLOW to RED clears triage session."""
|
| 244 |
+
session = SessionSpiritualState()
|
| 245 |
+
|
| 246 |
+
session.transition_to(SpiritualState.YELLOW, "enter")
|
| 247 |
+
assert session.triage_session is not None
|
| 248 |
+
|
| 249 |
+
session.transition_to(SpiritualState.RED, "escalate")
|
| 250 |
+
assert session.triage_session is None
|
| 251 |
+
|
| 252 |
+
def test_is_in_triage_only_in_yellow(self):
|
| 253 |
+
"""is_in_triage returns True only in YELLOW state."""
|
| 254 |
+
session = SessionSpiritualState()
|
| 255 |
+
|
| 256 |
+
assert not session.is_in_triage() # GREEN
|
| 257 |
+
|
| 258 |
+
session.transition_to(SpiritualState.YELLOW, "test")
|
| 259 |
+
assert session.is_in_triage()
|
| 260 |
+
|
| 261 |
+
session.transition_to(SpiritualState.RED, "test")
|
| 262 |
+
assert not session.is_in_triage()
|
| 263 |
+
|
| 264 |
+
session.transition_to(SpiritualState.GREEN, "test")
|
| 265 |
+
assert not session.is_in_triage()
|
| 266 |
+
|
| 267 |
+
@given(st.sampled_from(list(SpiritualState)))
|
| 268 |
+
def test_is_in_triage_matches_yellow_state(self, state):
|
| 269 |
+
"""is_in_triage matches whether state is YELLOW."""
|
| 270 |
+
session = SessionSpiritualState()
|
| 271 |
+
session.transition_to(state, "test")
|
| 272 |
+
|
| 273 |
+
expected = state == SpiritualState.YELLOW
|
| 274 |
+
assert session.is_in_triage() == expected
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
# =============================================================================
|
| 278 |
+
# SpiritualAssessment Properties
|
| 279 |
+
# =============================================================================
|
| 280 |
+
|
| 281 |
+
class TestSpiritualAssessmentProperties:
|
| 282 |
+
"""SpiritualAssessment dataclass property tests."""
|
| 283 |
+
|
| 284 |
+
@given(
|
| 285 |
+
st.sampled_from(list(SpiritualState)),
|
| 286 |
+
st.lists(st.text(min_size=1, max_size=50), max_size=5),
|
| 287 |
+
st.floats(min_value=0.0, max_value=1.0),
|
| 288 |
+
st.text(max_size=200)
|
| 289 |
+
)
|
| 290 |
+
@settings(max_examples=50)
|
| 291 |
+
def test_assessment_stores_all_fields(self, state, indicators, confidence, reasoning):
|
| 292 |
+
"""Assessment correctly stores all provided fields."""
|
| 293 |
+
assume(all(ind.strip() for ind in indicators)) # Non-empty indicators
|
| 294 |
+
|
| 295 |
+
assessment = SpiritualAssessment(
|
| 296 |
+
state=state,
|
| 297 |
+
indicators=indicators,
|
| 298 |
+
confidence=confidence,
|
| 299 |
+
reasoning=reasoning
|
| 300 |
+
)
|
| 301 |
+
|
| 302 |
+
assert assessment.state == state
|
| 303 |
+
assert assessment.indicators == indicators
|
| 304 |
+
assert assessment.confidence == confidence
|
| 305 |
+
assert assessment.reasoning == reasoning
|
| 306 |
+
|
| 307 |
+
def test_assessment_default_values(self):
|
| 308 |
+
"""Assessment has sensible defaults."""
|
| 309 |
+
assessment = SpiritualAssessment(state=SpiritualState.GREEN)
|
| 310 |
+
|
| 311 |
+
assert assessment.state == SpiritualState.GREEN
|
| 312 |
+
assert assessment.indicators == []
|
| 313 |
+
assert assessment.confidence == 0.0
|
| 314 |
+
assert assessment.reasoning == ""
|
| 315 |
+
|
| 316 |
+
|
| 317 |
+
if __name__ == "__main__":
|
| 318 |
+
pytest.main([__file__, "-v"])
|