tranth3truong's picture
Deploy public Scribe-only CarePath Space
cc678b9
Raw
History Blame Contribute Delete
6.42 kB
import re
import unicodedata
WORD_RE = re.compile(r"\w+", re.UNICODE)
NUMBER_VALUES = {
"mot": 1,
"hai": 2,
"ba": 3,
"bon": 4,
"tu": 4,
"nam": 5,
"lam": 5,
"nham": 5,
"sau": 6,
"bay": 7,
"tam": 8,
"chin": 9,
}
NUMBER_TOKENS = set(NUMBER_VALUES) | {
"muoi",
"tram",
"nghin",
"ngan",
"linh",
"le",
"ruoi",
"nua",
}
NUMBER_FOLLOWERS = {
"vien",
"goi",
"ong",
"lan",
"ngay",
"tuan",
"thang",
"gio",
"mg",
"ml",
"mcg",
"tuoi",
}
UNIT_PATTERNS = [
(r"\bmi\s*-?\s*li\s*-?\s*gam\b|\bmiligrams?\b|\bmilligrams?\b", "mg"),
(r"\bmi\s*-?\s*li\s*-?\s*lit\b|\bmi\s*-?\s*li\s*-?\s*lít\b|\bmililit(?:s)?\b", "ml"),
(r"\bmicrograms?\b|\bmcg\b|\bµg\b", "mcg"),
]
def strip_diacritics(text: str) -> str:
decomposed = unicodedata.normalize("NFD", text)
return "".join(char for char in decomposed if unicodedata.category(char) != "Mn")
def fold_for_match(text: str) -> str:
return strip_diacritics(unicodedata.normalize("NFC", text)).casefold()
def contains_folded(haystack: str, needle: str) -> bool:
return fold_for_match(needle) in fold_for_match(haystack)
def _word_key(word: str) -> str:
if word.casefold() == "sau":
return ""
return fold_for_match(word).replace("đ", "d")
def _parse_under_1000(tokens: list[str]) -> float | None:
total = 0
i = 0
if i + 1 < len(tokens) and tokens[i] in NUMBER_VALUES and tokens[i + 1] == "tram":
total += NUMBER_VALUES[tokens[i]] * 100
i += 2
if i < len(tokens) and tokens[i] in {"linh", "le"}:
i += 1
if i >= len(tokens):
return float(total)
if tokens[i] == "muoi":
total += 10
i += 1
if i < len(tokens) and tokens[i] in NUMBER_VALUES:
total += NUMBER_VALUES[tokens[i]]
i += 1
elif i + 1 < len(tokens) and tokens[i] in NUMBER_VALUES and tokens[i + 1] == "muoi":
total += NUMBER_VALUES[tokens[i]] * 10
i += 2
if i < len(tokens) and tokens[i] in NUMBER_VALUES:
total += NUMBER_VALUES[tokens[i]]
i += 1
elif tokens[i] in NUMBER_VALUES:
total += NUMBER_VALUES[tokens[i]]
i += 1
if i < len(tokens) and tokens[i] == "ruoi":
total += 0.5
i += 1
return float(total) if i == len(tokens) else None
def parse_vietnamese_number_words(words: list[str]) -> float | None:
tokens = [_word_key(word) for word in words]
if tokens == ["nua"]:
return 0.5
if "nghin" in tokens or "ngan" in tokens:
split_at = tokens.index("nghin") if "nghin" in tokens else tokens.index("ngan")
left = _parse_under_1000(tokens[:split_at])
right = _parse_under_1000(tokens[split_at + 1 :]) if split_at + 1 < len(tokens) else 0
if left is None or right is None:
return None
return left * 1000 + right
return _parse_under_1000(tokens)
def _format_number(value: float) -> str:
return str(int(value)) if value.is_integer() else str(value).rstrip("0").rstrip(".")
def _is_sentence_initial(text: str, start: int) -> bool:
before = text[:start].rstrip()
return not before or before[-1] in ".!?\n\r"
def _is_capitalized_name_candidate(text: str, match: re.Match[str]) -> bool:
word = match.group(0)
return word[:1].isupper() and not _is_sentence_initial(text, match.start())
def _next_word_key(matches: list[re.Match[str]], index: int) -> str:
return _word_key(matches[index].group(0)) if index < len(matches) else ""
def _replace_number_words(text: str) -> str:
parts: list[str] = []
last = 0
matches = list(WORD_RE.finditer(text))
i = 0
while i < len(matches):
key = _word_key(matches[i].group(0))
if key not in NUMBER_TOKENS:
i += 1
continue
j = i
words: list[str] = []
while j < len(matches):
gap = text[matches[j - 1].end() : matches[j].start()] if j > i else ""
next_key = _word_key(matches[j].group(0))
if next_key not in NUMBER_TOKENS or (j > i and gap != " "):
break
words.append(matches[j].group(0))
j += 1
value = parse_vietnamese_number_words(words)
if value is None:
i += 1
continue
is_single_token = j == i + 1
if _is_capitalized_name_candidate(text, matches[i]):
i += 1
continue
if is_single_token and _next_word_key(matches, j) not in NUMBER_FOLLOWERS:
i += 1
continue
if (
is_single_token
and _word_key(matches[i].group(0)) == "nam"
and j < len(matches)
and matches[j].group(0).isdigit()
):
i += 1
continue
parts.append(text[last : matches[i].start()])
parts.append(_format_number(value))
last = matches[j - 1].end()
i = j
parts.append(text[last:])
return "".join(parts)
def _canonicalize_units(text: str) -> str:
folded = text
for pattern, replacement in UNIT_PATTERNS:
folded = re.sub(pattern, replacement, folded, flags=re.IGNORECASE)
return folded
def _normalize_relative_dates(text: str) -> str:
return re.sub(
r"\bsau\s+(\d+(?:\.\d+)?)\s+ng[aà]y\b",
lambda match: f"+{match.group(1)} days",
text,
flags=re.IGNORECASE,
)
def normalize_text(text: str) -> str:
normalized = unicodedata.normalize("NFC", text).strip()
normalized = _canonicalize_units(normalized)
normalized = _replace_number_words(normalized)
normalized = _normalize_relative_dates(normalized)
return re.sub(r"\s+", " ", normalized)
def normalize_for_metrics(text: str) -> str:
"""Preserve the case-insensitive text contract used by GEC scorecards."""
text = text.lower().strip()
text = unicodedata.normalize("NFC", text)
return re.sub(r"\s+", " ", text)
def normalize_for_match(text: str) -> str:
"""Fold text for lexical medical-term retrieval."""
text = text.lower().strip()
text = unicodedata.normalize("NFD", text)
text = "".join(ch for ch in text if unicodedata.category(ch) != "Mn")
text = text.replace("đ", "d")
text = re.sub(r"[^a-z0-9%/.,]+", " ", text)
return re.sub(r"\s+", " ", text).strip()