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 @@
"""
Extraction package.
"""
from app.ai.extraction.multilingual_normalizer import normalize_text, detect_language
from app.ai.extraction.symptom_extractor import extract_symptoms
from app.ai.extraction.medication_extractor import extract_medications
from app.ai.extraction.allergy_extractor import extract_allergies
from app.ai.extraction.history_extractor import extract_history
from app.ai.extraction.negation_engine import is_negated, extract_family_subject
__all__ = [
"normalize_text",
"detect_language",
"extract_symptoms",
"extract_medications",
"extract_allergies",
"extract_history",
"is_negated",
"extract_family_subject",
]
@@ -0,0 +1,56 @@
"""
Allergy extraction engine distinguishing drug/food allergies from side effects.
"""
import re
from typing import Dict, Any, List, Optional, Union
ALLERGEN_PATTERNS = [
(r"(penicillin)", "Penicillin"),
(r"(sulfa)", "Sulfa drugs"),
(r"(aspirin)", "Aspirin"),
(r"(dust)", "Dust allergy"),
(r"(peanuts)", "Peanuts"),
(r"(milk|dairy)", "Milk / Dairy"),
]
def extract_allergies(
message: str,
normalized_msg: str,
last_ai_message: Optional[str] = None
) -> Optional[Union[List[Dict[str, Any]], Dict[str, Any]]]:
"""
Extracts structured allergies or confirmed_none container.
"""
msg_lower = normalized_msg.lower()
# Check for explicit negation of allergies
neg_phrases = [
"no allergies", "no allergy", "no drug allergy", "no food allergy", "not allergic",
"nkda", "no allergic reaction", "அலர்ஜி எதுவும் இல்லை", "எந்த அலர்ஜியும் இல்லை",
"कोई एलर्जी नहीं", "कोई समस्या नहीं", "ఏ అలర్జీ లేదు", "ಯಾವ ಅಲರ್ಜಿ ಇಲ್ಲ"
]
if any(neg in msg_lower for neg in neg_phrases):
return {"status": "confirmed_none", "items": []}
allergies: List[Dict[str, Any]] = []
has_allergen = any(re.search(pat, msg_lower) for pat, _ in ALLERGEN_PATTERNS)
has_alg_indicator = any(k in msg_lower or k in message for k in ["allergic", "allergy", "அலர்ஜி", "ஒவ்வாமை", "एलर्जी", "rash", "reaction", "side effect", "தடிப்பு", "எடுத்தா"])
# Side effect distinction: "Paracetamol didn't reduce fever" is NOT an allergy
if "didn't reduce" in msg_lower or "didn't work" in msg_lower or "no effect" in msg_lower:
return None
if has_allergen or has_alg_indicator:
for pat, allergen in ALLERGEN_PATTERNS:
if re.search(pat, msg_lower):
alg_obj: Dict[str, Any] = {"name": allergen}
if "rash" in msg_lower or "தடிப்பு" in message or "खुजली" in message:
alg_obj["reaction"] = "Rash / Skin reaction"
allergies.append(alg_obj)
if not allergies and has_alg_indicator:
allergies.append({"name": "Reported allergy", "type": "unspecified"})
return allergies if allergies else None
@@ -0,0 +1,83 @@
"""
Medical, surgical, family history, and previous episode extraction engine.
"""
import re
from typing import Dict, Any, List, Optional, Tuple, Union
from app.ai.extraction.negation_engine import extract_family_subject
HISTORY_KEYWORDS = [
("diabetes", "Diabetes"), ("sugar", "Diabetes"), ("diabetic", "Diabetes"),
("hypertension", "Hypertension (High BP)"), ("high bp", "Hypertension (High BP)"), ("bp", "Hypertension (High BP)"),
("asthma", "Asthma"), ("thyroid", "Thyroid disorder"),
("சர்க்கரை நோய்", "Diabetes"), ("சுகர்", "Diabetes"), ("பிபி", "Hypertension"),
("डायबिटीज", "Diabetes"), ("शुगर", "Diabetes"), ("बीपी", "Hypertension"),
]
def extract_history(
message: str,
normalized_msg: str,
duration: Optional[str] = None
) -> Dict[str, Any]:
"""
Extracts medical_history, family_history, surgical_history, previous_episodes, and additional_information.
"""
msg_lower = normalized_msg.lower()
updates: Dict[str, Any] = {}
# Check for explicit negation of medical history for self
neg_history_phrases = [
"no past history", "no medical history", "no health conditions", "no bp no diabetes",
"no chronic illness", "don't have diabetes", "no diabetes", "no bp",
"எந்த நோயும் இல்லை", "வேற ஒன்னும் இல்லை", "பிபி சுகர் எதுவும் இல்ல",
"कोई बीमारी नहीं", "बीमारी नहीं है", "ఏ సమస్య లేదు", "ಯಾವ ಕಾಯಿಲೆ ಇಲ್ಲ"
]
# Check if patient mentions family member (e.g., "my father has diabetes")
is_fam, relation, _ = extract_family_subject(message)
if is_fam:
fam_list: List[Dict[str, Any]] = []
for kw, cond in HISTORY_KEYWORDS:
if re.search(rf"\b{re.escape(kw)}\b", msg_lower) or kw in message:
fam_list.append({"relation": relation, "condition": cond})
if fam_list:
updates["family_history"] = fam_list
if any(neg in msg_lower for neg in neg_history_phrases):
updates["medical_history"] = {"status": "confirmed_none", "items": []}
elif not is_fam:
med_history: List[Dict[str, Any]] = []
for kw, cond in HISTORY_KEYWORDS:
if re.search(rf"\b{re.escape(kw)}\b", msg_lower) or kw in message:
h_obj: Dict[str, Any] = {"condition": cond}
h_dur_m = re.search(r"(?:for|since)\s+(\d+\s+(?:years|months|yrs))", msg_lower)
if h_dur_m:
h_obj["duration"] = h_dur_m.group(1).strip()
if not any(x.get("condition") == cond for x in med_history):
med_history.append(h_obj)
if med_history:
updates["medical_history"] = med_history
# Surgical History & Previous Episodes
if "surgery" in msg_lower or "operation" in msg_lower or "ஆபரேஷன்" in message:
updates["surgical_history"] = [{"procedure": "Prior surgery reported", "approximate_date": duration or "Past"}]
if "same problem" in msg_lower or "had this before" in msg_lower or "மூன்று மாதங்களுக்கு முன்" in message:
updates["previous_episodes"] = [{"description": "Similar symptoms previously", "time": duration or "Past"}]
# Additional Information / Functional Impact
add_info: List[Dict[str, Any]] = []
if "couldn't eat" in msg_lower or "not eaten" in msg_lower or "சாப்பிட முடியல" in message:
add_info.append({"category": "functional_impact", "value": "Loss of appetite / difficulty eating"})
if "couldn't sleep" in msg_lower or "haven't slept" in msg_lower or "தூங்க முடியல" in message:
add_info.append({"category": "functional_impact", "value": "Sleep disruption"})
if "weak" in msg_lower or "tired" in msg_lower or "ரொம்ப டயர்டா இருக்கு" in message:
add_info.append({"category": "functional_impact", "value": "Severe weakness / fatigue"})
if add_info:
updates["additional_information"] = add_info
return updates
@@ -0,0 +1,68 @@
"""
Medication extraction engine supporting structured dosage, frequency, and confirmed_none status.
"""
import re
from typing import Dict, Any, List, Optional, Union
KNOWN_MED_PATTERNS = [
(r"(metformin(?:\s+\d+\s*mg)?(?:\s+twice\s+a\s+day)?)", "Metformin"),
(r"(paracetamol(?:\s+\d+\s*mg)?)", "Paracetamol"),
(r"(dolo(?:\s*650)?)", "Dolo 650 (Paracetamol)"),
(r"(pantocid|pan-d|pan 40|omez|omeprazole)", "Pantoprazole / Antacid"),
(r"(telmisartan|telma\s*\d+)", "Telmisartan (BP)"),
(r"(amlodipine|amlong\s*\d+)", "Amlodipine (BP)"),
(r"((?:bp|பிபி|blood\s*pressure)\s*(?:மாத்திரை|medicine|tablet|tablets|மருந்து))", "BP Medication"),
(r"((?:sugar|சுகர்|சர்க்கரை)\s*(?:மாத்திரை|medicine|tablet|tablets|மருந்து))", "Diabetes Medication"),
(r"((?:thyroid|தைராய்டு)\s*(?:மாத்திரை|medicine|tablet|tablets|மருந்து))", "Thyroid Medication"),
]
def extract_medications(
message: str,
normalized_msg: str,
last_ai_message: Optional[str] = None,
is_allergy_utterance: bool = False
) -> Optional[Union[List[Dict[str, Any]], Dict[str, Any]]]:
"""
Extracts structured medications or confirmed_none container.
"""
msg_lower = normalized_msg.lower()
# Check for explicit negation of medicines
neg_phrases = [
"no medicines", "no medication", "don't take any medicine", "don't take medicines",
"no tablets", "not taking any medicine", "no daily medicines", "no regular medicines",
"மாத்திரை எதுவும் இல்லை", "எந்த மாத்திரையும் சாப்பிடல", "மருந்து எதுவும் இல்லை",
"कोई दवा नहीं", "दवाई नहीं लेते", "दवा नहीं लेता", "ఏ మందులు లేవు", "ಯಾವ ಮಾತ್ರೆ ಇಲ್ಲ"
]
if any(neg in msg_lower for neg in neg_phrases):
return {"status": "confirmed_none", "items": []}
meds: List[Dict[str, Any]] = []
# Known medicine matchers
for pat, med_label in KNOWN_MED_PATTERNS:
m_match = re.search(pat, msg_lower)
if m_match:
matched_str = m_match.group(1).strip()
med_obj: Dict[str, Any] = {"name": med_label}
dosage_m = re.search(r"(\d+\s*mg|\d+\s*ml)", matched_str)
if dosage_m:
med_obj["dosage"] = dosage_m.group(1).strip()
freq_m = re.search(r"(twice\s+a\s+day|once\s+a\s+day|\d+\s+times\s+a\s+day|daily|twice)", matched_str)
if freq_m:
med_obj["frequency"] = freq_m.group(1).strip()
if not any(x.get("name") == med_obj["name"] for x in meds):
meds.append(med_obj)
# Generic medicine indicator matchers (only if not an allergy utterance)
if not meds and not is_allergy_utterance:
generic_pat = r"(tablets|goli|mathirai|மாத்திரை|மருந்து|दवाई|दवा|మాత్రలు|ಮಾತ್ರೆಗಳು)"
if re.search(generic_pat, msg_lower) and any(verb in msg_lower for verb in ["take", "taking", "சாப்பிடுறேன்", "சாப்பிட்டேன்", "வாங்குறேன்", "लेता", "लेती", "వాడుతున్నాను"]):
meds.append({"name": "Unspecified medication"})
return meds if meds else None
@@ -0,0 +1,71 @@
"""
Multilingual normalization layer for clinical messages.
Preserves raw input while generating normalized, canonical representations.
"""
import re
import unicodedata
from typing import Dict, Any, Tuple
from app.services.contextual_normalizer import (
normalize_duration_from_message,
detect_previous_question_intent,
is_duration_expression,
is_severity_expression,
is_location_expression,
is_negative_expression,
is_casual_filler,
extract_severity_from_message,
extract_location_from_message,
)
def normalize_text(text: str) -> str:
"""
Cleans Unicode, strips extra spaces, standardizes quotes and punctuation.
"""
if not text:
return ""
# Unicode Normalization Form C
normalized = unicodedata.normalize("NFC", text)
# Replace non-breaking spaces and smart quotes
normalized = normalized.replace("\xa0", " ").replace("’", "'").replace("“", '"').replace("”", '"')
# Collapse multiple whitespaces
normalized = re.sub(r"\s+", " ", normalized).strip()
return normalized
def detect_language(text: str, default_lang: str = "en") -> str:
"""
Detects script/language (ta, hi, te, kn, en).
"""
if not text:
return default_lang
# Script character ranges
has_ta = bool(re.search(r"[\u0B80-\u0BFF]", text))
has_hi = bool(re.search(r"[\u0900-\u097F]", text))
has_te = bool(re.search(r"[\u0C00-\u0C7F]", text))
has_kn = bool(re.search(r"[\u0C80-\u0CFF]", text))
if has_ta:
return "ta-IN"
if has_hi:
return "hi-IN"
if has_te:
return "te-IN"
if has_kn:
return "kn-IN"
# Transliterated language keywords
msg_lower = text.lower()
if any(w in msg_lower for w in ["irukku", "thalaivali", "romba", "mudiyala", "saapudren"]):
return "ta-IN"
if any(w in msg_lower for w in ["dard", "ho raha", "hoga", "le raha"]):
return "hi-IN"
if any(w in msg_lower for w in ["noppi", "undi", "thala noppi"]):
return "te-IN"
if any(w in msg_lower for w in ["novu", "ide", "thale novu"]):
return "kn-IN"
return default_lang or "en-IN"
@@ -0,0 +1,54 @@
"""
Contextual negation and scope resolution engine.
"""
import re
from typing import Dict, Any, Tuple
# Family relations
FAMILY_RELATIONS = [
("father", "father"), ("dad", "father"), ("appa", "father"), ("அப்பா", "father"), ("पिता", "father"), ("नाना", "grandfather"),
("mother", "mother"), ("mom", "mother"), ("amma", "mother"), ("அம்மா", "mother"), ("माँ", "mother"), ("माता", "mother"),
("brother", "brother"), ("annaa", "brother"), ("தம்பி", "brother"), ("अண்ணன்", "brother"), ("भाई", "brother"),
("sister", "sister"), ("akka", "sister"), ("அக்கா", "sister"), ("தங்கை", "sister"), ("बहन", "sister"),
("parent", "parent"), ("grand-father", "grandfather"), ("grandfather", "grandfather"), ("grandmother", "grandmother"),
]
# Negation indicators
NEGATION_PHRASES = [
"don't have", "do not have", "no history of", "no symptoms", "no allergies", "no allergy", "no medicine",
"no medications", "don't take", "do not take", "never had", "without", "denies", "absent",
"இல்லை", "எதுவும் இல்லை", "இல்லவே இல்லை", "மாத்திரை எதுவும் இல்லை", "நோயும் இல்லை",
"नहीं है", "कोई बीमारी नहीं", "कोई एलर्जी नहीं", "कोई दवा नहीं",
"లేవు", "ఏమీ లేదు", "ఇబ్బంది లేదు",
"ಇಲ್ಲ", "ಯಾವುದೇ ಇಲ್ಲ",
]
def is_negated(text: str) -> bool:
"""Returns True if the text expresses explicit negation."""
if not text:
return False
msg_lower = text.lower().strip()
if msg_lower in ["no", "none", "nothing", "no more", "nil", "illness none", "nkda"]:
return True
return any(neg in msg_lower for neg in NEGATION_PHRASES)
def extract_family_subject(text: str) -> Tuple[bool, str, str]:
"""
Checks if text refers to family member rather than the patient.
Returns: (is_family_history, relation, condition_text)
"""
if not text:
return False, "", text
msg_lower = text.lower().strip()
for rel_kw, rel_canonical in FAMILY_RELATIONS:
pattern = rf"\b{re.escape(rel_kw)}\b"
if re.search(pattern, msg_lower) or rel_kw in text:
# Check if patient explicitly negates disease for self in same turn
return True, rel_canonical, text
return False, "", text
@@ -0,0 +1,117 @@
"""
Layered symptom extraction engine supporting attribute mapping per symptom.
"""
import re
from typing import Dict, Any, List, Optional, Set, Tuple
from app.services.contextual_normalizer import (
normalize_duration_from_message,
extract_severity_from_message,
extract_location_from_message,
is_duration_expression,
is_severity_expression,
is_location_expression,
is_casual_filler,
is_negative_expression,
)
SYMPTOM_DICTIONARY = [
# General & Respiratory
("fever", "fever"), ("feverish", "fever"), ("காய்ச்சல்", "fever"), ("बुखार", "fever"), ("జ్వరం", "fever"), ("ಜ್ವರ", "fever"),
("cough", "cough"), ("coughing", "cough"), ("இருமல்", "cough"), ("खांसी", "cough"), ("దగ్గు", "cough"), ("ಕೆಮ್ಮು", "cough"),
("cold", "cold"), ("sneezing", "cold"), ("சளி", "cold"), ("सर्दी", "cold"), ("జలుబు", "cold"), ("ನೆಗಡಿ", "cold"),
("breathlessness", "breathlessness"), ("shortness of breath", "breathlessness"), ("difficulty breathing", "breathlessness"),
("மூச்சு திணறல்", "breathlessness"), ("மூச்சு வாங்குது", "breathlessness"), ("மூச்சு வாங்குறேன்", "breathlessness"), ("சாப்பிடும்போது மூச்சு", "breathlessness"), ("சாப்பிடும் போது மூச்சு", "breathlessness"), ("सांस फूलना", "breathlessness"),
("palpitations", "palpitations"), ("படபடன்னு", "palpitations"), ("நெஞ்சு படபடன்னு", "palpitations"), ("படபடப்பு", "palpitations"),
# Pain & Head
("headache", "headache"), ("head ache", "headache"), ("head pain", "headache"), ("migraine", "headache"),
("தலைவலி", "headache"), ("தலை பாரம்", "headache"), ("सिरदर्द", "headache"), ("తలనొప్పి", "headache"), ("ತಲೆನೋವು", "headache"),
("chest pain", "chest pain"), ("chest pressure", "chest pain"), ("நெஞ்சு வலி", "chest pain"), ("छाती में दर्द", "chest pain"),
("stomach pain", "stomach pain"), ("abdominal pain", "stomach pain"), ("stomach ache", "stomach pain"),
("வயிற்று வலி", "stomach pain"), ("पेट दर्द", "stomach pain"), ("కడుపు నొప్పి", "stomach pain"), ("<ctrl42> ಹೊಟ್ಟೆ ನೋವು", "stomach pain"),
("back pain", "back pain"), ("முதுகு வலி", "back pain"), ("पीठ दर्द", "back pain"),
("joint pain", "joint pain"), ("knee pain", "knee pain"), ("மூட்டு வலி", "joint pain"), ("घुटने का दर्द", "knee pain"),
# Neurological / ENT
("dizziness", "dizziness"), ("giddiness", "dizziness"), ("dizzy", "dizziness"),
("தலை சுத்துது", "dizziness"), ("தலைசுற்றல்", "dizziness"), ("மயக்கம்", "dizziness"), ("चक्कर", "dizziness"),
("sore throat", "sore throat"), ("throat pain", "sore throat"), ("தொண்டை வலி", "sore throat"), ("गले में खराश", "sore throat"),
# GI
("vomiting", "vomiting"), ("vomit", "vomiting"), ("vomited", "vomiting"), ("nausea", "vomiting"),
("வாந்தி", "vomiting"), ("உल्टी", "vomiting"), ("వాంతులు", "vomiting"), ("ವಾಂತಿ", "vomiting"),
("loose motion", "diarrhea"), ("diarrhea", "diarrhea"), ("loose stools", "diarrhea"),
("வயிற்றுப்போக்கு", "diarrhea"), ("दस्त", "diarrhea"),
# Dermatological
("itching", "itching"), ("rash", "rash"), ("skin rash", "rash"),
("அரிப்பு", "itching"), ("தழும்பு", "rash"), ("खुजली", "itching"),
]
def extract_symptoms(
message: str,
normalized_msg: str,
current_state: Optional[Dict[str, Any]] = None
) -> Tuple[List[Dict[str, Any]], Optional[str]]:
"""
Extracts symptoms and maps attributes (duration, severity, location, frequency) specifically.
Returns: (symptoms_list, primary_reason_for_visit)
"""
msg_lower = normalized_msg.lower()
found_symptoms: List[Dict[str, Any]] = []
found_names: Set[str] = set()
# Extract global context attributes
duration = normalize_duration_from_message(message)
severity = extract_severity_from_message(message)
location = extract_location_from_message(message)
# Frequency extraction (e.g. "twice", "2 times")
freq_match = re.search(r"(\b\d+\s*times\b|\btwice\b|\bonce\b|\bmultiple times\b)", msg_lower)
frequency = freq_match.group(1) if freq_match else None
# Scan for symptoms
for kw, canonical in SYMPTOM_DICTIONARY:
pattern = rf"\b{re.escape(kw)}\b"
if re.search(pattern, msg_lower) or kw in message:
if canonical not in found_names:
found_names.add(canonical)
# Store canonical name as 'name' if kw is an English inflection like 'vomit', else keep kw if non-English
stored_name = canonical if kw in ["vomit", "vomited", "coughing", "feverish"] else kw
sym_obj: Dict[str, Any] = {"name": stored_name}
if duration:
sym_obj["duration"] = duration
if severity:
sym_obj["severity"] = severity
if location:
sym_obj["location"] = location
if frequency and ("vomit" in canonical or "வாந்தி" in kw or "உल्टी" in kw):
sym_obj["frequency"] = frequency
found_symptoms.append(sym_obj)
primary_rfv = list(found_names)[0] if found_names else None
# Check for medication or non-symptom statements to prevent fake symptom fallback
is_med_statement = any(k in msg_lower or k in message for k in ["metformin", "dolo", "paracetamol", "pantocid", "telmisartan", "amlodipine", "tablets", "medicine", "pill", "pills", "goli", "mathirai", "மாத்திரை", "மருந்து", "சாப்பிடுறேன்", "சாப்பிட்டேன்", "வாங்குறேன்", "दवाई", "दवा"])
# Fallback: single phrase short symptom message
if not found_symptoms and message and len(message.split()) < 10 and not is_med_statement:
if not (
is_duration_expression(message)
or is_severity_expression(message)
or is_location_expression(message)
or is_casual_filler(message)
or is_negative_expression(message)
):
clean_symptom = message.strip()
sym_obj = {"name": clean_symptom}
if duration:
sym_obj["duration"] = duration
if severity:
sym_obj["severity"] = severity
if location:
sym_obj["location"] = location
found_symptoms.append(sym_obj)
primary_rfv = clean_symptom
return found_symptoms, primary_rfv