File size: 8,326 Bytes
2d741b1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
"""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'(?<!\w)[\w]*\d[\w]*(?!\w)', re.UNICODE)
LEET_BLOCK_RE = re.compile(r'^(?:f\d{1,2}|h\d+n\d+|[a-z]\d{2,})$', re.IGNORECASE)
BARE_URL_RE = re.compile(r'\b(?:[a-z0-9-]+\.)+(?:com|net|org|vn|io|co|edu|gov)(?:/\S*)?', re.IGNORECASE)
OC_FOLLOW_RE = re.compile(r'^[\s._-]+(?:h3o|heo|ch0|chó|cho|cứt|cut|vật|lợn|lon)\b', re.IGNORECASE)
LEET_CHARS = {'0': 'o', '1': 'i', '3': 'e', '4': 'a',
              '5': 's', '7': 't', '8': 'b', '9': 'g'}
LEET_EXACT = {'0c': 'óc', 'm4y': 'mày', 't4o': 'tao'}


@lru_cache(maxsize=1)
def training_lexicon():
    """Từ vựng cho phép dùng khi giải mã, lấy từ dữ liệu đã xử lý thật.

    Không dùng nhãn và không thêm tín hiệu TOXIC. Từ vựng chỉ quyết định một
    token chữ-số có phải là cách viết né tránh hợp lý hay không.
    """
    words = set(stopwords())
    for values in cached_corpus().values():
        for value in values[:3]:
            for token in str(value).casefold().split():
                words.add(token)
                words.update(part for part in token.split('_') if part)
    for key, value in tp.TEENCODE_MAP.items():
        words.add(key.casefold())
        words.update(value.casefold().replace('_', ' ').split())
    words.update(LEET_EXACT.values())
    return words


def _overlaps(span, protected):
    start, end = span
    return any(start < right and end > 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 '<unk>'
    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 '<unk>'