Spaces:
Runtime error
Runtime error
Create core_numeric.py
Browse files- core_numeric.py +142 -0
core_numeric.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# core_numeric.py
|
| 2 |
+
# 纯逻辑函数:判定“数字一致性”,辅助行筛选/归一化等;供 app.py 调用。
|
| 3 |
+
|
| 4 |
+
import re, unicodedata
|
| 5 |
+
from collections import Counter
|
| 6 |
+
import pandas as pd
|
| 7 |
+
import cn2an
|
| 8 |
+
from number_parser import parse as en_words_parse
|
| 9 |
+
|
| 10 |
+
# ===== 公共常量 =====
|
| 11 |
+
SUFFIX = "_numeric_tagged"
|
| 12 |
+
CATEGORY_TITLES = {
|
| 13 |
+
"inconsistency in target", "inconsistency in source", "tag mismatch",
|
| 14 |
+
"numeric mismatch", "target same as source",
|
| 15 |
+
}
|
| 16 |
+
PROCESS_ONLY_CATEGORIES = {"numeric mismatch"}
|
| 17 |
+
|
| 18 |
+
# ===== 正则与工具 =====
|
| 19 |
+
DIGIT_RE = re.compile(r'[-+]?\d+(?:[.,]\d+)?(?:[%%‰])?')
|
| 20 |
+
TAG_RE = re.compile(r'<[^>]+>')
|
| 21 |
+
FILE_SEG_RE = re.compile(r'^(.*?)(?:\s*\((\d+)\))\s*$')
|
| 22 |
+
_WS = ("\u00A0", "\u2009", "\u2002", "\u2003", "\u2007", "\u202F")
|
| 23 |
+
|
| 24 |
+
MONTH_MAP = {"january":"1","jan":"1","february":"2","feb":"2","march":"3","mar":"3",
|
| 25 |
+
"april":"4","apr":"4","may":"5","june":"6","jun":"6","july":"7","jul":"7",
|
| 26 |
+
"august":"8","aug":"8","september":"9","sept":"9","sep":"9",
|
| 27 |
+
"october":"10","oct":"10","november":"11","nov":"11","december":"12","dec":"12"}
|
| 28 |
+
MONTH_RE = re.compile(r'\b(' + '|'.join(sorted(MONTH_MAP.keys(), key=len, reverse=True)) + r')\b', re.I)
|
| 29 |
+
DAY_RE = re.compile(r'\b(\d{1,2})(?:st|nd|rd|th)?\b', re.I)
|
| 30 |
+
YEAR_RE = re.compile(r'\b(19|20)\d{2}\b')
|
| 31 |
+
EN_ORDINAL_MAP = {"first":"1","second":"2","third":"3","fourth":"4","fifth":"5",
|
| 32 |
+
"sixth":"6","seventh":"7","eighth":"8","ninth":"9","tenth":"10",
|
| 33 |
+
"eleventh":"11","twelfth":"12","thirteenth":"13","fourteenth":"14",
|
| 34 |
+
"fifteenth":"15","sixteenth":"16","seventeenth":"17","eighteenth":"18",
|
| 35 |
+
"nineteenth":"19","twentieth":"20"}
|
| 36 |
+
EN_ORDINAL_RE = re.compile(r'\b(' + '|'.join(EN_ORDINAL_MAP.keys()) + r')\b', re.I)
|
| 37 |
+
DOUBLE_SEMANTIC_PAT = re.compile(r'(二重|二層|双重|双层|ダブル|이중)')
|
| 38 |
+
VAR_TOKEN_PAT = re.compile(r'\b[A-Z]\d+\b')
|
| 39 |
+
FULLWIDTH_TO_HALF = str.maketrans("0123456789", "0123456789")
|
| 40 |
+
|
| 41 |
+
def clean_invis_spaces(s: str) -> str:
|
| 42 |
+
if not isinstance(s, str): return s
|
| 43 |
+
for ch in _WS: s = s.replace(ch, " ")
|
| 44 |
+
return s
|
| 45 |
+
|
| 46 |
+
def strip_tags(s: str) -> str:
|
| 47 |
+
return TAG_RE.sub("", s)
|
| 48 |
+
|
| 49 |
+
def normalize_text(s: str) -> str:
|
| 50 |
+
if not isinstance(s, str): return ""
|
| 51 |
+
s = unicodedata.normalize("NFKC", s).replace(",", ",").replace(".", ".").replace("%", "%")
|
| 52 |
+
s = strip_tags(s)
|
| 53 |
+
# 英文常见词数化
|
| 54 |
+
try: s = en_words_parse(s)
|
| 55 |
+
except Exception: pass
|
| 56 |
+
# 中文数字转阿拉伯
|
| 57 |
+
try: s = cn2an.transform(s, "cn2an")
|
| 58 |
+
except Exception: pass
|
| 59 |
+
return s
|
| 60 |
+
|
| 61 |
+
def add_month_tokens_if_date_context(text: str, counter: Counter):
|
| 62 |
+
if not text: return
|
| 63 |
+
for m in MONTH_RE.finditer(text):
|
| 64 |
+
month_num = MONTH_MAP[m.group(1).lower()]
|
| 65 |
+
pre = text[max(0, m.start()-8): m.start()]
|
| 66 |
+
post = text[m.end(): m.end()+8]
|
| 67 |
+
if DAY_RE.search(pre) or DAY_RE.search(post) or YEAR_RE.search(post):
|
| 68 |
+
counter.update([month_num])
|
| 69 |
+
|
| 70 |
+
def _to_ascii_num(s: str) -> str: return s.translate(FULLWIDTH_TO_HALF)
|
| 71 |
+
|
| 72 |
+
def normalize_cjk_dates_in_counter(text: str, counter: Counter):
|
| 73 |
+
# 粗略把全角月/日换成半角,以便计数统一
|
| 74 |
+
m = re.search(r'([0-9\d]{1,2})\s*月\s*([0-9\d]{1,2})\s*日', text)
|
| 75 |
+
if m:
|
| 76 |
+
mm = str(int(_to_ascii_num(m.group(1)))); dd = str(int(_to_ascii_num(m.group(2))))
|
| 77 |
+
if counter.get(mm, 0) == 0: counter.update([mm])
|
| 78 |
+
if counter.get(dd, 0) == 0: counter.update([dd])
|
| 79 |
+
|
| 80 |
+
def extract_numbers(s: str) -> Counter:
|
| 81 |
+
s = re.sub(r'(\d)\s*[-–—~〜~]\s*(\d)', r'\1 \2', s)
|
| 82 |
+
s = re.sub(r'(?<=\d)(?=[A-Za-z])', ' ', s)
|
| 83 |
+
s = re.sub(r'(?<=[A-Za-z])(?=\d)', ' ', s)
|
| 84 |
+
s = clean_invis_spaces(s)
|
| 85 |
+
nums = [token.replace(",", "") for token in DIGIT_RE.findall(s) if token]
|
| 86 |
+
counter = Counter(nums)
|
| 87 |
+
add_month_tokens_if_date_context(s, counter)
|
| 88 |
+
normalize_cjk_dates_in_counter(s, counter)
|
| 89 |
+
return counter
|
| 90 |
+
|
| 91 |
+
def counter_to_str(c: Counter) -> str:
|
| 92 |
+
if not c: return ""
|
| 93 |
+
items = sorted(c.items(), key=lambda kv: kv[0])
|
| 94 |
+
return ", ".join([f"{k}×{v}" if v>1 else k for k,v in items])
|
| 95 |
+
|
| 96 |
+
def balanced_mask_vars(src: str, tgt: str):
|
| 97 |
+
src_set = set(VAR_TOKEN_PAT.findall(src))
|
| 98 |
+
tgt_set = set(VAR_TOKEN_PAT.findall(tgt))
|
| 99 |
+
if not src_set or src_set != tgt_set: return src, tgt, []
|
| 100 |
+
def _repl(m: "re.Match") -> str: return m.group(0)[0] + "§VAR§"
|
| 101 |
+
return VAR_TOKEN_PAT.sub(_repl, src), VAR_TOKEN_PAT.sub(_repl, tgt), sorted(src_set)
|
| 102 |
+
|
| 103 |
+
def classify_row(src: str, tgt: str):
|
| 104 |
+
note = []
|
| 105 |
+
s, t, vars_hits = balanced_mask_vars(src or "", tgt or "")
|
| 106 |
+
if vars_hits: note.append("MaskedVars(" + ",".join(vars_hits) + ")")
|
| 107 |
+
s_norm, t_norm = normalize_text(s), normalize_text(t)
|
| 108 |
+
ns, nt = extract_numbers(s_norm), extract_numbers(t_norm)
|
| 109 |
+
src_s, tgt_s = counter_to_str(ns), counter_to_str(nt)
|
| 110 |
+
if not ns and not nt:
|
| 111 |
+
base = "NoNumbersBothSides"
|
| 112 |
+
return (False, src_s, tgt_s, base if not note else base+";"+";".join(note))
|
| 113 |
+
if ns == nt:
|
| 114 |
+
base = "NumbersEqualAfterNormalization"
|
| 115 |
+
return (False, src_s, tgt_s, base if not note else base+";"+";".join(note))
|
| 116 |
+
base = "NumbersDiffer"
|
| 117 |
+
return (True, src_s, tgt_s, base if not note else base+";"+";".join(note))
|
| 118 |
+
|
| 119 |
+
def _is_blank(x) -> bool:
|
| 120 |
+
if x is None or (isinstance(x, float) and pd.isna(x)): return True
|
| 121 |
+
s = str(x).strip().lower()
|
| 122 |
+
return s in {"", "nan", "none", "null"}
|
| 123 |
+
|
| 124 |
+
def is_category_header_row(a, c, d) -> bool:
|
| 125 |
+
a_norm = clean_invis_spaces(str(a)).strip().lower()
|
| 126 |
+
return (a_norm != "") and _is_blank(c) and _is_blank(d)
|
| 127 |
+
|
| 128 |
+
def is_allowed_category_name(a) -> bool:
|
| 129 |
+
a_norm = clean_invis_spaces(str(a)).strip().lower()
|
| 130 |
+
return any(a_norm.startswith(cat) for cat in PROCESS_ONLY_CATEGORIES)
|
| 131 |
+
|
| 132 |
+
def find_header_row_by_probe(get_cell, max_scan=60) -> int:
|
| 133 |
+
"""
|
| 134 |
+
通过 get_cell(r, c) 探测第 C、D 列是否出现 'Source'/'Target'。
|
| 135 |
+
适配 xlrd 行读取或 pandas.DataFrame.iat。
|
| 136 |
+
"""
|
| 137 |
+
for r in range(max_scan):
|
| 138 |
+
c = str(get_cell(r, 2) or "").strip().lower()
|
| 139 |
+
d = str(get_cell(r, 3) or "").strip().lower()
|
| 140 |
+
if c == "source" and d == "target":
|
| 141 |
+
return r
|
| 142 |
+
return -1
|