Spaces:
Running
Running
| """ | |
| term_extractor.py -- LLM + XLMR hate term extraction. | |
| English: Qwen LLM (when cached) or XLMR similarity fallback | |
| Amharic: Afro-XLMR semantic similarity (n-gram + similarity scoring) | |
| Oromo: Afro-XLMR semantic similarity (n-gram + similarity scoring) | |
| """ | |
| import os, sys, re, json | |
| BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) | |
| sys.path.insert(0, BASE) | |
| def detect_language(text): | |
| am = sum(1 for c in str(text) if "\u1200" <= c <= "\u137f") | |
| if am > 2: | |
| return "amharic" | |
| oromo_markers = ["dha", "keenya", "biyya", "qabna", "jirti", "isaan", "kana", | |
| "qabna", "barbaachisaa", "balleessaa", "hordoftoota"] | |
| if any(w in str(text).lower().split() for w in oromo_markers): | |
| return "oromo" | |
| return "english" | |
| def _get_llm(lang="english"): | |
| import torch | |
| hf_token = os.environ.get("HF_TOKEN", None) | |
| _pn = sys.modules.get("pattern_namer") | |
| _reg = getattr(_pn, "_model_registry", {}) if _pn else {} | |
| if lang == "amharic" and "CohereLabs/aya-expanse-8b" in _reg: | |
| return _reg["CohereLabs/aya-expanse-8b"] | |
| model_name = "Qwen/Qwen2.5-1.5B-Instruct" | |
| if model_name in _reg: | |
| return _reg[model_name] | |
| from transformers import AutoTokenizer, AutoModelForCausalLM | |
| tokenizer = AutoTokenizer.from_pretrained(model_name, token=hf_token) | |
| if tokenizer.pad_token is None: | |
| tokenizer.pad_token = tokenizer.eos_token | |
| model = AutoModelForCausalLM.from_pretrained( | |
| model_name, torch_dtype=torch.bfloat16, device_map="auto", token=hf_token | |
| ) | |
| model.eval() | |
| if _pn is not None: | |
| if not hasattr(_pn, "_model_registry"): | |
| _pn._model_registry = {} | |
| _pn._model_registry[model_name] = (tokenizer, model) | |
| return tokenizer, model | |
| _COMMON_SINGLES = { | |
| "islam","muslim","muslims","christian","christians","orthodox", | |
| "jewish","jew","oromo","amhara","tigray","ethiopia","ethiopian", | |
| "kill","destroy","destroying","hate","disease","culture","women", | |
| "church","churches","mosque","mosques","religion","people", | |
| } | |
| def _get_xlmr_enc(): | |
| """Get XLMR encoder -- tries ml_engine first, then loads directly.""" | |
| import sys | |
| _me = sys.modules.get("detector.ml_engine") | |
| if _me is None: | |
| try: | |
| import detector.ml_engine as _me | |
| except Exception: | |
| return None | |
| enc = getattr(_me, "_xlmr_enc", None) | |
| if enc is None: | |
| try: | |
| _me._clf = None | |
| _me._load() | |
| enc = getattr(_me, "_xlmr_enc", None) | |
| except Exception as e: | |
| print(f" [xlmr] load failed: {e}") | |
| return enc | |
| def _is_meaningful_gram(gram, source_text=""): | |
| """Filter out grammatical fragments that carry no standalone hate signal.""" | |
| words = gram.split() | |
| # Ethiopic: reject if all words are very short (likely particles/conjunctions) | |
| am_words = [w for w in words if any("\u1200" <= c <= "\u137f" for c in w)] | |
| if am_words and all(len(w) <= 3 for w in am_words): | |
| return False | |
| # Reject pure connectors (Amharic common particles) | |
| AM_PARTICLES = {"ነው","ናቸው","ነበር","ይሆናል","ነበሩ","ሲሆን","እና","ወይም", | |
| "ግን","ስለዚህ","ነገር","ሆኖም","አለ","አሉ","ነን","ናቸን"} | |
| if am_words and all(w in AM_PARTICLES for w in am_words): | |
| return False | |
| # Latin: reject if all words are stopwords or particles | |
| lat_words = [w for w in words if all("a" <= c <= "z" for c in w.lower())] | |
| LAT_PARTICLES = {"the","a","an","is","it","in","on","at","to","for","of", | |
| "and","or","but","not","are","was","be","as","by","with", | |
| "from","have","has","they","we","dha","fi","irratti"} | |
| if lat_words and all(w.lower() in LAT_PARTICLES for w in lat_words): | |
| return False | |
| return True | |
| def _xlmr_score(ngrams, hate_anchors, threshold=0.75, max_keep=10): | |
| """Score n-grams against hate anchors using XLMR similarity.""" | |
| import torch | |
| import torch.nn.functional as F | |
| import numpy as np | |
| enc = _get_xlmr_enc() | |
| if enc is None: | |
| return [] | |
| def encode(texts): | |
| arr = enc._encode_batch(texts) | |
| embs = torch.tensor(np.array(arr), dtype=torch.float32) | |
| return F.normalize(embs, dim=-1) | |
| # Pre-filter nonsense grams before scoring | |
| filtered_ngrams = {g: v for g, v in ngrams.items() | |
| if _is_meaningful_gram(g, v.get("text",""))} | |
| if not filtered_ngrams: | |
| return [] | |
| gram_list = list(filtered_ngrams.keys())[:40] | |
| anchor_embs = encode(hate_anchors) | |
| gram_embs = encode(gram_list) | |
| sims = torch.mm(gram_embs, anchor_embs.T) | |
| best_sims = sims.max(dim=1).values | |
| scored = sorted( | |
| [(gram_list[i], best_sims[i].item()) for i in range(len(gram_list)) | |
| if best_sims[i].item() >= threshold], | |
| key=lambda x: -x[1] | |
| ) | |
| kept_terms = [] | |
| results = [] | |
| for gram, sim in scored: | |
| g_words = gram.split() | |
| already_covered = any( | |
| all(w in k.split() for w in g_words) and len(k.split()) > len(g_words) | |
| for k in kept_terms | |
| ) | |
| if already_covered: | |
| continue | |
| kept_terms.append(gram) | |
| results.append((gram, sim)) | |
| if len(results) >= max_keep: | |
| break | |
| return results | |
| def _get_lexicon_words(): | |
| """Get known hate words from the lexicon DB for anchor filtering.""" | |
| try: | |
| from detector.models import LexiconEntry | |
| words = set() | |
| for term in LexiconEntry.objects.exclude( | |
| grouped_label="Normal").values_list("term", flat=True): | |
| for w in str(term).strip().split(): | |
| if len(w) > 2: | |
| words.add(w.lower()) | |
| return words | |
| except Exception: | |
| return set() | |
| def _extract_amharic_terms(batch, existing_lower, all_results): | |
| HATE_ANCHORS_AM = [ | |
| "\u12a0\u123b\u1263\u122a\u12cd\u127d", | |
| "\u130b\u120b\u1276\u127b\u12c8\u1295", | |
| "\u12cd\u123b \u1293\u1278\u12c8", | |
| "\u1218\u1263\u1228\u122d \u12a0\u1208\u1263\u1278\u12c8", | |
| "\u12ad\u122d\u1235\u1272\u12eb\u1293\u127d\u1295 \u12eb\u1243\u1325\u120b\u120d", | |
| "\u130d\u1295\u1299\u12cd\u1295 \u1270\u12cd\u1290\u1271", | |
| "\u1208\u1240\u12c6\u1295 \u1270\u12cd\u1290\u1271", | |
| ] | |
| ngrams = {} | |
| for r in batch: | |
| text = r.get("text", "") | |
| label = r.get("label") or r.get("predicted_label", "Identity-Based Hate") | |
| words = re.findall(r"[\u1200-\u137f]+", text) | |
| for n in range(2, 5): | |
| for i in range(len(words) - n + 1): | |
| gram = " ".join(words[i:i+n]) | |
| if gram.lower() not in existing_lower and gram not in ngrams: | |
| ngrams[gram] = {"text": text, "label": label} | |
| if not ngrams: | |
| return | |
| try: | |
| # Only keep n-grams containing at least one known hate word from lexicon | |
| lexicon_words = _get_lexicon_words() | |
| if lexicon_words: | |
| filtered = {} | |
| for gram, meta in ngrams.items(): | |
| gram_words = {w.lower() for w in gram.split()} | |
| if gram_words & lexicon_words: | |
| filtered[gram] = meta | |
| ngrams = filtered if filtered else ngrams | |
| print(f" [amharic_extract] {len(ngrams)} n-grams contain lexicon words") | |
| scored = _xlmr_score(ngrams, HATE_ANCHORS_AM, threshold=0.78) | |
| for gram, sim in scored: | |
| if any(c["term"].lower() == gram.lower() for c in all_results): | |
| continue | |
| # Prefer shorter focused phrases over long sentence fragments | |
| if len(gram.split()) > 4: | |
| continue | |
| meta = ngrams[gram] | |
| all_results.append({ | |
| "term": gram, | |
| "suggested_label": meta["label"], | |
| "extraction_method": "amharic_xlmr", | |
| "score": round(sim, 3), | |
| "source_text": meta["text"][:300], | |
| "language": "amharic", | |
| }) | |
| print(f" [amharic_extract] sim={sim:.3f} Kept: \'{gram}\'") | |
| except Exception as e: | |
| print(f" [amharic_extract] Error: {e}") | |
| def _extract_oromo_terms(batch, existing_lower, all_results): | |
| HATE_ANCHORS_OM = [ | |
| "diina keenya", | |
| "ari\'uu qabna", | |
| "baasuun barbaachisaa", | |
| "ajjeesuu qabna", | |
| "balleessuun barbaachisaa", | |
| "gad aanaa dha", | |
| "biyya keenya keessaa", | |
| ] | |
| EN_STOP = {"the","a","an","is","it","in","on","at","to","for","of","and", | |
| "or","but","not","are","was","be","as","by","this","that","with", | |
| "from","have","has","they","we","you","i","he","she","our","their"} | |
| ngrams = {} | |
| for r in batch: | |
| text = r.get("text", "") | |
| label = r.get("label") or r.get("predicted_label", "Identity-Based Hate") | |
| words = [w for w in re.findall(r"[a-zA-Z\']+", text.lower()) | |
| if len(w) > 3 and w not in EN_STOP] | |
| for n in range(2, 5): | |
| for i in range(len(words) - n + 1): | |
| gram = " ".join(words[i:i+n]) | |
| if gram not in existing_lower and gram not in ngrams: | |
| ngrams[gram] = {"text": text, "label": label} | |
| if not ngrams: | |
| return | |
| try: | |
| scored = _xlmr_score(ngrams, HATE_ANCHORS_OM) | |
| for gram, sim in scored: | |
| if any(c["term"].lower() == gram.lower() for c in all_results): | |
| continue | |
| meta = ngrams[gram] | |
| all_results.append({ | |
| "term": gram, | |
| "suggested_label": meta["label"], | |
| "extraction_method": "oromo_xlmr", | |
| "score": round(sim, 3), | |
| "source_text": meta["text"][:300], | |
| "language": "oromo", | |
| }) | |
| print(f" [oromo_extract] sim={sim:.3f} Kept: \'{gram}\'") | |
| except Exception as e: | |
| print(f" [oromo_extract] Error: {e}") | |
| def _extract_english_terms_xlmr(batch, existing_lower, all_results): | |
| HATE_ANCHORS_EN = [ | |
| "kill them all", | |
| "must be expelled", | |
| "should be burned", | |
| "muslim savages", | |
| "islamic extremists", | |
| "jihadists terrorists", | |
| "subhuman parasites", | |
| "expel them all", | |
| "infidels must die", | |
| "ethnic cleansing", | |
| "cockroaches vermin", | |
| "must be eliminated", | |
| ] | |
| EN_STOP = {"the","a","an","is","it","in","on","at","to","for","of","and", | |
| "or","but","not","are","was","be","as","by","this","that","with", | |
| "from","have","has","they","we","you","i","he","she","our","their", | |
| "these","those","who","what","should","would","could","will","all"} | |
| ngrams = {} | |
| for r in batch: | |
| text = r.get("text", "") | |
| label = r.get("label") or r.get("predicted_label", "Identity-Based Hate") | |
| words = [w.lower() for w in re.findall(r"[a-zA-Z\']+", text) | |
| if len(w) > 2 and w.lower() not in EN_STOP] | |
| for n in range(2, 5): | |
| for i in range(len(words) - n + 1): | |
| gram = " ".join(words[i:i+n]) | |
| if gram not in existing_lower and gram not in ngrams: | |
| ngrams[gram] = {"text": text, "label": label} | |
| if not ngrams: | |
| return | |
| try: | |
| scored = _xlmr_score(ngrams, HATE_ANCHORS_EN, threshold=0.72) | |
| for gram, sim in scored: | |
| if any(c["term"].lower() == gram.lower() for c in all_results): | |
| continue | |
| all_results.append({ | |
| "term": gram, | |
| "suggested_label": ngrams[gram]["label"], | |
| "extraction_method": "english_xlmr", | |
| "score": round(sim, 3), | |
| "source_text": ngrams[gram]["text"][:300], | |
| "language": "english", | |
| }) | |
| print(f" [english_xlmr] sim={sim:.3f} Kept: \'{gram}\'") | |
| except Exception as e: | |
| print(f" [english_xlmr] Error: {e}") | |
| def extract_candidates(hate_flagged_rows, normal_rows=None, | |
| existing_terms=None, max_candidates=40): | |
| """LLM + XLMR extraction with language routing.""" | |
| if not hate_flagged_rows: | |
| return [] | |
| if existing_terms is None: | |
| try: | |
| from detector.models import LexiconEntry | |
| existing_terms = set(LexiconEntry.objects.values_list("term", flat=True)) | |
| except Exception: | |
| existing_terms = set() | |
| existing_lower = {t.lower().strip() for t in existing_terms} | |
| by_lang = {"english": [], "amharic": [], "oromo": []} | |
| for r in hate_flagged_rows: | |
| by_lang[detect_language(r.get("text", ""))].append(r) | |
| all_results = [] | |
| batch_size = 20 | |
| lang_batches = [] | |
| for lang_key, lang_rows in [("english", by_lang["english"]), | |
| ("amharic", by_lang["amharic"]), | |
| ("oromo", by_lang["oromo"])]: | |
| for start in range(0, len(lang_rows), batch_size): | |
| b = lang_rows[start:start + batch_size] | |
| if b: | |
| lang_batches.append((lang_key, b)) | |
| for lang_key, batch in lang_batches: | |
| if lang_key != "english": | |
| continue | |
| # Check if Qwen is available | |
| _pn = sys.modules.get("pattern_namer") | |
| _reg = getattr(_pn, "_model_registry", {}) if _pn else {} | |
| _qwen = "Qwen/Qwen2.5-1.5B-Instruct" in _reg | |
| if not _qwen: | |
| print(f" [llm_extract] Qwen not cached -- using XLMR for English") | |
| _extract_english_terms_xlmr(batch, existing_lower, all_results) | |
| continue | |
| try: | |
| import torch | |
| tokenizer, model = _get_llm("english") | |
| print(f" [llm_extract] Analyzing {len(batch)} texts (english)...") | |
| texts_block = chr(10).join( | |
| f"{i+1}. {r.get(chr(116)+chr(101)+chr(120)+chr(116), chr(39)+chr(39))[:250]}" | |
| for i, r in enumerate(batch) | |
| ) | |
| texts_block = chr(10).join( | |
| f"{i+1}. {r.get('text', '')[:250]}" for i, r in enumerate(batch) | |
| ) | |
| prompt = ( | |
| "Extract the key hate-bearing PHRASES (2-5 words max) from these texts." + chr(10) | |
| + "Extract the specific slur, insult, or violent phrase -- NOT the whole sentence." + chr(10) | |
| + "Copy words exactly as written. Output a flat JSON array only." + chr(10) + chr(10) | |
| + "Example:" + chr(10) | |
| + "Input: These muslim savages must be expelled from our country" + chr(10) | |
| + "Output: [" + chr(34) + "muslim savages" + chr(34) | |
| + ", " + chr(34) + "must be expelled" + chr(34) + "]" + chr(10) + chr(10) | |
| + "Texts:" + chr(10) + texts_block + chr(10) + chr(10) | |
| + "Output (2-5 word phrases only, flat JSON array):" | |
| ) | |
| messages = [{"role": "user", "content": prompt}] | |
| try: | |
| text_input = tokenizer.apply_chat_template( | |
| messages, tokenize=False, add_generation_prompt=True) | |
| except Exception: | |
| text_input = prompt | |
| inputs = tokenizer(text_input, return_tensors="pt", | |
| truncation=True, max_length=3500).to(model.device) | |
| with torch.no_grad(): | |
| gen_ids = model.generate(**inputs, max_new_tokens=400, do_sample=False, | |
| pad_token_id=tokenizer.pad_token_id) | |
| raw = tokenizer.decode(gen_ids[0][inputs["input_ids"].shape[1]:], | |
| skip_special_tokens=True).strip() | |
| print(f" [llm_extract] Response: {raw[:100]}") | |
| match = re.search(r"\[.*?\]", raw, re.DOTALL) | |
| if not match: | |
| continue | |
| try: | |
| parsed = json.loads(match.group(0)) | |
| except json.JSONDecodeError: | |
| parsed = re.findall(r'"\'([^\']{3,})\"\'', raw) | |
| terms = [] | |
| for item in parsed: | |
| if isinstance(item, list): | |
| terms.extend([x for x in item if isinstance(x, str)]) | |
| elif isinstance(item, str): | |
| terms.append(item) | |
| batch_text_lower = " ".join(r.get("text", "").lower() for r in batch) | |
| for term in terms: | |
| if not isinstance(term, str) or len(term.strip()) < 3: | |
| continue | |
| t_clean = term.strip() | |
| if t_clean.lower() in existing_lower: | |
| continue | |
| if t_clean.lower() not in batch_text_lower: | |
| print(f" [llm_extract] Rejected hallucination: {t_clean}") | |
| continue | |
| if len(t_clean.split()) == 1 and t_clean.lower() in _COMMON_SINGLES: | |
| print(f" [llm_extract] Rejected common single word: {t_clean}") | |
| continue | |
| if len(t_clean.split()) > 6: | |
| print(f" [llm_extract] Rejected full sentence: {t_clean[:50]}") | |
| continue | |
| if any(c["term"].lower() == t_clean.lower() for c in all_results): | |
| continue | |
| source, label = "", "Identity-Based Hate" | |
| for r in batch: | |
| if t_clean.lower() in r.get("text", "").lower(): | |
| source = r.get("text", "")[:300] | |
| label = r.get("label") or r.get("predicted_label", "Identity-Based Hate") | |
| break | |
| all_results.append({ | |
| "term": t_clean, | |
| "suggested_label": label, | |
| "extraction_method": "llm_direct", | |
| "score": 8.0, | |
| "source_text": source, | |
| "language": detect_language(t_clean), | |
| }) | |
| except Exception as e: | |
| print(f" [llm_extract] Error: {e}") | |
| _extract_english_terms_xlmr(batch, existing_lower, all_results) | |
| # XLMR extraction for Amharic and Oromo | |
| for lang_key, batch in lang_batches: | |
| if lang_key == "amharic": | |
| _extract_amharic_terms(batch, existing_lower, all_results) | |
| elif lang_key == "oromo": | |
| _extract_oromo_terms(batch, existing_lower, all_results) | |
| print(f" [llm_extract] Total extracted: {len(all_results)}") | |
| return all_results[:max_candidates] | |