import random import re import json import os _HERE = os.path.dirname(os.path.abspath(__file__)) CLUSTER_FILE = os.path.join(_HERE, "..", "tamil_clusters_v3.json") # ═════════════════════════════════════════════════════════════════════════════ # SEED CLUSTERS # ═════════════════════════════════════════════════════════════════════════════ SEED_CLUSTERS: dict[str, list[str]] = { "tamil_numbers": [ "ஒன்று", "இரண்டு", "மூன்று", "நான்கு", "ஐந்து", "ஆறு", "ஏழு", "எட்டு", "ஒன்பது", "பத்து", "பதினொன்று", "பன்னிரண்டு", "பதினைந்து", "இருபது", "முப்பது", ], "numeric_years": [ "1947", "1950", "1965", "1971", "1975", "1983", "1991", "2000", "2004", "2009", "2011", "2019", "2023", ], "centuries": [ "16ஆம் நூற்றாண்டு", "17ஆம் நூற்றாண்டு", "18ஆம் நூற்றாண்டு", "19ஆம் நூற்றாண்டு", "20ஆம் நூற்றாண்டு", "21ஆம் நூற்றாண்டு", ], "cricket_nicknames": [ "ஹிட்மேன்", "கேப்டன் கூல்", "லிட்டில் மாஸ்டர்", "கிரிக்கெட் கடவுள்", "இந்திய சிங்கம்", "மாஸ்டர் பிளாஸ்டர்", "ரன் மெஷின்", "வால் ஆஃப் இந்தியா", ], "cricket_phases": [ "ஆரம்ப ஓவர்கள்", "மிடில் ஓவர்கள்", "டெத் ஓவர்கள்", "பவர்பிளே", "இறுதி ஓவர்கள்", "பின்னர் ஓவர்கள்", ], "cricket_roles": [ "தூணாக", "கேப்டனாக", "திறவுகோலாக", "முதுகெலும்பாக", "வலிமையாக", "ஆதாரமாக", "தலைவராக", "நம்பகமான வீரராக", ], "cricket_double_centuries": [ "ஒன்று", "இரண்டு", "மூன்று", "நான்கு", "ஐந்து", ], "cricket_players": [ "விராட் கோஹ்லி", "ரோஹித் சர்மா", "எம்.எஸ். தோனி", "சீமன் ஹெட்மையர்", "கேன் வில்லியம்சன்", "ஜோ ரூட்", "டேவிட் வார்னர்", "ஸ்டீவ் ஸ்மித்", "பெயின் ஸ்டோக்ஸ்", ], "cricket_shots": [ "சிக்ஸர்", "ஃபோர்", "கட்", "புல்", "ஹூக்", "ஸ்வீப்", "டிரைவ்", "ஃப்ளிக்", "லேப்", ], "match_formats": [ "டெஸ்ட் போட்டி", "ஒருநாள் போட்டி", "T20 போட்டி", "T10 போட்டி", "IPL போட்டி", "உலகக்கோப்பை போட்டி", ], "bowling_types": [ "வேகப்பந்து", "ஸ்பின் பந்து", "மீடியம் பேஸ்", "ஆஃப் ஸ்பின்", "லெக் ஸ்பின்", "யார்க்கர்", "பவுன்சர்", ], "indian_states": [ "தமிழ்நாடு", "கேரளா", "கர்நாடகா", "ஆந்திரா", "தெலுங்கானா", "மகாராஷ்டிரா", "குஜராத்", "ராஜஸ்தான்", "உத்திரப்பிரதேசம்", "மேற்கு வங்காளம்", "பஞ்சாப்", ], "tamilnadu_cities": [ "சென்னை", "மதுரை", "கோயம்புத்தூர்", "திருச்சி", "சேலம்", "திருநெல்வேலி", "தஞ்சாவூர்", "வேலூர்", "ஈரோடு", "திருப்பூர்", "கன்னியாகுமரி", ], "countries": [ "இந்தியா", "இலங்கை", "பாகிஸ்தான்", "வங்கதேசம்", "ஆஸ்திரேலியா", "இங்கிலாந்து", "தென்னாப்பிரிக்கா", "நியூஸிலாந்து", "வெஸ்ட் இண்டீஸ்", "சீனா", "அமெரிக்கா", ], "dynasties": [ "சோழர்", "சேரர்", "பாண்டியர்", "பல்லவர்", "நாயக்கர்", "மராத்தியர்", "விஜயநகரம்", "முகலாயர்", ], "historical_eras": [ "பழங்கற்காலம்", "புதிய கற்காலம்", "வெண்கலக் காலம்", "இரும்புக்காலம்", "சங்ககாலம்", "இடைக்காலம்", "காலனித்துவ காலம்", "நவீன காலம்", ], "tamil_literature": [ "திருக்குறள்", "சிலப்பதிகாரம்", "மணிமேகலை", "புறநானூறு", "அகநானூறு", "தொல்காப்பியம்", "கம்பராமாயணம்", "பதிற்றுப்பத்து", ], "tamil_poets": [ "திருவள்ளுவர்", "இளங்கோவடிகள்", "கம்பர்", "ஔவையார்", "சுப்பிரமணிய பாரதி", "பாரதிதாசன்", "கண்ணதாசன்", "வாலி", ], "freedom_fighters": [ "மகாத்மா காந்தி", "சுபாஷ் சந்திர போஸ்", "பகத் சிங்", "வீரபாண்டிய கட்டபொம்மன்", "சுப்பிரமணிய பாரதி", "வ.உ.சிதம்பரனார்", "திலகர்", "லாலா லஜபதி ராய்", ], "indian_rivers": [ "காவிரி", "கங்கை", "யமுனை", "கோதாவரி", "கிருஷ்ணா", "நர்மதா", "தாமிரபரணி", "வைகை", ], "directions": [ "வடக்கு", "தெற்கு", "கிழக்கு", "மேற்கு", "வடகிழக்கு", "தென்கிழக்கு", "வடமேற்கு", "தென்மேற்கு", ], "tamil_months": [ "தை", "மாசி", "பங்குனி", "சித்திரை", "வைகாசி", "ஆனி", "ஆடி", "ஆவணி", "புரட்டாசி", "ஐப்பசி", "கார்த்திகை", "மார்கழி", ], "days_of_week": [ "திங்கள்", "செவ்வாய்", "புதன்", "வியாழன்", "வெள்ளி", "சனி", "ஞாயிறு", ], "colours": [ "சிவப்பு", "நீலம்", "பச்சை", "மஞ்சள்", "வெள்ளை", "கருப்பு", "ஆரஞ்சு", "வெள்ளி நிறம்", ], "animals": [ "சிங்கம்", "புலி", "யானை", "குதிரை", "மான்", "நரி", "ஓநாய்", "கரடி", ], "politicians": [ "ஜவகர்லால் நேரு", "இந்திரா காந்தி", "ராஜீவ் காந்தி", "அடல் பிஹாரி வாஜ்பாயி", "மன்மோகன் சிங்", "நரேந்திர மோடி", "எம். கே. ஸ்டாலின்", "எடப்பாடி பழனிசாமி", "ஜெயலலிதா", "கருணாநிதி", "அம்பேத்கர்", ], "ipl_teams": [ "சென்னை சூப்பர் கிங்ஸ்", "மும்பை இந்தியன்ஸ்", "ரோயல் சேலஞ்சர்ஸ் பெங்களூரு", "கோல்கத்தா நைட் ரைடர்ஸ்", "டெல்லி கேபிடல்ஸ்", "பஞ்சாப் கிங்ஸ்", "ராஜஸ்தான் ராயல்ஸ்", "சன்ரைஸர்ஸ் ஹைதராபாத்", "லக்னோ சூப்பர் ஜெயன்ட்ஸ்", "குஜராத் டைட்டன்ஸ்", ], # ── Politics ────────────────────────────────────────────────────────────── "political_bodies": [ "சட்டமன்றம்", "நாடாளுமன்றம்", "அமைச்சரவை", "தேர்தல் ஆணையம்", "உச்சநீதிமன்றம்", "உயர்நீதிமன்றம்", "ஆளுநர்", "மாநில அரசு", "மத்திய அரசு", "நிர்வாகம்", "அரசமைப்பு", "பாராளுமன்றம்", "சட்டமன்றத்தில்", "நாடாளுமன்றத்தில்", "அமைச்சரவையின்", "மாநகராட்சி", "பேரூராட்சி", "ஊராட்சி", "மாவட்ட நிர்வாகம்", "அரசு துறைகள்", "துறை செயலகம்", "ஆட்சியர் அலுவலகம்", ], "political_entities": [ "கட்சிகள்", "கூட்டணிகள்", "இயக்கங்கள்", "அமைப்புகள்", "தொண்டர்கள்", "தலைவர்கள்", "வேட்பாளர்கள்", "வாக்காளர்கள்", "செயற்குழு", "மாவட்ட கமிட்டி", "தேர்தல் பிரசாரம்", "கட்சி", "கூட்டணி", "இயக்கம்", "அமைப்பு", "முன்னணிகள்", "கூட்டமைப்புகள்", "அரசியல் கட்சிகள்", "எதிர்க்கட்சிகள்", "ஆளுங்கட்சி", "பொதுக்கூட்டங்கள்", ], "political_processes": [ "தேர்தல்", "வாக்கெடுப்பு", "வாக்குப்பதிவு", "தொகுதி", "மறுசீரமைப்பு", "பதிவுகள்", "கட்டுப்பாடுகள்", "விதிகள்", "கொள்கைகள்", "மசோதாக்கள்", "சட்டங்கள்", "ஒதுக்கீடு", ], "political_concepts": [ "அரசியல்", "ஜனநாயகம்", "சமூக நீதி", "பொருளாதாரம்", "கல்வி உரிமை", "ஒதுக்கீட்டு", "இட ஒதுக்கீடு", "சுதந்திரம்", "சமத்துவம்", "மதச்சார்பின்மை", "கூட்டாட்சி", ], "tamil_regions": [ "தமிழ்நாட்டின்", "தமிழகத்தின்", "சென்னையின்", "மதுரையின்", "கோயம்புத்தூரின்", "திருச்சியின்", "மாநிலத்தின்", "மாவட்டத்தின்", "பகுதியின்", "நகரத்தின்", "கிராமத்தின்", "இந்தியாவின்", "கேரளாவின்", "கர்நாடகாவின்", "ஆந்திராவின்", "வட இந்தியாவின்", "தென்னிந்தியாவின்", "வட மாநிலத்தின்", "பிராந்தியத்தின்", "நாட்டின்", "மாநகரத்தின்", ], "governance_terms": [ "ஆணையத்தின்", "துறையின்", "அமைப்பின்", "நிறுவனத்தின்", "குழுவின்", "கமிட்டியின்", "மன்றத்தின்", "சபையின்", "அரசின்", "நிர்வாகத்தின்", "அதிகாரிகளின்", ], # ── Economics ───────────────────────────────────────────────────────────── "economics_terms": [ "சந்தை", "வங்கி", "முதலீடு", "ஏற்றுமதி", "இறக்குமதி", "உற்பத்தி", "தொழில்", "வர்த்தகம்", "பொருளாதாரம்", "வளர்ச்சி", "நிதி", "பட்ஜெட்", "வரி", "கடன்", "வட்டி", ], # ── Science & Technology ────────────────────────────────────────────────── "science_terms": [ "விஞ்ஞானம்", "தொழில்நுட்பம்", "ஆராய்ச்சி", "கண்டுபிடிப்பு", "விண்வெளி", "அணு ஆற்றல்", "மருத்துவம்", "இயற்பியல்", "வேதியியல்", "உயிரியல்", "கணினி", "செயற்கோள்", ], # ── Education ───────────────────────────────────────────────────────────── "education_terms": [ "பள்ளி", "கல்லூரி", "பல்கலைக்கழகம்", "மாணவர்கள்", "ஆசிரியர்கள்", "பாடத்திட்டம்", "தேர்வு", "படிப்பு", "சான்றிதழ்", "பட்டம்", "கல்வி கட்டணம்", "உதவித்தொகை", ], # ── Cinema ──────────────────────────────────────────────────────────────── "cinema_terms": [ "திரைப்படம்", "நடிகர்", "நடிகை", "இயக்குநர்", "தயாரிப்பாளர்", "இசையமைப்பாளர்", "வசனகர்த்தா", "விருது", "பாடல்", "படப்பிடிப்பு", "வெளியீடு", "வசூல்", ], # ── Health ──────────────────────────────────────────────────────────────── "health_terms": [ "மருத்துவமனை", "நோய்", "சிகிச்சை", "மருந்து", "டாக்டர்", "தடுப்பூசி", "ஆரோக்கியம்", "அறுவை சிகிச்சை", "நோயாளி", "மருத்துவர்", "ஆய்வகம்", "சுகாதாரம்", ], # ── Environment ─────────────────────────────────────────────────────────── "environment_terms": [ "காடு", "மரம்", "ஆறு", "மழை", "காலநிலை", "மாசுபாடு", "பசுமை", "இயற்கை", "சுற்றுச்சூழல்", "கடல்", "மலை", "பாலைவனம்", "வெள்ளம்", ], # ── History ─────────────────────────────────────────────────────────────── "history_terms": [ "சரித்திரம்", "போர்", "மன்னர்", "சாம்ராஜ்யம்", "படையெடுப்பு", "நினைவுச்சின்னம்", "கல்வெட்டு", "வரலாறு", "பண்டைய காலம்", "நாகரிகம்", ], } # ═════════════════════════════════════════════════════════════════════════════ # KEYWORD → CLUSTER (named cluster detection from question/context) # ═════════════════════════════════════════════════════════════════════════════ KEYWORD_TO_CLUSTER: list[tuple[list[str], str]] = [ # Cricket (["எண்ணிக்கை", "எத்தனை", "மொத்தம்", "இரட்டைச் சதம்"], "cricket_double_centuries"), (["சிறப்புப் பெயர்", "அழைக்கப்படுகிறார்", "செல்லப்பெயர்"], "cricket_nicknames"), (["ஓவர்", "பவர்பிளே", "இனிங்ஸ்"], "cricket_phases"), (["அணியின்", "விளங்கினார்", "ஆட்டத்தால்"], "cricket_roles"), (["ஃபார்மேட்", "போட்டி வகை"], "match_formats"), (["ஷாட்", "அடி"], "cricket_shots"), (["பந்து வீசும்", "போலிங்"], "bowling_types"), (["கிரிக்கெட் வீரர்", "பேட்ஸ்மேன்"], "cricket_players"), (["முறை", "தடவை", "சாம்பியன்"], "tamil_numbers"), (["அணி", "கிளப்", "ஐபிஎல்", "IPL"], "ipl_teams"), # History / Literature (["வம்சம்", "மன்னர்", "பேரரசு"], "dynasties"), (["காலம்", "நூற்றாண்டு", "யுகம்"], "historical_eras"), (["நூல்", "காவியம்", "இலக்கியம்", "எழுதியவர்", "படைப்பு"], "tamil_literature"), (["கவிஞர்", "புலவர்", "இயற்றியவர்"], "tamil_poets"), (["விடுதலை", "போராட்டம்", "தியாகி", "சுதந்திரம்"], "freedom_fighters"), # Geography (["மாவட்டம்", "நகரம்", "தலைநகரம்"], "tamilnadu_cities"), (["மாநிலம்"], "indian_states"), (["நாடு", "தேசம்"], "countries"), (["ஆறு", "நதி"], "indian_rivers"), (["திசை"], "directions"), # Time (["மாதம்", "மாசம்"], "tamil_months"), (["நாள்", "வாரம்"], "days_of_week"), # Misc (["நிறம்", "வர்ணம்"], "colours"), (["விலங்கு", "மிருகம்", "தேசிய விலங்கு", "தேசிய பறவை", "பறவை"], "animals"), # Politics (["சட்டமன்றம்", "நாடாளுமன்றம்", "மசோதா"], "political_bodies"), (["அமைச்சரவை", "துறைகள்", "நிர்வாக"], "political_bodies"), (["கட்சி", "கூட்டணி", "இயக்கம்", "வேட்பாளர்", "வாக்காளர்"], "political_entities"), (["கூட்டணி", "காலகட்டம்", "பிராந்திய"], "political_entities"), (["தேர்தல்", "வாக்கு", "தொகுதி", "மறுசீரமைப்பு"], "political_processes"), (["அரசியல்", "ஜனநாயகம்", "சமூக நீதி", "ஒதுக்கீடு", "பண்பாடு"], "political_concepts"), (["தமிழ்நாட்டின்", "தமிழகம்", "பரிணாமம்"], "tamil_regions"), (["ஆணையம்", "குழு", "கமிட்டி", "நிறுவனம்"], "governance_terms"), (["பிரதமர்", "ஜனாதிபதி", "முதல்வர்", "ஆளுநர்"], "politicians"), # Other domains (["பொருளாதார", "வர்த்தக", "சந்தை", "வங்கி", "முதலீடு"], "economics_terms"), (["விஞ்ஞான", "தொழில்நுட்ப", "ஆராய்ச்சி", "விண்வெளி"], "science_terms"), (["கல்வி", "பள்ளி", "கல்லூரி", "மாணவர்", "ஆசிரியர்"], "education_terms"), (["சினிமா", "திரைப்படம்", "நடிகர்", "இயக்குநர்", "விருது"], "cinema_terms"), (["மருத்துவ", "நோய்", "சிகிச்சை", "மருந்து", "டாக்டர்"], "health_terms"), (["சுற்றுச்சூழல்", "காடு", "மரம்", "காலநிலை", "மாசுபாடு"], "environment_terms"), (["வரலாற்று", "சரித்திர", "போர்", "படையெடுப்பு"], "history_terms"), ] # ═════════════════════════════════════════════════════════════════════════════ # DOMAIN SIGNALS — for auto-clustering unknown words by context # ═════════════════════════════════════════════════════════════════════════════ DOMAIN_SIGNALS: list[tuple[list[str], str]] = [ (["அரசியல்", "கட்சி", "தேர்தல்", "சட்டமன்றம்", "நாடாளுமன்றம்", "அமைச்சரவை", "வாக்காளர்", "ஆணையம்", "மசோதா", "கூட்டணி", "நிர்வாக", "துறை", "சட்டம்", "கொள்கை", "பிரதமர்", "முதல்வர்", "ஆளுநர்", "ஒதுக்கீடு", "பண்பாடு", "பரிணாமம்", "ஜனநாயகம்", "சமூக நீதி", "மறுசீரமைப்பு", "வாக்குப்பதிவு"], "auto_politics"), (["பொருளாதார", "வர்த்தக", "சந்தை", "வங்கி", "முதலீடு", "ஏற்றுமதி", "இறக்குமதி", "வளர்ச்சி", "உற்பத்தி", "தொழில்", "நிதி", "பட்ஜெட்", "வரி", "கடன்"], "auto_economics"), (["விஞ்ஞான", "தொழில்நுட்ப", "ஆராய்ச்சி", "கண்டுபிடிப்பு", "விண்வெளி", "அணு", "இயற்பியல்", "வேதியியல்", "உயிரியல்", "கணினி", "செயற்கோள்", "நுட்பம்"], "auto_science"), (["கல்வி", "பள்ளி", "கல்லூரி", "பல்கலைக்கழக", "மாணவர்", "ஆசிரியர்", "பாடம்", "தேர்வு", "படிப்பு", "சான்றிதழ்", "பட்டம்", "உதவித்தொகை"], "auto_education"), (["சினிமா", "திரைப்படம்", "நடிகர்", "இயக்குநர்", "இசை", "பாடல்", "விருது", "நடிகை", "தயாரிப்பாளர்", "வசூல்"], "auto_cinema"), (["விளையாட்டு", "கிரிக்கெட்", "ஃபுட்பால்", "டென்னிஸ்", "ஒலிம்பிக்", "போட்டி", "வெற்றி", "தோல்வி", "வீரர்", "பயிற்சியாளர்", "மைதானம்"], "auto_sports"), (["மருத்துவ", "மருத்துவமனை", "நோய்", "சிகிச்சை", "மருந்து", "டாக்டர்", "தடுப்பூசி", "ஆரோக்கியம்", "அறுவை", "நோயாளி", "சுகாதார"], "auto_health"), (["சுற்றுச்சூழல்", "காடு", "மரம்", "மழை", "காலநிலை", "மாசுபாடு", "பசுமை", "இயற்கை", "கடல்", "மலை", "வெள்ளம்", "வறட்சி"], "auto_environment"), (["வரலாற்று", "சரித்திர", "போர்", "மன்னர்", "சாம்ராஜ்யம்", "படையெடுப்பு", "நினைவுச்சின்னம்", "கல்வெட்டு", "நாகரிகம்"], "auto_history"), (["சமயம்", "மதம்", "கோயில்", "பூஜை", "திருவிழா", "பண்டிகை", "வழிபாடு", "ஆகமம்", "வேதம்"], "auto_religion"), (["விவசாய", "நில", "பயிர்", "உழவு", "நீர்ப்பாசனம்", "விளை", "தோட்டம்", "கால்நடை", "உரம்"], "auto_agriculture"), ] # ═════════════════════════════════════════════════════════════════════════════ # LOAD / SAVE # ═════════════════════════════════════════════════════════════════════════════ def load_clusters() -> dict[str, list[str]]: clusters = {k: list(v) for k, v in SEED_CLUSTERS.items()} if os.path.exists(CLUSTER_FILE): try: with open(CLUSTER_FILE, "r", encoding="utf-8") as f: saved = json.load(f) for cname, items in saved.items(): bucket = clusters.setdefault(cname, []) for item in items: if item not in bucket: bucket.append(item) except (json.JSONDecodeError, OSError): pass return clusters def save_clusters(clusters: dict[str, list[str]]) -> None: with open(CLUSTER_FILE, "w", encoding="utf-8") as f: json.dump(clusters, f, ensure_ascii=False, indent=2) CLUSTERS: dict[str, list[str]] = load_clusters() # ═════════════════════════════════════════════════════════════════════════════ # ANSWER SHAPE (used only as last resort) # ═════════════════════════════════════════════════════════════════════════════ def _answer_shape(answer: str) -> str: answer = answer.strip() if re.fullmatch(r"\d{4}", answer): return "digits_4" if re.fullmatch(r"\d+", answer): return f"digits_{len(answer)}" if re.search(r"\d", answer): return "mixed_digit_tamil" suffix_shapes = [ ("போர்", "shape_war"), ("இயக்கம்", "shape_movement"), ("காலம்", "shape_era"), ("நூல்", "shape_book"), ("ஆறு", "shape_river"), ("நகர்", "shape_city"), ("மாநிலம்", "shape_state"), ("நாடு", "shape_country"), ("மாதம்", "shape_month"), ("நாள்", "shape_day"), ("ஆக", "shape_role_suffix"), ("ராக", "shape_role_suffix"), ("வராக", "shape_role_suffix"), ("ஓவர்கள்", "shape_overs"), ("கிங்ஸ்", "shape_ipl_team"), ("ரைடர்ஸ்", "shape_ipl_team"), ("வாரியர்ஸ்", "shape_ipl_team"), ("இந்தியன்ஸ்", "shape_ipl_team"), ] for suffix, shape in suffix_shapes: if answer.endswith(suffix): return shape wc = len(answer.split()) if wc >= 3: return "tamil_phrase_long" if wc == 2: return "tamil_phrase_2w" return "tamil_single_word" # ═════════════════════════════════════════════════════════════════════════════ # CLUSTER DETECTION # ═════════════════════════════════════════════════════════════════════════════ def detect_cluster_for(answer: str, question: str, context: str = "") -> str: combined = question + " " + context # 1. Direct lookup — already registered for cname, items in CLUSTERS.items(): if answer in items: return cname # 2. Pure answer structure if re.fullmatch(r"\d{4}", answer): return "numeric_years" if re.search(r"\d+ஆம் நூற்றாண்டு", answer): return "centuries" if any(k in answer for k in ["காலம்", "கி.மு", "கி.பி"]): return "historical_eras" if re.fullmatch(r"\d+", answer): return "tamil_numbers" # 3. Keyword match from question + context for keywords, cluster in KEYWORD_TO_CLUSTER: if any(kw in combined for kw in keywords): return cluster # 4. Answer suffix heuristics if answer.endswith("காலம்"): return "historical_eras" if answer.endswith("நூற்றாண்டு"): return "centuries" if answer.endswith(("ஆக", "ராக", "வராக")): return "cricket_roles" if answer.endswith(("ஓவர்கள்", "பிளே")): return "cricket_phases" if answer.endswith(("நாடு", "தேசம்")): return "countries" if answer.endswith("மாதம்"): return "tamil_months" if answer.endswith(("கிங்ஸ்", "ரைடர்ஸ்", "இந்தியன்ஸ்", "வாரியர்ஸ்", "கேபிடல்ஸ்", "டைட்டன்ஸ்", "ஜெயன்ட்ஸ்", "ராயல்ஸ்")): return "ipl_teams" return "__unknown__" def _find_or_create_domain_cluster(answer: str, question: str, context: str) -> str: """ Use sentence context to detect the domain and place the unknown word into a meaningful auto_ cluster instead of a shape-based one. Falls back to shape only when domain is completely undetectable. """ combined = (question + " " + context).strip() for keywords, cluster_name in DOMAIN_SIGNALS: if any(kw in combined for kw in keywords): if cluster_name not in CLUSTERS: CLUSTERS[cluster_name] = [] print(f" [NEW DOMAIN CLUSTER] '{cluster_name}' created for: '{answer}'") return cluster_name # True last resort — domain undetectable, fall back to shape shape = _answer_shape(answer) cluster_name = f"auto_{shape}" if cluster_name not in CLUSTERS: CLUSTERS[cluster_name] = [] return cluster_name # ═════════════════════════════════════════════════════════════════════════════ # PUBLIC API # ═════════════════════════════════════════════════════════════════════════════ def _pos_domain_routing_table() -> dict: return { ("PROPN", "auto_politics"): "politicians", ("NOUN", "auto_politics"): "political_entities", ("PROPN", "auto_history"): "freedom_fighters", ("NOUN", "auto_history"): "history_terms", ("PROPN", "auto_sports"): "cricket_players", ("NOUN", "auto_sports"): "match_formats", ("PROPN", "auto_cinema"): "cinema_terms", ("NOUN", "auto_cinema"): "cinema_terms", ("PROPN", "auto_geography"): "tamilnadu_cities", ("NOUN", "auto_geography"): "indian_states", ("NOUN", "auto_economics"): "economics_terms", ("PROPN", "auto_economics"): "economics_terms", ("NOUN", "auto_science"): "science_terms", ("NOUN", "auto_education"): "education_terms", ("NOUN", "auto_health"): "health_terms", ("NOUN", "auto_environment"): "environment_terms", ("PROPN", "auto_literature"): "tamil_poets", ("NOUN", "auto_literature"): "tamil_literature", } def _find_most_similar_cluster(answer: str, domain_cluster: str, pos_tag: str = "NOUN") -> str: """ Refine a broad auto_ cluster to the best named sub-cluster using the POS+domain routing table built by _pos_domain_routing_table(). Falls back to domain_cluster if no rule matches or the target cluster does not exist yet in CLUSTERS. """ routing = _pos_domain_routing_table() # Try exact pos+domain key first target = routing.get((pos_tag, domain_cluster)) if target and target in CLUSTERS: return target # Fallback: try NOUN key (covers cases where POS detection was uncertain) target = routing.get(("NOUN", domain_cluster)) if target and target in CLUSTERS: return target return domain_cluster def auto_register(answer: str, question: str, context: str = "", pos_tag: str = "NOUN") -> str: """ Detect the best cluster for `answer`, register it, and persist to disk. Parameters ---------- answer : root form of the answer word question : the MCQ question string (used for keyword matching) context : original sentence (used for domain detection) pos_tag : Stanza UPOS tag of the answer word — NOUN / PROPN / VERB etc. Drives sub-cluster routing when the word is not yet in the KB. Flow ---- 1. Direct lookup → already in KB, return immediately (no write). 2. Structural pattern (4-digit year, century string, plain digit). 3. Keyword match from question+context → named cluster. 4. Answer suffix heuristics. 5. Domain detection from context → broad auto_ bucket. 6. POS + domain routing → refined named sub-cluster. """ cluster_name = detect_cluster_for(answer, question, context) if cluster_name == "__unknown__": domain_cluster = _find_or_create_domain_cluster(answer, question, context) cluster_name = _find_most_similar_cluster(answer, domain_cluster, pos_tag) print(f" [auto_register] '{answer}' ({pos_tag}) " f"→ '{cluster_name}' (domain: {domain_cluster})") bucket = CLUSTERS.setdefault(cluster_name, []) if answer not in bucket: bucket.append(answer) save_clusters(CLUSTERS) print(f" [KB] Added '{answer}' to cluster '{cluster_name}'") return cluster_name # Clusters that should NEVER appear as distractors for non-cricket answers CRICKET_ONLY_CLUSTERS = { "cricket_shots", "cricket_nicknames", "cricket_phases", "cricket_roles", "cricket_players", "bowling_types", "cricket_double_centuries", "match_formats", "ipl_teams", } # Groups of related clusters — widening stays within the same group CLUSTER_GROUPS: list[set[str]] = [ {"political_bodies", "political_entities", "political_processes", "political_concepts", "governance_terms", "tamil_regions", "politicians", "auto_politics"}, {"tamil_literature", "tamil_poets", "freedom_fighters", "dynasties", "historical_eras", "history_terms", "auto_history"}, {"indian_states", "tamilnadu_cities", "countries", "indian_rivers", "directions", "tamil_regions"}, {"economics_terms", "auto_economics"}, {"science_terms", "auto_science"}, {"education_terms", "auto_education"}, {"cinema_terms", "auto_cinema"}, {"health_terms", "auto_health"}, {"environment_terms", "auto_environment"}, {"auto_religion"}, {"auto_agriculture"}, {"cricket_shots", "cricket_nicknames", "cricket_phases", "cricket_roles", "cricket_players", "bowling_types", "cricket_double_centuries", "match_formats", "ipl_teams", "auto_sports"}, ] def _related_clusters(cluster_name: str) -> set[str]: """Return all cluster names in the same group as cluster_name.""" for group in CLUSTER_GROUPS: if cluster_name in group: return group - {cluster_name} return set() def _is_cricket_cluster(cluster_name: str) -> bool: return cluster_name in CRICKET_ONLY_CLUSTERS def generate_distractors(answer: str, question: str, context: str = "", k: int = 3, pos_tag: str = "NOUN") -> list[str]: """Return k plausible-but-wrong options for the given answer.""" cluster_name = auto_register(answer, question, context, pos_tag=pos_tag) is_cricket = _is_cricket_cluster(cluster_name) # Step 1: primary cluster pool pool = [c for c in CLUSTERS.get(cluster_name, []) if c != answer] # Step 2: widen within the same cluster GROUP only if len(pool) < k: related = _related_clusters(cluster_name) for cname in related: for it in CLUSTERS.get(cname, []): if it not in pool and it != answer: pool.append(it) # Step 3: widen by answer shape — but NEVER cross into cricket # unless the answer itself is cricket if len(pool) < k: my_shape = _answer_shape(answer) for cname, items in CLUSTERS.items(): if cname == cluster_name: continue if not is_cricket and _is_cricket_cluster(cname): continue # hard block — never pull cricket into non-cricket for it in items: if _answer_shape(it) == my_shape and it not in pool and it != answer: pool.append(it) # Step 4: absolute last resort — anything, still blocking cricket for non-cricket if len(pool) < k: for cname, items in CLUSTERS.items(): if not is_cricket and _is_cricket_cluster(cname): continue # hard block maintained for it in items: if it != answer and it not in pool: pool.append(it) random.shuffle(pool) return pool[:k] def build_mcq(question: str, answer: str, context: str = "") -> dict: distractors = generate_distractors(answer, question, context, k=3) options = list(dict.fromkeys([answer] + distractors))[:4] if len(options) < 4: cname = auto_register(answer, question, context) extra = [c for c in CLUSTERS.get(cname, []) if c not in options] random.shuffle(extra) options += extra[:4 - len(options)] random.shuffle(options) labeled = {chr(65 + i): opt for i, opt in enumerate(options[:4])} correct_label = next(lbl for lbl, val in labeled.items() if val == answer) return { "question": question, "options": labeled, "answer": answer, "correct_label": correct_label, }