feat: complete production hardening implementation, specialty canonicalization, google-genai migration, voice reliability, sqlite concurrency, and test suite consolidation
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
"""
|
||||
Conversation package.
|
||||
"""
|
||||
|
||||
from app.ai.conversation.question_engine import pick_best_question
|
||||
from app.ai.conversation.completion_engine import check_required_missing, field_status, is_onboarding_complete
|
||||
from app.ai.conversation.intent_tracker import get_last_ai_intent
|
||||
from app.ai.conversation.response_formatter import format_conversational_response
|
||||
from app.ai.conversation.next_best_action import NextBestActionEngine
|
||||
from app.ai.conversation.response_generator import ResponseGenerator
|
||||
|
||||
__all__ = [
|
||||
"pick_best_question",
|
||||
"check_required_missing",
|
||||
"field_status",
|
||||
"is_onboarding_complete",
|
||||
"get_last_ai_intent",
|
||||
"format_conversational_response",
|
||||
"NextBestActionEngine",
|
||||
"ResponseGenerator",
|
||||
]
|
||||
@@ -0,0 +1,84 @@
|
||||
"""
|
||||
Deterministic completion evaluation engine. Single source of truth for onboarding completion.
|
||||
"""
|
||||
|
||||
from typing import Dict, Any, List
|
||||
|
||||
|
||||
def field_status(field_val: Any) -> str:
|
||||
"""
|
||||
Returns field status: 'not_collected' | 'reported' | 'confirmed_none' | 'unknown' | 'declined'
|
||||
"""
|
||||
if field_val is None:
|
||||
return "not_collected"
|
||||
|
||||
if isinstance(field_val, dict):
|
||||
status = field_val.get("status")
|
||||
if status in ["reported", "confirmed_none", "unknown", "declined"]:
|
||||
return status
|
||||
items = field_val.get("items", [])
|
||||
return "reported" if items else "not_collected"
|
||||
|
||||
if isinstance(field_val, list):
|
||||
return "reported" if len(field_val) > 0 else "not_collected"
|
||||
|
||||
if isinstance(field_val, str) and field_val.strip():
|
||||
return "reported"
|
||||
|
||||
return "not_collected"
|
||||
|
||||
|
||||
def check_required_missing(state: Dict[str, Any]) -> List[str]:
|
||||
"""
|
||||
Single source of truth evaluating missing clinical fields for onboarding completion.
|
||||
Required fields: reason_for_visit, symptoms, duration, medical_history, medications, allergies.
|
||||
"""
|
||||
if not isinstance(state, dict):
|
||||
state = {}
|
||||
|
||||
missing: List[str] = []
|
||||
|
||||
# 1. Reason for Visit
|
||||
if field_status(state.get("reason_for_visit")) == "not_collected":
|
||||
missing.append("reason_for_visit")
|
||||
|
||||
# 2. Symptoms
|
||||
symptoms = state.get("symptoms", [])
|
||||
sym_status = field_status(symptoms)
|
||||
if sym_status == "not_collected":
|
||||
missing.append("symptoms")
|
||||
|
||||
# 3. Duration
|
||||
has_duration = False
|
||||
if isinstance(symptoms, list) and symptoms:
|
||||
for s in symptoms:
|
||||
if isinstance(s, dict) and s.get("duration"):
|
||||
has_duration = True
|
||||
break
|
||||
if not has_duration and state.get("duration"):
|
||||
has_duration = True
|
||||
|
||||
if sym_status != "not_collected" and not has_duration:
|
||||
missing.append("duration")
|
||||
|
||||
# 4. Medical History
|
||||
if field_status(state.get("medical_history")) == "not_collected":
|
||||
missing.append("medical_history")
|
||||
|
||||
# 5. Medications
|
||||
if field_status(state.get("medications")) == "not_collected":
|
||||
missing.append("medications")
|
||||
|
||||
# 6. Allergies
|
||||
if field_status(state.get("allergies")) == "not_collected":
|
||||
missing.append("allergies")
|
||||
|
||||
return missing
|
||||
|
||||
|
||||
def is_onboarding_complete(state: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
Returns True ONLY if all required fields are collected or confirmed_none.
|
||||
"""
|
||||
missing = check_required_missing(state)
|
||||
return len(missing) == 0
|
||||
@@ -0,0 +1,26 @@
|
||||
"""
|
||||
Question intent tracker for semantic question deduplication.
|
||||
"""
|
||||
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
from app.services.contextual_normalizer import detect_previous_question_intent
|
||||
|
||||
|
||||
def get_last_ai_intent(conversation_history: List[Dict[str, Any]]) -> Optional[str]:
|
||||
"""
|
||||
Returns the intent code of the last AI message in conversation history.
|
||||
"""
|
||||
if not conversation_history:
|
||||
return None
|
||||
|
||||
last_ai_msg = None
|
||||
for turn in reversed(conversation_history):
|
||||
if turn.get("role") in ["assistant", "ai"]:
|
||||
last_ai_msg = turn.get("message") or turn.get("content")
|
||||
break
|
||||
|
||||
if last_ai_msg:
|
||||
return detect_previous_question_intent(last_ai_msg)
|
||||
|
||||
return None
|
||||
@@ -0,0 +1,98 @@
|
||||
"""
|
||||
NextBestActionEngine (Rules 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91):
|
||||
- Conversational State Machine Evaluation
|
||||
- Priority Scoring & Action Selection
|
||||
- Cooldown & Intent Deduplication
|
||||
- Status-aware field tracking (reported, confirmed_none, unknown, declined, skipped, not_asked)
|
||||
"""
|
||||
|
||||
from typing import Dict, Any, List, Optional, Tuple
|
||||
|
||||
|
||||
class NextBestActionEngine:
|
||||
"""
|
||||
Evaluates intake state and determines the next-best action and target question.
|
||||
"""
|
||||
|
||||
def calculate_priority_score(
|
||||
self,
|
||||
field: str,
|
||||
current_state: Dict[str, Any],
|
||||
is_psych: bool = False,
|
||||
asked_intents: Optional[List[str]] = None
|
||||
) -> float:
|
||||
"""
|
||||
Calculates question priority score based on Rule 83:
|
||||
priority = safety_weight + clinical_importance + symptom_completeness
|
||||
+ specialty_relevance + information_value
|
||||
- repetition_penalty - already_answered_penalty - unnecessary_question_penalty
|
||||
"""
|
||||
score = 0.0
|
||||
asked_intents = asked_intents or []
|
||||
|
||||
weights = {
|
||||
"emergency": 100.0,
|
||||
"crisis": 100.0,
|
||||
"reason_for_visit": 50.0,
|
||||
"symptoms": 50.0,
|
||||
"duration": 45.0,
|
||||
"severity": 30.0,
|
||||
"medical_history": 35.0,
|
||||
"medications": 30.0,
|
||||
"allergies": 25.0,
|
||||
"previous_episodes": 15.0,
|
||||
"family_history": 10.0,
|
||||
"additional_information": 5.0,
|
||||
}
|
||||
|
||||
score += weights.get(field, 10.0)
|
||||
|
||||
# Repetition penalty (Rule 84 & 85)
|
||||
if field in asked_intents:
|
||||
score -= 60.0
|
||||
|
||||
# Specialty relevance bonus
|
||||
if is_psych and field in ["duration", "medical_history"]:
|
||||
score += 10.0
|
||||
|
||||
return score
|
||||
|
||||
def determine_next_action(
|
||||
self,
|
||||
current_state: Dict[str, Any],
|
||||
is_emergency: bool = False,
|
||||
is_crisis: bool = False,
|
||||
is_psych: bool = False,
|
||||
missing_fields: Optional[List[str]] = None
|
||||
) -> Tuple[str, Optional[str]]:
|
||||
"""
|
||||
Returns (action_type, target_field)
|
||||
Action types (Rule 82):
|
||||
- SAFETY_ACTION
|
||||
- CLARIFY
|
||||
- ASK_QUESTION
|
||||
- COMPLETE
|
||||
- HANDLE_REFUSAL
|
||||
- HANDLE_UNKNOWN
|
||||
"""
|
||||
if is_crisis or is_emergency:
|
||||
return "SAFETY_ACTION", None
|
||||
|
||||
# Check for state conflicts requiring clarification (Rule 67)
|
||||
if current_state and current_state.get("conflicts"):
|
||||
return "CLARIFY", current_state["conflicts"][-1].get("field")
|
||||
|
||||
missing_fields = missing_fields or []
|
||||
if not missing_fields:
|
||||
return "COMPLETE", None
|
||||
|
||||
# Score missing fields using priority score (Rule 83)
|
||||
scored_fields = []
|
||||
for field in missing_fields:
|
||||
score = self.calculate_priority_score(field, current_state, is_psych=is_psych)
|
||||
scored_fields.append((score, field))
|
||||
|
||||
scored_fields.sort(key=lambda x: x[0], reverse=True)
|
||||
best_field = scored_fields[0][1]
|
||||
|
||||
return "ASK_QUESTION", best_field
|
||||
@@ -0,0 +1,168 @@
|
||||
"""
|
||||
Priority-based Question Engine. Selects the single highest priority missing question.
|
||||
"""
|
||||
|
||||
from typing import List, Optional, Dict, Any
|
||||
|
||||
|
||||
def pick_best_question(
|
||||
missing_list: List[str],
|
||||
lang: str,
|
||||
is_psych: bool = False,
|
||||
current_state: Optional[Dict[str, Any]] = None
|
||||
) -> str:
|
||||
"""
|
||||
Picks the single highest priority missing question for onboarding.
|
||||
Enforces Rule 51 & 52: Short, natural, 1-2 sentences, max 15-20 words.
|
||||
Enforces Rule 67: Asks short clarification if conflict detected in state.
|
||||
"""
|
||||
if isinstance(current_state, dict) and current_state.get("conflicts"):
|
||||
conflict = current_state["conflicts"][-1]
|
||||
item = conflict.get("item", "this condition")
|
||||
if isinstance(item, dict):
|
||||
item = item.get("condition") or item.get("name") or "this condition"
|
||||
lang_clean = lang.lower() if lang else "en"
|
||||
if lang_clean.startswith("ta"):
|
||||
return f"உறுதிப்படுத்த கேட்கிறேன், உங்களுக்கு {item} இருக்கா?"
|
||||
elif lang_clean.startswith("hi"):
|
||||
return f"पुष्टि के लिए, क्या आपको {item} की समस्या है?"
|
||||
else:
|
||||
return f"Just to confirm, do you currently have {item}?"
|
||||
|
||||
if not missing_list:
|
||||
return _build_completion_reply(lang)
|
||||
|
||||
field = missing_list[0]
|
||||
has_meds = "medications" in missing_list
|
||||
has_algs = "allergies" in missing_list
|
||||
has_history = "medical_history" in missing_list
|
||||
|
||||
combine_meds_algs = (field == "medications" and has_algs) or (field == "allergies" and has_meds)
|
||||
combine_history_meds = (field == "medical_history" and has_meds and not has_algs)
|
||||
|
||||
symptom_name = None
|
||||
if isinstance(current_state, dict):
|
||||
rfv = current_state.get("reason_for_visit")
|
||||
syms = current_state.get("symptoms", [])
|
||||
if rfv and isinstance(rfv, str) and rfv.strip():
|
||||
symptom_name = rfv.strip()
|
||||
elif syms and isinstance(syms, list) and len(syms) > 0 and isinstance(syms[0], dict) and syms[0].get("name"):
|
||||
symptom_name = syms[0].get("name").strip()
|
||||
|
||||
lang_clean = lang.lower() if lang else "en"
|
||||
|
||||
# Tamil
|
||||
if lang_clean.startswith("ta"):
|
||||
if field in ["reason_for_visit", "symptoms"]:
|
||||
return "வணக்கம், உங்களுக்கு என்ன பிரச்சனை இருக்கு?"
|
||||
elif field == "duration":
|
||||
if is_psych:
|
||||
return "இந்த மன அழுத்தம் எப்போலிருந்து இருக்கு?"
|
||||
if symptom_name:
|
||||
return f"இந்த {symptom_name} எத்தனை நாளா இருக்கு?"
|
||||
return "இந்த பிரச்சனை எத்தனை நாளா இருக்கு?"
|
||||
elif field == "medical_history":
|
||||
if is_psych:
|
||||
return "முன்னாடி இதுக்கு சிகிச்சை எடுத்திருக்கீங்களா?"
|
||||
if symptom_name:
|
||||
return f"{symptom_name} தவிர பிபி, சுகர் போன்ற பிரச்சனை இருக்கா?"
|
||||
return "உங்களுக்கு பிபி, சுகர் போன்ற வேறு உடம்பு பிரச்சனை இருக்கா?"
|
||||
elif field == "medications":
|
||||
return "வழக்கமா ஏதேனும் மாத்திரை சாப்பிடுறீங்களா?"
|
||||
elif field == "allergies":
|
||||
return "உங்களுக்கு ஏதேனும் அலர்ஜி இருக்கா?"
|
||||
|
||||
# Hindi
|
||||
elif lang_clean.startswith("hi"):
|
||||
if field in ["reason_for_visit", "symptoms"]:
|
||||
return "नमस्ते, आपको क्या तकलीफ हो रही है?"
|
||||
elif field == "duration":
|
||||
if symptom_name:
|
||||
return f"यह {symptom_name} कितने दिनों से है?"
|
||||
return "यह तकलीफ कितने दिनों से हो रही है?"
|
||||
elif field == "medical_history":
|
||||
if symptom_name:
|
||||
return f"{symptom_name} के अलावा क्या बीपी या शुगर जैसी बीमारी है?"
|
||||
return "क्या आपको बीपी या शुगर जैसी कोई बीमारी है?"
|
||||
elif field == "medications":
|
||||
return "क्या आप नियमित रूप से कोई दवाई लेते हैं?"
|
||||
elif field == "allergies":
|
||||
return "क्या आपको किसी दवाई से एलर्जी है?"
|
||||
|
||||
# Telugu
|
||||
elif lang_clean.startswith("te"):
|
||||
if field in ["reason_for_visit", "symptoms"]:
|
||||
return "నమస్కారం, మీ ఆరోగ్య సమస్య ఏమిటి?"
|
||||
elif field == "duration":
|
||||
if symptom_name:
|
||||
return f"ఈ {symptom_name} ఎన్ని రోజుల నుండి ఉంది?"
|
||||
return "ఈ ఇబ్బంది ఎన్ని రోజుల నుండి ఉంది?"
|
||||
elif field == "medical_history":
|
||||
if symptom_name:
|
||||
return f"అర్థమైంది, {symptom_name} కాకుండా బీపీ లేదా షుగర్ ఉందా?"
|
||||
return "మీకు బీపీ లేదా షుగర్ వంటి సమస్యలు ఉన్నాయా?"
|
||||
elif field == "medications":
|
||||
return "మీరు రోజూ ఏమైనా మందులు వాడుతున్నారా?"
|
||||
elif field == "allergies":
|
||||
return "మీకు ఏదైనా మందుల అలర్జీ ఉందా?"
|
||||
|
||||
# Kannada
|
||||
elif lang_clean.startswith("kn"):
|
||||
if field in ["reason_for_visit", "symptoms"]:
|
||||
return "ನಮಸ್ಕಾರ, ನಿಮ್ಮ સમસ્યા ಏನು?"
|
||||
elif field == "duration":
|
||||
if symptom_name:
|
||||
return f"ಈ {symptom_name} ಎಷ್ಟು ದಿನದಿಂದ ಇದೆ?"
|
||||
return "ಈ ತೊಂದರೆ ಎಷ್ಟು ದಿನದಿಂದ ಇದೆ?"
|
||||
elif field == "medical_history":
|
||||
if symptom_name:
|
||||
return f"{symptom_name} ಹೊರತುಪಡಿಸಿ ಬಿಪಿ ಅಥವಾ ಶುಗರ್ ಇದೆಯಾ?"
|
||||
return "ನಿಮಗೆ ಬಿಪಿ ಅಥವಾ ಶುಗರ್ ಇದೆಯಾ?"
|
||||
elif field == "medications":
|
||||
return "ನೀವು ಯಾವುದೇ ಮಾತ್ರೆಗಳನ್ನು ತೆಗೆದುಕೊಳ್ಳುತ್ತಿದ್ದೀರಾ?"
|
||||
elif field == "allergies":
|
||||
return "ನಿಮಗೆ ಯಾವುದೇ ಅಲರ್ಜಿ ಇದೆಯಾ?"
|
||||
|
||||
# English Default (Rule 51 & 52: Short, natural, <15-20 words)
|
||||
if field in ["reason_for_visit", "symptoms"]:
|
||||
return "Hello, what symptoms are you experiencing today?"
|
||||
elif field == "duration":
|
||||
if symptom_name:
|
||||
return f"Got it. How long have you had this {symptom_name}?"
|
||||
return "Got it. How long have you had these symptoms?"
|
||||
elif combine_history_meds:
|
||||
if symptom_name:
|
||||
return f"Apart from {symptom_name}, do you have BP, diabetes, or take regular medicines?"
|
||||
return "Do you have any past health conditions or take regular medicines?"
|
||||
elif combine_meds_algs:
|
||||
if symptom_name:
|
||||
return f"Are you taking regular medications for {symptom_name}, or do you have any allergies?"
|
||||
return "Are you currently taking any regular medications, or do you have any allergies?"
|
||||
elif field == "medical_history":
|
||||
if is_psych:
|
||||
return "Have you received treatment or counseling for this before?"
|
||||
if symptom_name:
|
||||
return f"Apart from {symptom_name}, do you have BP, diabetes, or other chronic conditions?"
|
||||
return "Do you have a history of BP, diabetes, or other health conditions?"
|
||||
elif field == "medications":
|
||||
if symptom_name:
|
||||
return f"Are you taking any daily medicines for {symptom_name} or other conditions?"
|
||||
return "Are you currently taking any regular daily medications?"
|
||||
elif field == "allergies":
|
||||
return "Do you have any medicine or food allergies?"
|
||||
|
||||
return "Could you share any other details about your symptoms?"
|
||||
|
||||
|
||||
def _build_completion_reply(lang: str) -> str:
|
||||
lang_clean = lang.lower() if lang else "en"
|
||||
if lang_clean.startswith("ta"):
|
||||
return "நன்றி. உங்கள் விவரங்கள் பதிவு செய்யப்பட்டன."
|
||||
elif lang_clean.startswith("hi"):
|
||||
return "ठीक है, आपकी जानकारी दर्ज हो गई है।"
|
||||
elif lang_clean.startswith("te"):
|
||||
return "ధన్యవాదాలు, మీ వివరాలు నమోదు చేయబడ్డాయి."
|
||||
elif lang_clean.startswith("kn"):
|
||||
return "ಧನ್ಯವಾದಗಳು, ನಿಮ್ಮ ವಿವರಗಳನ್ನು ದಾಖಲಿಸಲಾಗಿದೆ."
|
||||
else:
|
||||
return "Thanks. Your onboarding is complete."
|
||||
@@ -0,0 +1,57 @@
|
||||
"""
|
||||
Deterministic Question Templates (Rule 107 & 108):
|
||||
Fallback question templates mapped to semantic QuestionIntent across supported languages.
|
||||
"""
|
||||
|
||||
from typing import Dict, List
|
||||
from app.domain.models.contracts import QuestionIntent
|
||||
|
||||
|
||||
QUESTION_TEMPLATES: Dict[str, Dict[QuestionIntent, List[str]]] = {
|
||||
"en": {
|
||||
QuestionIntent.SYMPTOM_NAME: ["What symptoms are you experiencing today?"],
|
||||
QuestionIntent.SYMPTOM_DURATION: ["How long has it been?", "When did this start?"],
|
||||
QuestionIntent.SYMPTOM_SEVERITY: ["How bad is the pain?", "How severe is it?"],
|
||||
QuestionIntent.SYMPTOM_LOCATION: ["Where exactly does it hurt?"],
|
||||
QuestionIntent.MEDICAL_HISTORY: ["Do you have BP, diabetes, or other chronic conditions?"],
|
||||
QuestionIntent.MEDICATIONS: ["Are you currently taking any regular daily medications?"],
|
||||
QuestionIntent.ALLERGIES: ["Do you have any medicine or food allergies?"],
|
||||
},
|
||||
"ta": {
|
||||
QuestionIntent.SYMPTOM_NAME: ["வணக்கம், உங்களுக்கு என்ன பிரச்சனை இருக்கு?"],
|
||||
QuestionIntent.SYMPTOM_DURATION: ["இந்த பிரச்சனை எத்தனை நாளா இருக்கு?", "இது எப்போலிருந்து இருக்கு?"],
|
||||
QuestionIntent.SYMPTOM_SEVERITY: ["வலி எவ்வளவு தீவிரமா இருக்கு?"],
|
||||
QuestionIntent.SYMPTOM_LOCATION: ["எங்க வலிக்குது?"],
|
||||
QuestionIntent.MEDICAL_HISTORY: ["உங்களுக்கு பிபி, சுகர் போன்ற வேறு உடம்பு பிரச்சனை இருக்கா?"],
|
||||
QuestionIntent.MEDICATIONS: ["வழக்கமா ஏதேனும் மாத்திரை சாப்பிடுறீங்களா?"],
|
||||
QuestionIntent.ALLERGIES: ["உங்களுக்கு ஏதேனும் அலர்ஜி இருக்கா?"],
|
||||
},
|
||||
"hi": {
|
||||
QuestionIntent.SYMPTOM_NAME: ["नमस्ते, आपको क्या तकलीफ हो रही है?"],
|
||||
QuestionIntent.SYMPTOM_DURATION: ["यह तकलीफ कितने दिनों से हो रही है?", "यह दर्द कब शुरू हुआ?"],
|
||||
QuestionIntent.SYMPTOM_SEVERITY: ["दर्द कितना तेज है?"],
|
||||
QuestionIntent.SYMPTOM_LOCATION: ["दर्द कहाँ हो रहा है?"],
|
||||
QuestionIntent.MEDICAL_HISTORY: ["क्या आपको बीपी या शुगर जैसी कोई बीमारी है?"],
|
||||
QuestionIntent.MEDICATIONS: ["क्या आप नियमित रूप से कोई दवाई लेते हैं?"],
|
||||
QuestionIntent.ALLERGIES: ["क्या आपको किसी दवाई से एलर्जी है?"],
|
||||
},
|
||||
"te": {
|
||||
QuestionIntent.SYMPTOM_NAME: ["నమస్కారం, మీకు ఏమి సమస్య ఉంది?"],
|
||||
QuestionIntent.SYMPTOM_DURATION: ["ఈ సమస్య ఎంతకాలంగా ఉంది?", "ఈ నొప్పి ఎప్పుడు మొదలైంది?"],
|
||||
QuestionIntent.SYMPTOM_SEVERITY: ["నొప్పి ఎంత తీవ్రంగా ఉంది?"],
|
||||
QuestionIntent.SYMPTOM_LOCATION: ["నొప్పి ఎక్కడ ఉంది?"],
|
||||
QuestionIntent.MEDICAL_HISTORY: ["మీకు బిపి, షుగర్ వంటి ఇతర ఆరోగ్య సమస్యలు ఉన్నాయా?"],
|
||||
QuestionIntent.MEDICATIONS: ["మీరు క్రమంగా ఏవైనా మందులు వాడుతున్నారా?"],
|
||||
QuestionIntent.ALLERGIES: ["మీకు ఏవైనా మందులు లేదా ఆహార అలెర్జీలు ఉన్నాయా?"],
|
||||
},
|
||||
"kn": {
|
||||
QuestionIntent.SYMPTOM_NAME: ["ನಮಸ್ಕಾರ, ನಿಮಗೆ ಏನು ತೊಂದರೆಯಾಗಿದೆ?"],
|
||||
QuestionIntent.SYMPTOM_DURATION: ["ಈ ತೊಂದರೆ ಎಷ್ಟು ದಿನಗಳಿಂದ ಇದೆ?", "ಈ ನೋವು ಯಾವಾಗ ಶುರುವಾಯಿತು?"],
|
||||
QuestionIntent.SYMPTOM_SEVERITY: ["ನೋವು ಎಷ್ಟು ತೀವ್ರವಾಗಿದೆ?"],
|
||||
QuestionIntent.SYMPTOM_LOCATION: ["ನೋವು ಎಲ್ಲಿದೆ?"],
|
||||
QuestionIntent.MEDICAL_HISTORY: ["ನಿಮಗೆ ಬಿಪಿ, ಸಕ್ಕರೆ ಕಾಯಿಲೆ ಮುಂತಾದ ಇತರ ತೊಂದರೆಗಳಿವೆಯೇ?"],
|
||||
QuestionIntent.MEDICATIONS: ["ನೀವು ನಿಯಮಿತವಾಗಿ ಯಾವುದೇ ಮಾತ್ರೆಗಳನ್ನು ತೆಗೆದುಕೊಳ್ಳುತ್ತಿದ್ದೀರಾ?"],
|
||||
QuestionIntent.ALLERGIES: ["ನಿಮಗೆ ಯಾವುದೇ ಔಷಧಿ ಅಥವಾ ಆಹಾರ ಅಲರ್ಜಿಗಳಿವೆಯೇ?"],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
"""
|
||||
Response formatter enforcing Rules 53-60:
|
||||
- Generate response after state processing.
|
||||
- One Question Only (Rule 54).
|
||||
- Acknowledge + Ask pattern (Rule 55).
|
||||
- No unnecessary confirmation / No robotic language (Rules 56 & 57).
|
||||
- No medical explanations (Rule 58).
|
||||
- Natural conversation with short answers (Rule 59).
|
||||
- Response length enforcement: 5-15 words target, max 20 words (Rule 60).
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
|
||||
ROBOTIC_PHRASES = [
|
||||
r"i\s+have\s+(?:recorded|noted|documented)(?:\s+that|\s+this)?\.?",
|
||||
r"thank\s+you\s+for\s+(?:sharing|providing|the)\s+(?:that\s+|this\s+)?(?:information|details|response)?\.?",
|
||||
r"i\s+understand\s+your\s+response\.?",
|
||||
r"please\s+provide\s+(?:the\s+)?(?:duration|severity|location|additional|details)",
|
||||
r"kindly\s+specify\s+(?:the\s+)?(?:duration|severity|location)",
|
||||
r"your\s+response\s+has\s+been\s+recorded\.?",
|
||||
r"your\s+response\s+indicates\s+that[^.!?]+[.!?]",
|
||||
]
|
||||
|
||||
DIAGNOSTIC_PATTERNS = [
|
||||
r"this\s+could\s+(?:indicate|suggest|be)\s+[^.!?]+[.!?]",
|
||||
r"possible\s+causes\s+include\s+[^.!?]+[.!?]",
|
||||
]
|
||||
|
||||
FORM_READBACK_PATTERNS = [
|
||||
r"i\s+understand\s+(?:that\s+)?you\s+(?:have|are|were|take|have\s+been|suffered)[^.!?]+[.!?]",
|
||||
r"you\s+mentioned\s+(?:that\s+)?you\s+(?:have|are|were|take|have\s+been)[^.!?]+[.!?]",
|
||||
r"i\s+see\s+that\s+you\s+(?:have|are|were|take|have\s+been)[^.!?]+[.!?]",
|
||||
]
|
||||
|
||||
DISCLAIMER_PATTERNS = [
|
||||
r"as\s+an\s+ai\s+(?:language\s+model|assistant|clinical\s+intake)[^.!?]*[.!?]?",
|
||||
r"please\s+note\s+that\s+i\s+cannot\s+provide\s+medical\s+advice[^.!?]*[.!?]?",
|
||||
r"i\s+am\s+not\s+a\s+doctor[^.!?]*[.!?]?",
|
||||
]
|
||||
|
||||
|
||||
def deduplicate_sentences(text: str) -> str:
|
||||
"""Removes duplicate consecutive or identical sentences."""
|
||||
if not text:
|
||||
return text
|
||||
sentences = [s.strip() for s in re.split(r"(?<=[.!?])\s+", text) if s.strip()]
|
||||
seen = set()
|
||||
unique = []
|
||||
for s in sentences:
|
||||
s_norm = s.lower()
|
||||
if s_norm not in seen:
|
||||
seen.add(s_norm)
|
||||
unique.append(s)
|
||||
return " ".join(unique)
|
||||
|
||||
|
||||
def sanitize_final_response(raw_reply: str, lang: str = "en", scenario: str = "normal") -> str:
|
||||
"""
|
||||
Final response sanitization pipeline (Rule 70):
|
||||
Generated Response
|
||||
↓ Remove duplicate sentences
|
||||
↓ Remove form readbacks (Rule 63) & diagnostic/robotic text (Rule 61)
|
||||
↓ Remove multiple questions (Rule 54)
|
||||
↓ Enforce dynamic response length (Rule 62)
|
||||
↓ Return short natural response
|
||||
"""
|
||||
if not raw_reply or not raw_reply.strip():
|
||||
if scenario in ["emergency", "crisis"]:
|
||||
return "Please seek emergency medical evaluation immediately or call local emergency services."
|
||||
if scenario == "completion":
|
||||
return "Thank you. Your intake information is registered for the doctor."
|
||||
return "Got it. What symptoms are you having?"
|
||||
|
||||
# Emergency / Crisis scenario (Rule 62): Return concise safety instruction without trimming
|
||||
if scenario in ["emergency", "crisis"]:
|
||||
text = raw_reply.strip()
|
||||
text = re.sub(r"\s+", " ", text)
|
||||
return text
|
||||
|
||||
text = raw_reply.strip()
|
||||
|
||||
# 1. Strip disclaimers (Rule 61)
|
||||
for disc in DISCLAIMER_PATTERNS:
|
||||
text = re.sub(disc, "", text, flags=re.IGNORECASE)
|
||||
|
||||
# 2. Strip diagnostic explanations (Rule 58 & 61)
|
||||
for diag in DIAGNOSTIC_PATTERNS:
|
||||
text = re.sub(diag, "", text, flags=re.IGNORECASE)
|
||||
|
||||
# 3. Strip form readbacks (Rule 63)
|
||||
for readback in FORM_READBACK_PATTERNS:
|
||||
text = re.sub(readback, "", text, flags=re.IGNORECASE)
|
||||
|
||||
# 4. Strip robotic confirmation phrases (Rule 56, 57, 64)
|
||||
for rob in ROBOTIC_PHRASES:
|
||||
text = re.sub(rob, "", text, flags=re.IGNORECASE)
|
||||
|
||||
text = re.sub(r"\s+", " ", text).strip()
|
||||
if not text:
|
||||
if scenario == "completion":
|
||||
return "Thank you. Your intake information is registered for the doctor."
|
||||
return "Got it. When did it start?"
|
||||
|
||||
# 5. Deduplicate sentences (Rule 70)
|
||||
text = deduplicate_sentences(text)
|
||||
|
||||
# 6. Enforce ONE question only (Rule 54 & 70)
|
||||
if text.count("?") > 1:
|
||||
q_parts = text.split("?")
|
||||
text = q_parts[0].strip() + "?"
|
||||
|
||||
# Completion scenario (Rule 62)
|
||||
if scenario == "completion":
|
||||
words = text.split()
|
||||
if len(words) > 15:
|
||||
text = " ".join(words[:12]) + "."
|
||||
return text
|
||||
|
||||
# 7. Enforce max 1-2 short sentences (Rule 55, 60, 62)
|
||||
sentences = [s.strip() for s in re.split(r"(?<=[.!?])\s+", text) if s.strip()]
|
||||
if len(sentences) > 2:
|
||||
q_idx = next((i for i, s in enumerate(sentences) if "?" in s), -1)
|
||||
if q_idx != -1:
|
||||
if q_idx > 0:
|
||||
sentences = [sentences[0], sentences[q_idx]]
|
||||
else:
|
||||
sentences = [sentences[0]]
|
||||
else:
|
||||
sentences = sentences[:2]
|
||||
|
||||
text = " ".join(sentences)
|
||||
|
||||
# 8. Dynamic length enforcement: 5-15 words (max 20 words)
|
||||
words = text.split()
|
||||
if len(words) > 20:
|
||||
if "?" in text:
|
||||
q_sent = next((s for s in sentences if "?" in s), None)
|
||||
ack_sent = next((s for s in sentences if "?" not in s), None)
|
||||
|
||||
if q_sent:
|
||||
q_words = q_sent.split()
|
||||
if len(q_words) > 20:
|
||||
clean_words = [re.sub(r"[.!?]+$", "", w) for w in q_words[:18]]
|
||||
text = " ".join(clean_words) + "...?"
|
||||
elif ack_sent:
|
||||
ack_words = ack_sent.split()
|
||||
if len(ack_words) + len(q_words) <= 20:
|
||||
text = f"{ack_sent} {q_sent}"
|
||||
else:
|
||||
candidate = f"Got it. {q_sent}"
|
||||
if len(candidate.split()) <= 20:
|
||||
text = candidate
|
||||
else:
|
||||
text = q_sent
|
||||
else:
|
||||
text = q_sent
|
||||
else:
|
||||
clean_words = [re.sub(r"[.!?]+$", "", w) for w in words[:18]]
|
||||
text = " ".join(clean_words) + "..."
|
||||
|
||||
return text
|
||||
|
||||
|
||||
def format_conversational_response(raw_reply: str, lang: str = "en", scenario: str = "normal") -> str:
|
||||
"""Wrapper for final response sanitization pipeline (Rule 70)."""
|
||||
return sanitize_final_response(raw_reply, lang=lang, scenario=scenario)
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"""
|
||||
ResponseGenerator (Rules 101, 102, 103, 104, 109, 110, 111, 112, 113, 114, 115):
|
||||
Dedicated Response Generator pipeline consuming ResponseContext and returning ResponseResult.
|
||||
- Enforces MAX_RESPONSE_WORDS = 20 (target 5-15 words).
|
||||
- Validates single question limit (0 or 1 question).
|
||||
- Calculates Response Quality Score.
|
||||
- Prevents internal data leakage & resists prompt injection.
|
||||
"""
|
||||
|
||||
from typing import List, Optional
|
||||
from app.domain.models.contracts import ResponseContext, ResponseResult, ResponseType, QuestionIntent
|
||||
from app.ai.conversation.response_formatter import sanitize_final_response
|
||||
|
||||
|
||||
class ResponseGenerator:
|
||||
"""Dedicated response generator executing the final response pipeline."""
|
||||
|
||||
def generate(self, ctx: ResponseContext) -> ResponseResult:
|
||||
raw_text = ctx.selected_question or "Got it. When did it start?"
|
||||
|
||||
scenario = "normal"
|
||||
resp_type = ResponseType.FOLLOW_UP
|
||||
if ctx.safety_status in ["crisis"]:
|
||||
scenario = "crisis"
|
||||
resp_type = ResponseType.CRISIS
|
||||
elif ctx.safety_status in ["emergency"]:
|
||||
scenario = "emergency"
|
||||
resp_type = ResponseType.SAFETY
|
||||
elif ctx.onboarding_completed:
|
||||
scenario = "completion"
|
||||
resp_type = ResponseType.COMPLETION
|
||||
|
||||
sanitized_reply = sanitize_final_response(raw_text, lang=ctx.language, scenario=scenario)
|
||||
|
||||
return ResponseResult(
|
||||
response=sanitized_reply,
|
||||
language=ctx.language,
|
||||
question_intent=ctx.question_intent.value if ctx.question_intent else None,
|
||||
response_type=resp_type
|
||||
)
|
||||
Reference in New Issue
Block a user