feat: complete production hardening implementation, specialty canonicalization, google-genai migration, voice reliability, sqlite concurrency, and test suite consolidation

This commit is contained in:
2026-09-07 12:57:54 +05:30
parent c23d979338
commit 92fa71ae4a
99 changed files with 5167 additions and 3550 deletions
@@ -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
)