Lou / core_numeric.py
Lousama's picture
Create core_numeric.py
17b6ec9 verified
Raw
History Blame Contribute Delete
6.08 kB
# core_numeric.py
# 纯逻辑函数:判定“数字一致性”,辅助行筛选/归一化等;供 app.py 调用。
import re, unicodedata
from collections import Counter
import pandas as pd
import cn2an
from number_parser import parse as en_words_parse
# ===== 公共常量 =====
SUFFIX = "_numeric_tagged"
CATEGORY_TITLES = {
"inconsistency in target", "inconsistency in source", "tag mismatch",
"numeric mismatch", "target same as source",
}
PROCESS_ONLY_CATEGORIES = {"numeric mismatch"}
# ===== 正则与工具 =====
DIGIT_RE = re.compile(r'[-+]?\d+(?:[.,]\d+)?(?:[%%‰])?')
TAG_RE = re.compile(r'<[^>]+>')
FILE_SEG_RE = re.compile(r'^(.*?)(?:\s*\((\d+)\))\s*$')
_WS = ("\u00A0", "\u2009", "\u2002", "\u2003", "\u2007", "\u202F")
MONTH_MAP = {"january":"1","jan":"1","february":"2","feb":"2","march":"3","mar":"3",
"april":"4","apr":"4","may":"5","june":"6","jun":"6","july":"7","jul":"7",
"august":"8","aug":"8","september":"9","sept":"9","sep":"9",
"october":"10","oct":"10","november":"11","nov":"11","december":"12","dec":"12"}
MONTH_RE = re.compile(r'\b(' + '|'.join(sorted(MONTH_MAP.keys(), key=len, reverse=True)) + r')\b', re.I)
DAY_RE = re.compile(r'\b(\d{1,2})(?:st|nd|rd|th)?\b', re.I)
YEAR_RE = re.compile(r'\b(19|20)\d{2}\b')
EN_ORDINAL_MAP = {"first":"1","second":"2","third":"3","fourth":"4","fifth":"5",
"sixth":"6","seventh":"7","eighth":"8","ninth":"9","tenth":"10",
"eleventh":"11","twelfth":"12","thirteenth":"13","fourteenth":"14",
"fifteenth":"15","sixteenth":"16","seventeenth":"17","eighteenth":"18",
"nineteenth":"19","twentieth":"20"}
EN_ORDINAL_RE = re.compile(r'\b(' + '|'.join(EN_ORDINAL_MAP.keys()) + r')\b', re.I)
DOUBLE_SEMANTIC_PAT = re.compile(r'(二重|二層|双重|双层|ダブル|이중)')
VAR_TOKEN_PAT = re.compile(r'\b[A-Z]\d+\b')
FULLWIDTH_TO_HALF = str.maketrans("0123456789", "0123456789")
def clean_invis_spaces(s: str) -> str:
if not isinstance(s, str): return s
for ch in _WS: s = s.replace(ch, " ")
return s
def strip_tags(s: str) -> str:
return TAG_RE.sub("", s)
def normalize_text(s: str) -> str:
if not isinstance(s, str): return ""
s = unicodedata.normalize("NFKC", s).replace(",", ",").replace(".", ".").replace("%", "%")
s = strip_tags(s)
# 英文常见词数化
try: s = en_words_parse(s)
except Exception: pass
# 中文数字转阿拉伯
try: s = cn2an.transform(s, "cn2an")
except Exception: pass
return s
def add_month_tokens_if_date_context(text: str, counter: Counter):
if not text: return
for m in MONTH_RE.finditer(text):
month_num = MONTH_MAP[m.group(1).lower()]
pre = text[max(0, m.start()-8): m.start()]
post = text[m.end(): m.end()+8]
if DAY_RE.search(pre) or DAY_RE.search(post) or YEAR_RE.search(post):
counter.update([month_num])
def _to_ascii_num(s: str) -> str: return s.translate(FULLWIDTH_TO_HALF)
def normalize_cjk_dates_in_counter(text: str, counter: Counter):
# 粗略把全角月/日换成半角,以便计数统一
m = re.search(r'([0-9\d]{1,2})\s*月\s*([0-9\d]{1,2})\s*日', text)
if m:
mm = str(int(_to_ascii_num(m.group(1)))); dd = str(int(_to_ascii_num(m.group(2))))
if counter.get(mm, 0) == 0: counter.update([mm])
if counter.get(dd, 0) == 0: counter.update([dd])
def extract_numbers(s: str) -> Counter:
s = re.sub(r'(\d)\s*[-–—~〜~]\s*(\d)', r'\1 \2', s)
s = re.sub(r'(?<=\d)(?=[A-Za-z])', ' ', s)
s = re.sub(r'(?<=[A-Za-z])(?=\d)', ' ', s)
s = clean_invis_spaces(s)
nums = [token.replace(",", "") for token in DIGIT_RE.findall(s) if token]
counter = Counter(nums)
add_month_tokens_if_date_context(s, counter)
normalize_cjk_dates_in_counter(s, counter)
return counter
def counter_to_str(c: Counter) -> str:
if not c: return ""
items = sorted(c.items(), key=lambda kv: kv[0])
return ", ".join([f"{k}×{v}" if v>1 else k for k,v in items])
def balanced_mask_vars(src: str, tgt: str):
src_set = set(VAR_TOKEN_PAT.findall(src))
tgt_set = set(VAR_TOKEN_PAT.findall(tgt))
if not src_set or src_set != tgt_set: return src, tgt, []
def _repl(m: "re.Match") -> str: return m.group(0)[0] + "§VAR§"
return VAR_TOKEN_PAT.sub(_repl, src), VAR_TOKEN_PAT.sub(_repl, tgt), sorted(src_set)
def classify_row(src: str, tgt: str):
note = []
s, t, vars_hits = balanced_mask_vars(src or "", tgt or "")
if vars_hits: note.append("MaskedVars(" + ",".join(vars_hits) + ")")
s_norm, t_norm = normalize_text(s), normalize_text(t)
ns, nt = extract_numbers(s_norm), extract_numbers(t_norm)
src_s, tgt_s = counter_to_str(ns), counter_to_str(nt)
if not ns and not nt:
base = "NoNumbersBothSides"
return (False, src_s, tgt_s, base if not note else base+";"+";".join(note))
if ns == nt:
base = "NumbersEqualAfterNormalization"
return (False, src_s, tgt_s, base if not note else base+";"+";".join(note))
base = "NumbersDiffer"
return (True, src_s, tgt_s, base if not note else base+";"+";".join(note))
def _is_blank(x) -> bool:
if x is None or (isinstance(x, float) and pd.isna(x)): return True
s = str(x).strip().lower()
return s in {"", "nan", "none", "null"}
def is_category_header_row(a, c, d) -> bool:
a_norm = clean_invis_spaces(str(a)).strip().lower()
return (a_norm != "") and _is_blank(c) and _is_blank(d)
def is_allowed_category_name(a) -> bool:
a_norm = clean_invis_spaces(str(a)).strip().lower()
return any(a_norm.startswith(cat) for cat in PROCESS_ONLY_CATEGORIES)
def find_header_row_by_probe(get_cell, max_scan=60) -> int:
"""
通过 get_cell(r, c) 探测第 C、D 列是否出现 'Source'/'Target'。
适配 xlrd 行读取或 pandas.DataFrame.iat。
"""
for r in range(max_scan):
c = str(get_cell(r, 2) or "").strip().lower()
d = str(get_cell(r, 3) or "").strip().lower()
if c == "source" and d == "target":
return r
return -1