"""Training-aligned PyVi and RDRSegmenter pipelines, with exact text caches.""" import atexit, gzip, html, json, re, threading, unicodedata from functools import lru_cache from pathlib import Path from runtime_setup import activate_java import text_pipeline as tp ROOT = Path(__file__).resolve().parent _segmenter = None _segment_lock = threading.Lock() @lru_cache(maxsize=1) def cached_corpus(): path = ROOT / 'resources/preprocessed_cache.json.gz' if not path.exists(): return {} with gzip.open(path, 'rt', encoding='utf-8') as f: return json.load(f) @lru_cache(maxsize=1) def stopwords(): words = set() for line in (ROOT / 'resources/vietnamese-stopwords.txt').read_text(encoding='utf-8').splitlines(): w = line.strip().lower() if w: words.update([w, w.replace(' ', '_')]) return words @lru_cache(maxsize=25000) def basic(text): raw = str(text).strip() hit = cached_corpus().get(raw) if hit: return dict(clean=hit[0], seg_text=hit[1], nostop_text=hit[2], inference_text=normalize_raw(raw), obfuscation_changed=False, obfuscation_replacements=[], robust_mode='training_cache', source='training_pipeline_cache') if not tp.HAS_EMOJI_LIB or not tp.HAS_TONE_LIB: raise RuntimeError('Thiếu emoji/viet-text-tools. Chạy lại 01_CAI_DAT.bat để khớp tiền xử lý lúc train.') inference_text, replacements = normalize_obfuscation(raw) clean, tokens, seg = tp.to_seg_text(inference_text) nostop = ' '.join(w for w in tokens if w.lower() not in stopwords()) return dict(clean=clean, seg_text=seg, nostop_text=nostop, inference_text=inference_text, obfuscation_changed=bool(replacements), obfuscation_replacements=replacements, robust_mode='training_lexicon_leetspeak_v1', source='live_pyvi_robust' if replacements else 'live_pyvi') def normalize_raw(text): text = unicodedata.normalize('NFC', html.unescape(str(text))) text = re.sub('[\u200b\u200c\u200d\ufeff]', '', text) return re.sub(r'\s+', ' ', text).strip() # Chỉ giải mã leetspeak khi từ sau giải mã đã xuất hiện trong chính pipeline # Train/Dev/Test. Cách này nhận được c0n -> con, h3o -> heo nhưng tránh biến # các mã hợp lệ như H1N1, F0 hoặc COVID-19 thành từ tùy ý. LEET_TOKEN_RE = re.compile(r'(? left for left, right in protected) def normalize_obfuscation(text): """Giải mã leetspeak có kiểm soát và trả cả nhật ký thay thế. URL, email và số điện thoại được bảo vệ. Mọi thay thế đều có thể kiểm tra trong JSON/CSV và giao diện; hàm này không gán nhãn nội dung. """ normalized = normalize_raw(text) protected = [] for pattern in (tp.URL_RE, BARE_URL_RE, tp.EMAIL_RE, tp.PHONE_RE): protected.extend(match.span() for match in pattern.finditer(normalized)) lexicon = training_lexicon() replacements = [] def replace(match): original = match.group(0) start, _ = match.span() if (_overlaps(match.span(), protected) or (start and normalized[start-1] == '%') or not any(ch.isalpha() for ch in original)): return original low = original.casefold() if low in LEET_EXACT: if low == '0c' and not OC_FOLLOW_RE.match(normalized[match.end():]): return original candidate = LEET_EXACT[low] strategy = 'exact_diacritic_restore' else: if LEET_BLOCK_RE.fullmatch(low): return original # Kiểu né tránh tổng quát phải có số nằm giữa chữ: c0n, h3o, # d4ng... Loại số lượng/mốc giờ/mã sản phẩm như 1k, 8h, A4, L3. if len(original) < 3 or not original[0].isalpha() or not original[-1].isalpha(): return original if any(ch.isdigit() and ch not in LEET_CHARS for ch in original): return original candidate = ''.join(LEET_CHARS.get(ch, ch) for ch in low) if candidate == low or candidate not in lexicon: return original strategy = 'training_lexicon_leetspeak' if original.isupper(): candidate = candidate.upper() elif original[:1].isupper(): candidate = candidate[:1].upper() + candidate[1:] replacements.append(dict(original=original, normalized=candidate, strategy=strategy)) return candidate return LEET_TOKEN_RE.sub(replace, normalized), replacements RISK_PATTERNS = ( ('animal_brain_insult', re.compile(r'\b(?:óc|oc)[ _]+(?:chó|cho|heo|lợn|lon|cứt|cut|vật)\b', re.IGNORECASE)), ('direct_personal_insult', re.compile(r'\b(?:đồ|thằng|con|mày|may)[ _]+(?:ngu|đần|khốn|điên|chó|heo|lợn)\b', re.IGNORECASE)), ('restored_profanity', re.compile(r'\b(?:địt|đéo|lồn|cặc)\b', re.IGNORECASE)), ) def guardrail_assessment(processed): """Cờ rà soát độc lập, không tham gia vote và không sửa điểm mô hình.""" searchable = str(processed.get('clean', '')).replace('_', ' ') matches = [] for category, pattern in RISK_PATTERNS: for match in pattern.finditer(searchable): value = match.group(0) if not any(item['text'].casefold() == value.casefold() for item in matches): matches.append(dict(category=category, text=value)) changed = bool(processed.get('obfuscation_changed')) review = changed and bool(matches) return dict(status='review' if review else ('normalized' if changed else 'clear'), review_required=review, contributes_to_vote=False, matches=matches, note=('Phát hiện cách viết né tránh và cụm từ có nguy cơ công kích; ' 'cần đọc cùng dự đoán mô hình.' if review else None)) @lru_cache(maxsize=25000) def phobert_text(text): global _segmenter hit = cached_corpus().get(text.strip()) if hit: return hit[3] normalized, _ = normalize_obfuscation(text) if not normalized: return '' with _segment_lock: if _segmenter is None: activate_java() jar = ROOT / 'resources/vncorenlp/VnCoreNLP-1.2.jar' if not jar.is_file(): raise RuntimeError('Thiếu VnCoreNLP. Chạy python scripts/setup_resources.py.') from vncorenlp import VnCoreNLP _segmenter = VnCoreNLP(str(jar), annotators='wseg', max_heap_size='-Xmx512m') atexit.register(_segmenter.close) sentences = _segmenter.tokenize(normalized) return ' '.join(' '.join(s) for s in sentences).strip() or ''