Spaces:
Runtime error
Runtime error
| """ | |
| TextNormalizer — lớp chuẩn hóa truy vấn tiếng Việt (khối "Text Normalization" | |
| trong pipeline của bài báo, Fig. 2). | |
| Người dùng thật gõ thiếu dấu, viết tắt và sai hoa thường rất thường xuyên | |
| ("quân binh thanh", "tp.hcm", "q.1"...). Lớp này chạy TRƯỚC NER/Intent để các | |
| tầng sau nhận được văn bản sạch: | |
| 1. Chuẩn hóa Unicode (NFC) + gom khoảng trắng thừa. | |
| 2. Bung viết tắt phổ biến: tp.hcm → Thành phố Hồ Chí Minh, | |
| q.1/q1 → Quận 1, p.5 → Phường 5, h. → Huyện, tx. → Thị xã ... | |
| 3. Phục hồi dấu cho ĐỊA DANH bằng gazetteer dựng từ datasets/Wards.csv và | |
| Provinces.csv: "quan binh thanh" → "Quận Bình Thạnh", | |
| "phuong linh tay" → "Phường Linh Tây". So khớp không phân biệt | |
| dấu/hoa-thường, ưu tiên cụm dài nhất, chỉ thay khi đúng ranh giới từ. | |
| Mọi phép biến đổi được ghi lại trong `NormalizedQuery.changes` để có thể | |
| hiển thị/giải thích (provenance). | |
| """ | |
| from __future__ import annotations | |
| import csv | |
| import logging | |
| import re | |
| import unicodedata | |
| from dataclasses import dataclass, field | |
| from pathlib import Path | |
| from typing import Dict, List, Optional, Tuple | |
| logger = logging.getLogger(__name__) | |
| _REPO_ROOT = Path(__file__).resolve().parents[2] | |
| _DATASETS = _REPO_ROOT / "datasets" | |
| _TYPE_TITLE = { | |
| "phường": "Phường", "xã": "Xã", "thị trấn": "Thị trấn", "thị xã": "Thị xã", | |
| "quận": "Quận", "huyện": "Huyện", "thành phố": "Thành phố", | |
| } | |
| def fold(s: str) -> str: | |
| """Bỏ dấu + thường hóa, ánh xạ 1:1 từng ký tự (giữ nguyên offset).""" | |
| s = (s or "").replace("đ", "d").replace("Đ", "D") | |
| nfd = unicodedata.normalize("NFD", s) | |
| return "".join(c for c in nfd if unicodedata.category(c) != "Mn").lower() | |
| def _cap_type_phrase(phrase: str) -> str: | |
| """'quận bình thạnh' -> 'Quận Bình Thạnh' (viết hoa từ loại + tên riêng).""" | |
| phrase = (phrase or "").strip() | |
| low = phrase.lower() | |
| for t in sorted(_TYPE_TITLE, key=len, reverse=True): | |
| if low.startswith(t + " ") or low == t: | |
| rest = phrase[len(t):].strip() | |
| rest_cap = " ".join(w[:1].upper() + w[1:] for w in rest.split()) | |
| return f"{_TYPE_TITLE[t]} {rest_cap}".strip() | |
| return phrase | |
| class NormalizedQuery: | |
| original: str | |
| text: str | |
| changes: List[Tuple[str, str, str]] = field(default_factory=list) # (from, to, kind) | |
| def changed(self) -> bool: | |
| return self.text != self.original | |
| class TextNormalizer: | |
| """Singleton qua get_text_normalizer(). Gazetteer build một lần (~5k mục).""" | |
| # (pattern, replacement, kind) — chạy theo thứ tự, case-insensitive | |
| _ABBREVIATIONS: List[Tuple[str, str, str]] = [ | |
| (r"\btp\.?\s*hcm\b", "Thành phố Hồ Chí Minh", "abbrev"), | |
| (r"\btphcm\b", "Thành phố Hồ Chí Minh", "abbrev"), | |
| (r"\bhcm\b", "Hồ Chí Minh", "abbrev"), | |
| (r"\btp\.\s*(?=\w)", "Thành phố ", "abbrev"), | |
| (r"\btx\.\s*(?=\w)", "Thị xã ", "abbrev"), | |
| (r"\btt\.\s*(?=\w)", "Thị trấn ", "abbrev"), | |
| (r"\bq\.?\s*(\d{1,2})\b", r"Quận \1", "abbrev"), | |
| (r"\bp\.?\s*(\d{1,2})\b", r"Phường \1", "abbrev"), | |
| (r"\bq\.\s*(?=\w)", "Quận ", "abbrev"), | |
| (r"\bp\.\s*(?=\w)", "Phường ", "abbrev"), | |
| (r"\bh\.\s*(?=\w)", "Huyện ", "abbrev"), | |
| ] | |
| def __init__(self) -> None: | |
| # folded phrase -> canonical phrase; sắp theo độ dài giảm dần khi quét | |
| self._gazetteer: Dict[str, str] = {} | |
| self._build_gazetteer() | |
| self._entries = sorted(self._gazetteer.items(), key=lambda kv: -len(kv[0])) | |
| # ------------------------------------------------------------- gazetteer | |
| def _add(self, canonical: str, min_len: int = 0) -> None: | |
| canonical = (canonical or "").strip() | |
| f = fold(canonical) | |
| if len(f) >= max(min_len, 3) and f not in self._gazetteer: | |
| self._gazetteer[f] = canonical | |
| def _build_gazetteer(self) -> None: | |
| wards_csv = _DATASETS / "Wards.csv" | |
| if wards_csv.exists(): | |
| try: | |
| with open(wards_csv, encoding="utf-8") as fh: | |
| for row in csv.DictReader(fh): | |
| # quận/huyện (kèm từ loại + dạng trần đủ dài) | |
| dc = (row.get("district_city") or "").strip() | |
| if dc: | |
| self._add(_cap_type_phrase(dc)) | |
| bare = re.sub( | |
| r"^(quận|huyện|thị xã|thành phố)\s+", "", dc, flags=re.IGNORECASE | |
| ).strip() | |
| if bare and not bare.isdigit(): | |
| self._add(" ".join( | |
| w[:1].upper() + w[1:] for w in bare.split()), min_len=6) | |
| # phường/xã cũ và mới (kèm từ loại) | |
| for tcol, ncol in (("old_type", "old_name"), ("new_type", "new_name")): | |
| t = (row.get(tcol) or "").strip() | |
| n = (row.get(ncol) or "").strip() | |
| if t and n: | |
| self._add(_cap_type_phrase(f"{t} {n}")) | |
| except Exception as e: # pragma: no cover | |
| logger.warning(f"TextNormalizer: không đọc được Wards.csv: {e}") | |
| prov_csv = _DATASETS / "Provinces.csv" | |
| if prov_csv.exists(): | |
| try: | |
| with open(prov_csv, encoding="utf-8") as fh: | |
| for row in csv.DictReader(fh): | |
| for key in ("old_province_name", "new_province_name"): | |
| name = (row.get(key) or "").strip() | |
| if name: | |
| self._add(name, min_len=5) | |
| self._add(f"tỉnh {name}" if name not in | |
| ("Hồ Chí Minh", "Cần Thơ") else f"Thành phố {name}") | |
| except Exception as e: # pragma: no cover | |
| logger.warning(f"TextNormalizer: không đọc được Provinces.csv: {e}") | |
| logger.info(f"TextNormalizer: gazetteer {len(self._gazetteer)} mục") | |
| # ------------------------------------------------------------- pipeline | |
| def normalize(self, text: str) -> NormalizedQuery: | |
| original = text or "" | |
| changes: List[Tuple[str, str, str]] = [] | |
| # 1) Unicode NFC + gom khoảng trắng | |
| out = unicodedata.normalize("NFC", original) | |
| out = re.sub(r"\s+", " ", out).strip() | |
| # 1.5) Câu VIẾT HOA TOÀN BỘ là out-of-distribution với NER/Intent | |
| # (train data là chữ thường/title-case) -> hạ về chữ thường; bước | |
| # phục hồi gazetteer phía dưới sẽ dựng lại đúng tên riêng có dấu. | |
| letters = [c for c in out if c.isalpha()] | |
| if len(letters) >= 8 and sum(c.isupper() for c in letters) / len(letters) > 0.7: | |
| changes.append((out, out.lower(), "lowercase_allcaps")) | |
| out = out.lower() | |
| # 2) Bung viết tắt | |
| for pattern, repl, kind in self._ABBREVIATIONS: | |
| def _sub(m: "re.Match[str]") -> str: | |
| new = m.expand(repl) if "\\" in repl or "$" in repl else repl | |
| changes.append((m.group(0), new, kind)) | |
| return new | |
| out = re.sub(pattern, _sub, out, flags=re.IGNORECASE) | |
| # 3) Phục hồi dấu địa danh theo gazetteer | |
| out = self._restore_diacritics(out, changes) | |
| return NormalizedQuery(original=original, text=out, changes=changes) | |
| def _restore_diacritics(self, text: str, changes: List[Tuple[str, str, str]]) -> str: | |
| folded = fold(text) # 1:1 — offset trùng với text | |
| n = len(folded) | |
| taken: List[Tuple[int, int]] = [] | |
| repls: List[Tuple[int, int, str]] = [] | |
| def _boundary_ok(s: int, e: int) -> bool: | |
| before = folded[s - 1] if s > 0 else " " | |
| after = folded[e] if e < n else " " | |
| return not before.isalnum() and not after.isalnum() | |
| def _overlaps(s: int, e: int) -> bool: | |
| return any(not (e <= ts or s >= te) for ts, te in taken) | |
| for f_phrase, canonical in self._entries: # dài trước, ngắn sau | |
| start = 0 | |
| while True: | |
| idx = folded.find(f_phrase, start) | |
| if idx == -1: | |
| break | |
| end = idx + len(f_phrase) | |
| if _boundary_ok(idx, end) and not _overlaps(idx, end): | |
| span = text[idx:end] | |
| if span != canonical: # chỉ thay khi thật sự khác (thiếu dấu/sai hoa thường) | |
| repls.append((idx, end, canonical)) | |
| changes.append((span, canonical, "diacritics")) | |
| taken.append((idx, end)) | |
| start = end | |
| for s, e, canonical in sorted(repls, key=lambda r: -r[0]): # thay từ phải sang trái | |
| text = text[:s] + canonical + text[e:] | |
| return text | |
| # ------------------------------------------------------------- tiện ích | |
| def find_district(self, text: str) -> Optional[str]: | |
| """Tên quận/huyện chuẩn xuất hiện trong câu (sau khi đã normalize).""" | |
| folded = fold(text) | |
| best: Optional[str] = None | |
| for f_phrase, canonical in self._entries: | |
| if fold(canonical).startswith(("quan ", "huyen ", "thi xa ", "thanh pho ")): | |
| if f_phrase in folded and (best is None or len(f_phrase) > len(fold(best))): | |
| best = canonical | |
| return best | |
| def fold(self, s: str) -> str: | |
| return fold(s) | |
| # Singleton | |
| _normalizer: Optional[TextNormalizer] = None | |
| def get_text_normalizer() -> TextNormalizer: | |
| global _normalizer | |
| if _normalizer is None: | |
| _normalizer = TextNormalizer() | |
| return _normalizer | |