Lousama commited on
Commit
17b6ec9
·
verified ·
1 Parent(s): 32f8d14

Create core_numeric.py

Browse files
Files changed (1) hide show
  1. 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