feat(ai-onboarding): enhance conversation flow, prompt rules & update Postman environment port to 8001

This commit is contained in:
2026-09-01 15:22:40 +05:30
parent 1d794ca425
commit c23d979338
31 changed files with 4686 additions and 749 deletions
@@ -0,0 +1,427 @@
"""
Contextual Normalizer for Clinical Intake.
Provides:
- Tamil, Tanglish, Hindi, and English number & duration normalization.
- Contextual intent recognition from previous AI dialogue turns.
- Non-symptom expression filters (duration, severity, location, negative phrases, filler).
- Deduplication and deterministic clinical attribute extraction.
"""
import re
from typing import Optional, Tuple, Dict, Any, List
# 1. Spoken Number Mappings across Tamil, Tanglish, Hindi, English
TAMIL_NUMBER_MAP: Dict[str, int] = {
"ஒரு": 1, "ஒண்ணு": 1, "ஒன்று": 1, "ஒன்னு": 1,
"ரெண்டு": 2, "இரண்டு": 2,
"மூணு": 3, "மூன்று": 3,
"நாலு": 4, "நான்கு": 4,
"அஞ்சு": 5, "ஐந்து": 5,
"ஆறு": 6,
"ஏழு": 7,
"எட்டு": 8,
"ஒன்பது": 9,
"பத்து": 10,
"பதினொன்னு": 11, "பதினொன்று": 11,
"பன்னிரண்டு": 12, "பன்னிரெண்டு": 12,
"பதின்மூன்று": 13,
"பதினாலு": 14, "பதினான்கு": 14,
"பதினைந்து": 15, "பதினாஞ்சு": 15,
"பதினாறு": 16,
"பதினேழு": 17,
"பதினெட்டு": 18,
"பத்தொன்பது": 19,
"இருபது": 20, "இருபத்தஞ்சு": 25,
"முப்பது": 30,
"நாற்பது": 40,
"ஐம்பது": 50,
}
TANGLISH_NUMBER_MAP: Dict[str, int] = {
"oru": 1, "onnu": 1, "ondru": 1,
"rendu": 2, "irandu": 2,
"moonu": 3, "moonru": 3,
"naalu": 4, "nangu": 4,
"anju": 5, "ainthu": 5,
"aaru": 6,
"yezhu": 7, "ezhu": 7, "elu": 7,
"ettu": 8,
"onbathu": 9, "ombadhu": 9,
"pathu": 10, "padhu": 10,
"pathinonnu": 11,
"pannirendu": 12,
"pathinaanchu": 15, "pathinainthu": 15,
"irubathu": 20, "irupathu": 20,
"muppathu": 30,
}
HINDI_NUMBER_MAP: Dict[str, int] = {
"एक": 1, "दो": 2, "तीन": 3, "चार": 4, "पांच": 5, "पाँच": 5,
"छह": 6, "सात": 7, "आठ": 8, "नौ": 9, "दस": 10,
"ग्यारह": 11, "बारह": 12, "पंद्रह": 15, "बीस": 20, "तीस": 30, "चालीस": 40, "पचास": 50,
}
TELUGU_NUMBER_MAP: Dict[str, int] = {
"ఒకటి": 1, "ఒక": 1, "రెండు": 2, "మూడు": 3, "నాలుగు": 4, "ఐదు": 5,
"ఆరు": 6, "ఏడు": 7, "ఎనిమిది": 8, "తొమ్మిది": 9, "పది": 10,
}
KANNADA_NUMBER_MAP: Dict[str, int] = {
"ಒಂದು": 1, "ಒಂದ": 1, "ಎರಡು": 2, "ಮೂರು": 3, "ನಾಲ್ಕು": 4, "ಐದು": 5,
"ಆರು": 6, "ಏಳು": 7, "ಎಂಟು": 8, "ಒಂಬತ್ತು": 9, "ಹತ್ತು": 10,
}
ENGLISH_NUMBER_MAP: Dict[str, int] = {
"a": 1, "an": 1, "one": 1, "two": 2, "three": 3, "four": 4,
"five": 5, "six": 6, "seven": 7, "eight": 8, "nine": 9, "ten": 10,
"eleven": 11, "twelve": 12, "thirteen": 13, "fourteen": 14, "fifteen": 15,
"sixteen": 16, "seventeen": 17, "eighteen": 18, "nineteen": 19,
"twenty": 20, "twenty-five": 25, "twenty five": 25, "thirty": 30,
"forty": 40, "fifty": 50,
}
def normalize_duration_from_message(message: str) -> Optional[str]:
"""
Detects and canonicalizes duration phrases across English, Tamil, Tanglish, and Mixed forms.
Returns canonical strings such as: '10 days', '2 days', '1 week', '2 months', 'since yesterday',
'since last week', 'since Monday', 'for a few days', 'for many months'.
Returns None if no duration is recognized.
"""
if not message or not message.strip():
return None
msg = message.strip()
msg_lower = msg.lower()
# 1. Check relative date / anchor phrases
rel_patterns = [
(r"(?:நேற்றிலிருந்து|நேத்து\s*ல\s*இருந்து|நேத்துல\s*இருந்து|நேத்து\s*இருந்து|நேற்று\s*முதல்|since\s*yesterday|कल\s*से|निन्नೆಯಿಂದ|నిన్నటి\s*నుండి)", "since yesterday"),
(r"(?:இன்னைக்கு\s*காலையிலிருந்து|காலையிலிருந்து|today\s*morning|since\s*morning|आज\s*सुबह\s*से)", "since morning"),
(r"(?:since\s*last\s*night|நேத்து\s*ராத்திரி\s*இருந்து|நேத்து\s*நைட்டிலிருந்து)", "since last night"),
(r"(?:since\s*last\s*week|போன\s*வாரத்திலிருந்து|पिछले\s*हफ़्ते\s*से)", "since last week"),
(r"(?:since\s*(?:monday|tuesday|wednesday|thursday|friday|saturday|sunday))", lambda m: m.group(0).strip()),
(r"(?:for\s*a\s*few\s*days|few\s*days|சில\s*நாட்களா|கொஞ்ச\s*நாளா|कुछ\s*दिनों\s*से)", "for a few days"),
(r"(?:for\s*many\s*months|ரொம்ப\s*மாசமா|பல\s*மாதங்களா|कई\s*महीनों\s*से)", "for many months"),
]
for pat, canonical in rel_patterns:
m = re.search(pat, msg, re.IGNORECASE) or re.search(pat, msg_lower, re.IGNORECASE)
if m:
if callable(canonical):
return canonical(m)
return canonical
# 2. Number word or digit + Unit detection
all_tamil_nums = "|".join(sorted(TAMIL_NUMBER_MAP.keys(), key=len, reverse=True))
all_tanglish_nums = "|".join(sorted(TANGLISH_NUMBER_MAP.keys(), key=len, reverse=True))
all_hindi_nums = "|".join(sorted(HINDI_NUMBER_MAP.keys(), key=len, reverse=True))
all_telugu_nums = "|".join(sorted(TELUGU_NUMBER_MAP.keys(), key=len, reverse=True))
all_kannada_nums = "|".join(sorted(KANNADA_NUMBER_MAP.keys(), key=len, reverse=True))
all_eng_nums = "|".join(sorted(ENGLISH_NUMBER_MAP.keys(), key=len, reverse=True))
num_regex_part = rf"(\d+|{all_tamil_nums}|{all_tanglish_nums}|{all_hindi_nums}|{all_telugu_nums}|{all_kannada_nums}|{all_eng_nums})"
unit_regex_part = (
r"(?:days|day|டேஸா|டேஸ்|டே|நாட்களா|நாட்களாக|நாளா|நாளாக|நாளு|நாட்கள்|நாள்|"
r"weeks|week|வாரமா|வாரமாக|வாரம்|வாரங்கள்|"
r"months|month|மாசம்|மாசமா|மாதமா|மாதம்|மாதங்களாக|"
r"years|year|வருஷம்|வருசமா|வருடம்|வருடங்கள்|"
r"hours|hour|hrs|மணி\s*நேரம்|மணி|"
r"naala|naal\s*ah|naal|nala|vaarama|vaaram|maasama|maasam|varusham|varushama|"
r"दिनों|दिन|हफ़्ते|हफ्ता|महीने|साल|घंटे|"
r"రోజుల\s*నుండి|రోజుల\s*నుంచి|రోజులుగా|రోజుల|రోజులు|వారం|వారాలు|"
r"ದಿನದಿಂದ|ದಿನಗಳಿಂದ|ದಿನಗಳು|ದಿನ|ವಾರದಿಂದ|ವಾರಗಳು)"
)
# Prefix qualifiers: "ஒரு 10 டேஸா", "about 10 days", "for 10 days", "இன்னும் ஒரு பத்து நாளா"
full_pattern = (
rf"(?:(?:எனக்கு\s*வந்து\s*)?(?:இன்னும்\s*)?(?:ஒரு|oru|about|around|for\s+the\s+last|for|approx|approximately)\s+)?"
rf"{num_regex_part}\s*[-]?\s*{unit_regex_part}"
rf"(?:[-]?\s*(?:ah|a|ஆ|இருக்கு|ஆச்சு|ஆகியது|ஆகப்\s*போகுது|உంది|ಇದೆ|now))?"
)
match = re.search(full_pattern, msg, re.IGNORECASE) or re.search(full_pattern, msg_lower, re.IGNORECASE)
if match:
raw_num_str = match.group(1).lower().strip()
matched_text = match.group(0).lower()
# Parse number
val = None
if raw_num_str.isdigit():
val = int(raw_num_str)
elif raw_num_str in TAMIL_NUMBER_MAP:
val = TAMIL_NUMBER_MAP[raw_num_str]
elif raw_num_str in TANGLISH_NUMBER_MAP:
val = TANGLISH_NUMBER_MAP[raw_num_str]
elif raw_num_str in HINDI_NUMBER_MAP:
val = HINDI_NUMBER_MAP[raw_num_str]
elif raw_num_str in TELUGU_NUMBER_MAP:
val = TELUGU_NUMBER_MAP[raw_num_str]
elif raw_num_str in KANNADA_NUMBER_MAP:
val = KANNADA_NUMBER_MAP[raw_num_str]
elif raw_num_str in ENGLISH_NUMBER_MAP:
val = ENGLISH_NUMBER_MAP[raw_num_str]
# Determine unit
unit = "days"
if any(w in matched_text for w in ["week", "வாரம்", "வார", "vaaram", "vaarama", "हफ़्ते", "हफ्ता", "వారం", "ವಾರ"]):
unit = "week" if val == 1 else "weeks"
elif any(w in matched_text for w in ["month", "மாச", "மாத", "maasam", "maasama", "महीने", "నెలా", "ತಿಂಗಳು"]):
unit = "month" if val == 1 else "months"
elif any(w in matched_text for w in ["year", "வருஷ", "வருட", "varusham", "varushama", "साल", "సంవత్సరం", "ವರ್ಷ"]):
unit = "year" if val == 1 else "years"
elif any(w in matched_text for w in ["hour", "மணி", "hrs", "घंटे", "గంటలు", "ಗಂಟೆ"]):
unit = "hour" if val == 1 else "hours"
else:
unit = "day" if val == 1 else "days"
if val is not None:
return f"{val} {unit}"
# 3. Fallback loose check for standalone Tanglish/Tamil tokens (e.g. "10 days-ah", "10 naala", "2 days-ah")
loose_match = re.search(r"(\d+)\s*[-]?\s*(?:days?|naal[a]?|nal[a]?|weeks?|months?|years?|டேஸா?|டேஸ்|நாளா?|நாட்களா?)(?:[-]?\s*(?:ah|a|இருக்கு))?", msg_lower)
if loose_match:
val = int(loose_match.group(1))
unit = "day" if val == 1 else "days"
if "week" in loose_match.group(0):
unit = "week" if val == 1 else "weeks"
elif "month" in loose_match.group(0):
unit = "month" if val == 1 else "months"
elif "year" in loose_match.group(0):
unit = "year" if val == 1 else "years"
return f"{val} {unit}"
return None
def detect_previous_question_intent(last_ai_message: str) -> Optional[str]:
"""
Identifies what clinical category the previous AI message asked about.
Returns one of: 'duration', 'severity', 'location', 'onset', 'frequency',
'triggers', 'relieving_factors', 'aggravating_factors', 'medications',
'allergies', 'medical_history', 'surgical_history', 'family_history',
'previous_episodes', or None.
"""
if not last_ai_message or not last_ai_message.strip():
return None
msg = last_ai_message.lower()
# Duration indicators
if any(k in msg for k in [
"எத்தனை நாளா", "எப்போலிருந்து", "எத்தனை நாள்", "எப்போ இருந்து", "கால அளவு",
"how long", "since when", "how many days", "how many weeks", "duration",
"कितने दिनों से", "कब से", "कितने दिन",
"ఎన్ని రోజుల", "ఎప్పటి నుండి", "ఎన్ని రోజులు",
"ಎಷ್ಟು ದಿನದಿಂದ", "ಯಾವಾಗಿನಿಂದ", "ಎಷ್ಟು ದಿನ",
]):
return "duration"
# Severity indicators
if any(k in msg for k in [
"எவ்வளவு தீவிரமா", "ரொம்ப வலியா", "தீவிரம்", "தாங்க முடியாத", "எப்படி இருக்கு வலி",
"how severe", "how bad", "mild or severe", "severity", "scale of 1",
"कितना तेज", "तीव्रता", "ఎంత తీవ్రంగా", "ಎಷ್ಟು ತೀವ್ರ",
]):
return "severity"
# Location indicators
if any(k in msg for k in [
"எந்த இடத்துல", "எந்த பக்கத்துல", "எங்கே வலிக்குது", "எங்கு", "எந்த பகுதி",
"where does it hurt", "which side", "location of the pain", "where is",
"कहाँ दर्द", "किस जगह", "ఎక్కడ నొప్పి", "ಯಾವ ಜಾಗದಲ್ಲಿ",
]):
return "location"
# Onset indicators
if any(k in msg for k in [
"திடீர்னு", "படிப்படியா", "எப்படி ஆரம்பிச்சது", "onset", "sudden", "gradual", "how did it start",
"अचानक", "धीरे-धीरे",
]):
return "onset"
# Frequency indicators
if any(k in msg for k in [
"எத்தனை வாட்டி", "எத்தனை முறை", "அடிக்கடி", "frequency", "how often", "how many times",
"कितनी बार", "ఎన్ని సార్లు", "ಎಷ್ಟು ಬಾರಿ",
]):
return "frequency"
# Triggers / Aggravating / Relieving indicators
if any(k in msg for k in [
"எப்போ அதிகமாகுது", "எப்போ குறையுது", "சாப்பிட்ட பிறகு", "trigger", "worse", "better", "relieve", "aggravate",
]):
return "triggers"
# Combined Medications + Allergies indicators (asked together in one question)
has_med_kw = any(k in msg for k in [
"மாத்திரை", "மருந்து", "மெடிசின்", "medication", "medicine", "tablets", "taking anything", "taken any",
"दवाई", "दवा", "మందులు", "ಔಷಧ",
])
has_alg_kw = any(k in msg for k in [
"அலர்ஜி", "ஒவ்வாமை", "allergy", "allergies", "allergic",
"एलर्जी", "అలర్జీ", "ಅಲರ್ಜಿ",
])
if has_med_kw and has_alg_kw:
return "medications_and_allergies"
# Medications indicators
if has_med_kw or any(k in msg for k in [
"டேப்லெட்", "எடுத்துக்குறீங்களா", "சாப்பிட்டீங்களா", "சாப்பிடுறீங்களா",
"prescribed", "மாத்திரை சாப்பிடுறீங்களா",
"మాత్ర", "ಮಾತ್ರೆ",
]):
return "medications"
# Allergies indicators
if has_alg_kw or any(k in msg for k in [
"ஏதேனும் அலர்ஜி", "மாத்திரை அலர்ஜி",
"reaction to any medicine",
]):
return "allergies"
# Medical History indicators
if any(k in msg for k in [
"பிபி", "சுகர்", "ஆஸ்துமா", "முந்தைய", "உடம்பு பிரச்சனை", "வேறு ஏதேனும்",
"medical history", "chronic condition", "past illness", "diabetes", "hypertension",
"पुरानी बीमारी", "बीपी", "शुगर", "పాత ఆరోగ్య సమస్యలు", "హಳೆಯ ತೊಂದರೆ",
]):
return "medical_history"
# Surgical History indicators
if any(k in msg for k in [
"ஆபரேஷன்", "அறுவை சிகிச்சை", "surgery", "operation", "surgical history", "ऑपरेशन",
]):
return "surgical_history"
# Family History indicators
if any(k in msg for k in [
"குடும்பத்துல", "குடும்ப பின்னணி", "family history", "family members", "parivarik",
]):
return "family_history"
# Previous Episodes indicators
if any(k in msg for k in [
"இதுக்கு முன்னாடி", "முன்னாடி வந்திருக்கா", "previous episode", "had this before", "same problem before",
]):
return "previous_episodes"
return None
def is_duration_expression(text: str) -> bool:
"""Check if the text is primarily a duration statement rather than a symptom name."""
if not text:
return False
norm = normalize_duration_from_message(text)
if norm is not None:
clean = re.sub(r"[^\w\s\u0B80-\u0BFF]", "", text.lower()).strip()
words = clean.split()
if len(words) <= 8:
return True
filler_words = {"எனக்கு", "வந்து", "ஒரு", "இன்னும்", "இருக்கு", "ஆச்சு", "ஆகுது", "ஆ", "for", "me", "it", "is", "about", "around", "since", "from", "now"}
non_filler = [w for w in words if w not in filler_words]
if len(non_filler) <= 3:
return True
return False
def is_severity_expression(text: str) -> bool:
"""Check if the text is primarily a severity descriptor."""
if not text:
return False
t_lower = text.lower().strip()
patterns = [
"mild", "moderate", "severe", "very severe", "unbearable", "high",
"ரொம்ப severe", "ரொம்ப அதிகம்", "மிதமான", "தாங்க முடியாத", "அதிகமா இருக்கு", "ரொம்ப வலி",
"बहुत तेज", "हल्का", "कम", "చాలా తీవ్రంగా", "తక్కువగా", "ತುಂಬಾ ಜಾಸ್ತಿ",
]
return any(p in t_lower for p in patterns) and len(text.split()) <= 6
def is_location_expression(text: str) -> bool:
"""Check if the text is primarily a body location descriptor."""
if not text:
return False
t_lower = text.lower().strip()
patterns = [
"right side", "left side", "front", "back", "lower abdomen", "upper abdomen", "both sides",
"வலது பக்கம்", "இடது பக்கம்", "வலது பக்கத்துல", "இடது பக்கத்துல", "முன் பக்கம்", "பின் பக்கம்", "அடிவயிறு",
"दाएं तरफ", "बाएं तरफ", "పీట", "కుడి వైపు", "ఎడమ వైపు", "ಬಲಗಡೆ", "ಎಡಗಡೆ",
]
return any(p in t_lower for p in patterns) and len(text.split()) <= 6
def is_negative_expression(text: str) -> bool:
"""Check if text is an explicit negation / nothing else."""
if not text:
return False
t_clean = re.sub(r"[^\w\s\u0B80-\u0BFF\u0900-\u097F\u0C00-\u0C7F\u0C80-\u0CFF]", "", text.lower()).strip()
words = t_clean.split()
if not words:
return False
negative_keywords = [
"no", "no more", "nothing", "nothing else", "none", "no illness", "no problem", "that's all", "thats all",
"இல்லை", "இல்லைங்க", "இல்ல", "ஒன்றுமில்லை", "ஒன்னுமில்ல", "ஏதுமில்லை", "அவ்வளவுதான்", "வேற எதுவும் இல்ல",
"எனக்கு வேற எதுவும் இல்ல", "வேறு எதுவும் இல்லை", "வேறு எதுவும் இல்ல", "வேற எதுவும் இல்லை",
"नहीं", "कुछ नहीं", "कोई नहीं", "बस यही है", "और कुछ नहीं",
"లేదు", "ఏమీ లేదు", "లేదండి", "ఇంతేనండి", "ఇంకేమీ లేదు",
"ಇಲ್ಲ", "ಇಲ್ಲವೇ ಇಲ್ಲ", "ಏನೂ ಇಲ್ಲ", "ಅಷ್ಟೇ",
"ഇല്ല", "ഒന്നുമില്ല",
]
for k in negative_keywords:
k_clean = re.sub(r"[^\w\s\u0B80-\u0BFF\u0900-\u097F\u0C00-\u0C7F\u0C80-\u0CFF]", "", k.lower()).strip()
if k_clean == t_clean:
return True
if len(words) <= 6:
pattern = rf"(?:^|\s){re.escape(k_clean)}(?:\s|$)"
if re.search(pattern, t_clean):
return True
return False
def is_casual_filler(text: str) -> bool:
"""Identify casual filler statements that should not be extracted as clinical entities."""
if not text or not str(text).strip():
return False
t_lower = str(text).lower().strip()
filler_patterns = [
r"^(?:சரி\s*சார்\s*சரி\s*சார்|சரி\s*சார்|சரிங்க|ஓகே\s*சார்|ஓகேங்க|ok\s*sir|ok\s*mam|okay|yes|yeah|sure)$",
r"^(?:நான்\s*bus-?ல\s*வந்தேன்|auto-?ல\s*வந்தேன்|நடந்து\s*வந்தேன்)$",
r"^(?:hello|hi|vanakkam|namaste)$",
]
return any(re.search(p, t_lower) for p in filler_patterns)
def extract_severity_from_message(msg: str) -> Optional[str]:
"""Extract normalized severity string."""
m_lower = msg.lower()
if any(k in m_lower for k in ["very severe", "unbearable", "ரொம்ப அதிகம்", "தாங்க முடியாத", "बहुत तेज"]):
return "severe"
if any(k in m_lower for k in ["severe", "ரொம்ப severe", "அதிகமா", "तेज"]):
return "severe"
if any(k in m_lower for k in ["moderate", "medium", "மிதமான", "मध्यम"]):
return "moderate"
if any(k in m_lower for k in ["mild", "light", "லேசா", "हल्का"]):
return "mild"
return None
def extract_location_from_message(msg: str) -> Optional[str]:
"""Extract normalized location string."""
m_lower = msg.lower()
if any(k in m_lower for k in ["right side", "வலது பக்கம்", "வலது பக்கத்துல", "दाएं"]):
return "right side"
if any(k in m_lower for k in ["left side", "இடது பக்கம்", "இடது பக்கத்துல", "बाएं"]):
return "left side"
if any(k in m_lower for k in ["front", "முன் பக்கம்"]):
return "front"
if any(k in m_lower for k in ["back", "பின் பக்கம்"]):
return "back"
if any(k in m_lower for k in ["lower abdomen", "அடிவயிறு"]):
return "lower abdomen"
return None