mcq / cluster_store.py
4deffo's picture
Upload folder using huggingface_hub
b96156b verified
Raw
History Blame Contribute Delete
42.9 kB
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_<domain> 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_<domain> 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_<domain> 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,
}