Repository navigation
Expand file tree
/
Copy pathhandler_text.py
More file actions
442 lines (370 loc) · 21.5 KB
/
Copy pathhandler_text.py
File metadata and controls
442 lines (370 loc) · 21.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
"""
一般文字(非程式碼)敏感資訊偵測規則。
只負責「偵測」,回傳 [(start, end, label, score, source), ...];標籤替換/存檔統一交給 main.py 處理。
想加規則:在 RULES 加一行 Rule(...) 即可,不用動 detect()。
固定要遮的人名/機構名等:寫進 known_entities.txt (每行 LABEL<Tab>值),detect() 會當成 "regex" 規則一起跑。
第 5 個欄位 source 是這筆結果的「來源」,固定是下面三種之一:
"regex" -> 正則規則命中 (score 固定 None)
"en" -> Presidio 英文模型 (環境裡的 en_core_web_sm,或 models/en_core_web_sm;都沒有就停用) + Presidio 內建規則式辨識器
"zh" -> 中文 NER 模型 (環境的 Hugging Face 快取,或 models/roberta-base-finetuned-cluener2020-chinese;都沒有就停用)
main.py / handler_media.py 在合併重疊結果時,就是靠這個欄位決定「規則 > 語言吻合的模型 > 語言
不吻合的模型」的優先序,所以每個偵測函式都要老實標好 source,不能省略。
偵測分三層:
1) 正則規則 (RULES,不分語言都會跑)
2) ㄏ
中文想換別的模型),寫一個 func(text) -> [(start,end,label,score,source), ...],
把 NLP_MODELS["zh"] 或 NLP_MODELS["en"] 蓋掉即可,presidio_matches()/detect() 都不用動;
只要記得自己的 source 欄位固定回傳 "en" 或 "zh"(對應蓋掉的是哪一層),重疊優先序才不會判斷錯。
"""
import os
import re
from dataclasses import dataclass
from typing import Callable, Optional, List, Tuple
EN_NER_LABELS = {
"PERSON",
"ORGANIZATION",
"LOCATION",
}
import app_env # 判斷環境有沒有模型、models 資料夾位置 (APP_DIR/models);打包成 EXE 後也能正確找到 EXE 旁邊的模型
# 啟動時各模型的載入結果,core.warmup() 會把這些訊息印到記錄區,讓使用者知道哪些偵測功能目前停用
MODEL_STATUS = []
# 模型尋找順序:① 先查「環境」(pip 裝好的 spaCy 模型 / 本機 Hugging Face 快取) → ② 再看 models/ 資料夾 → ③ 都沒有就停用
EN_MODEL_PACKAGE = "en_core_web_sm" # 環境裡的 spaCy 模型套件名稱
EN_MODEL_DIR_NAME = "en_core_web_sm" # models/ 底下的資料夾名稱
ZH_MODEL_DIR_NAME = "roberta-base-finetuned-cluener2020-chinese" # models/ 底下的資料夾名稱
# ---------- 英文:走 Presidio,含內建的規則式辨識器 (SSN/IBAN/信用卡/加密貨幣錢包/IP) + spaCy 英文 NER ----------
# 先查環境有沒有 en_core_web_sm,沒有再看 models/en_core_web_sm 資料夾;兩邊都沒有就停用英文 NER,只用正則規則
_analyzer = None
_en_sources = [] # [(來源說明, spaCy 模型名稱或資料夾路徑), ...] 依優先序嘗試
if app_env.has_spacy_package(EN_MODEL_PACKAGE):
_en_sources.append(("環境已安裝", EN_MODEL_PACKAGE))
_en_model_dir = app_env.find_model_dir(EN_MODEL_DIR_NAME)
if _en_model_dir is not None:
_en_sources.append(("models 資料夾", _en_model_dir))
if not _en_sources:
MODEL_STATUS.append(f"[停用] 英文 NER (Presidio):環境沒有 {EN_MODEL_PACKAGE},"
f"{os.path.join(app_env.MODELS_DIR, EN_MODEL_DIR_NAME)} 也找不到,"
"英文的人名/地點/機構不會被偵測 (正則規則照常運作)")
else:
_en_errors = []
for _label, _model in _en_sources:
try:
from presidio_analyzer import AnalyzerEngine
from presidio_analyzer.nlp_engine import NlpEngineProvider
nlp_configuration = {
"nlp_engine_name": "spacy",
"models": [{"lang_code": "en", "model_name": _model}], # 只需要英文,中文不用 spaCy
}
_nlp_engine = NlpEngineProvider(nlp_configuration=nlp_configuration).create_engine()
_analyzer = AnalyzerEngine(nlp_engine=_nlp_engine, supported_languages=["en"])
MODEL_STATUS.append(f"[啟用] 英文 NER (Presidio + spaCy {EN_MODEL_PACKAGE}),來源:{_label}")
break
except Exception as e: # 環境裡的壞掉 / presidio 沒包進程式時,換下一個來源試試
_analyzer = None
_en_errors.append(f"{_label}:{e}")
else:
MODEL_STATUS.append("[停用] 英文 NER (Presidio) 載入失敗,只用正則規則 -> " + ";".join(_en_errors))
EXCLUDED_PRESIDIO_LABELS = {"US_DRIVER_LICENSE"} # 不想遮蔽的 Presidio 類型,往這個 set 加
def contains_chinese(text: str) -> bool:
return any("\u4e00" <= ch <= "\u9fff" for ch in text)
def presidio_en(text: str) -> List[Tuple[int, int, str, Optional[float], str]]:
if _analyzer is None:
return []
try:
return [(r.start, r.end, r.entity_type, round(float(r.score), 2), "en")
for r in _analyzer.analyze(text=text, language="en")
if r.entity_type not in EXCLUDED_PRESIDIO_LABELS]
except Exception:
return [] # 模型沒裝/分析失敗都不影響正則規則的結果
# ---------- 中文:不經過 Presidio/spaCy,直接呼叫 Hugging Face 模型 ----------
ZH_NER_MODEL_NAME = "uer/roberta-base-finetuned-cluener2020-chinese" # 模型出處 (環境快取的名稱、prepare_models.py 下載用)
MIN_ZH_NER_SCORE = 0.7 # 信心分數門檻,低於這個值直接丟掉不採用;想放寬/收緊調這個數字即可
# CLUENER2020 的標籤 -> 我們自己的實體名稱。book/game/movie/scene 跟隱私/敏感資訊無關
# (書名/遊戲/電影/場景),故意不對應、不遮蔽;如果換了別的模型,標籤名稱不一樣,改這裡就好。
_ZH_LABEL_MAP = {
"name": "PERSON",
"address": "ADDRESS",
"company": "ORGANIZATION",
"government": "ORGANIZATION",
"organization": "ORGANIZATION",
"position": "POSITION",
}
_zh_ner_pipeline = None
# 先查環境 (本機 Hugging Face 快取) 有沒有中文 NER 模型,沒有再看 models/ 資料夾;兩邊都沒有就停用,不連網下載
_zh_sources = [] # [(來源說明, 模型名稱或資料夾路徑), ...] 依優先序嘗試
if app_env.has_hf_cached(ZH_NER_MODEL_NAME):
_zh_sources.append(("環境 (Hugging Face 快取)", ZH_NER_MODEL_NAME))
_zh_model_dir = app_env.find_model_dir(ZH_MODEL_DIR_NAME)
if _zh_model_dir is not None:
_zh_sources.append(("models 資料夾", _zh_model_dir))
if not _zh_sources:
# 兩邊都沒有就連 transformers 都不 import (啟動更快),中文只用正則規則
MODEL_STATUS.append(f"[停用] 中文 NER:環境沒有 {ZH_NER_MODEL_NAME},"
f"{os.path.join(app_env.MODELS_DIR, ZH_MODEL_DIR_NAME)} 也找不到,"
"中文的人名/地址/機構不會被偵測 (正則規則照常運作)")
else:
try:
from transformers import pipeline
except ModuleNotFoundError as e:
pipeline = None
MODEL_STATUS.append(f"[停用] 中文 NER:沒有安裝 transformers 套件,中文只用正則規則:{e}")
except Exception as e:
pipeline = None
MODEL_STATUS.append("[停用] 中文 NER:transformers 有安裝但匯入失敗 "
f"(常見原因是 torch 等套件版本不合),中文只用正則規則:{e}")
if pipeline is not None:
_zh_errors = []
for _label, _model in _zh_sources:
try:
print(f"正在載入中文 NER 模型 ({_label}:{_model})...")
_zh_ner_pipeline = pipeline("ner", model=_model, aggregation_strategy="simple")
MODEL_STATUS.append(f"[啟用] 中文 NER ({ZH_NER_MODEL_NAME}),來源:{_label}")
break
except Exception as e: # 環境裡的快取不完整 / 損壞時,換下一個來源試試
_zh_ner_pipeline = None
_zh_errors.append(f"{_label}:{e}")
else:
MODEL_STATUS.append("[停用] 中文 NER 模型載入失敗,中文只用正則規則 -> " + ";".join(_zh_errors))
def zh_ner(text: str) -> List[Tuple[int, int, str, Optional[float], str]]:
"""不經過 Presidio/spaCy,直接呼叫上面載入的中文 NER pipeline。
中文 Transformer 對過長輸入可能因模型最大長度而分析失敗,因此這裡會把長文字
切成帶有 overlap 的區塊分別推論,再把區塊內座標換回原文的全域座標。
overlap 可避免人名/地址等實體剛好落在切割邊界時被切成兩半。
注意:這裡只回傳分數 >= MIN_ZH_NER_SCORE 的結果;被門檻擋掉的候選不會出現在回傳值裡。
"""
if _zh_ner_pipeline is None or not text:
return []
# 目前實測約 400 字仍可正常推論、702 字會失敗;保守使用 350 字一段。
# 相鄰區塊重疊 50 字,降低敏感實體剛好被切在邊界的機率。
chunk_size = 350
overlap = 50
step = chunk_size - overlap
matches = []
try:
for chunk_start in range(0, len(text), step):
chunk_end = min(chunk_start + chunk_size, len(text))
chunk = text[chunk_start:chunk_end]
for item in _zh_ner_pipeline(chunk):
label = _ZH_LABEL_MAP.get(item["entity_group"])
score = float(item["score"])
if label and score >= MIN_ZH_NER_SCORE:
matches.append((
chunk_start + item["start"],
chunk_start + item["end"],
label,
round(score, 2),
"zh",
))
if chunk_end >= len(text):
break
except Exception as e:
print(f"[警告] 中文 NER 分析失敗:{e}")
return []
# overlap 區域可能讓同一實體被兩個 chunk 重複辨識。
# 相同 (start, end, label, source) 僅保留信心分數較高的一筆。
dedup = {}
for match in matches:
key = (match[0], match[1], match[2], match[4])
previous = dedup.get(key)
if previous is None or (match[3] or 0) > (previous[3] or 0):
dedup[key] = match
return sorted(dedup.values(), key=lambda m: (m[0], m[1], m[2]))
# 語言 -> NLP 模型函式 的註冊表:想加/換某個語言用的模型,往這裡改一行就好
NLP_MODELS = {
"en": presidio_en,
"zh": zh_ner,
}
def luhn_valid(value: str) -> bool:
"""信用卡卡號 Luhn 演算法驗證"""
digits = [int(d) for d in value if d.isdigit()]
if not (13 <= len(digits) <= 19):
return False
checksum, parity = 0, len(digits) % 2
for i, d in enumerate(digits):
if i % 2 == parity:
d *= 2
if d > 9:
d -= 9
checksum += d
return checksum % 10 == 0
FILE_EXTENSIONS = (
r"txt|docx?|xlsx?|pptx?|pdf|png|jpe?g|gif|bmp|svg|csv|zip|rar|7z|tar|gz|"
r"mp4|mp3|wav|py|ipynb|js|ts|jsx|tsx|json|xml|ya?ml|log|ini|cfg|conf|env|sql|html?|css|sh|bat|exe|dll"
)
def quoted_sensitive_valid(value: str) -> bool:
"""給 QUOTED_SECRET 用:引號裡的內容要像網址/檔名/密碼才算數,避免把一般引言也遮掉"""
if re.match(r"^https?://", value, re.IGNORECASE):
return True
if re.search(rf"\.(?:{FILE_EXTENSIONS})$", value, re.IGNORECASE):
return True
return (" " not in value and 4 <= len(value) <= 64
and re.search(r"[A-Za-z]", value) and re.search(r"[0-9]", value))
def iban_valid(value: str) -> bool:
"""IBAN 的 ISO 7064 MOD 97-10 檢查碼驗證,格式寬鬆比對+這個驗證可以大幅降低誤判"""
v = re.sub(r"\s+", "", value).upper()
if not re.match(r"^[A-Z]{2}\d{2}[A-Z0-9]{4,30}$", v):
return False
rearranged = v[4:] + v[:4]
try:
numeric = "".join(str(int(ch, 36)) for ch in rearranged)
except ValueError:
return False
return int(numeric) % 97 == 1
@dataclass
class Rule:
name: str
pattern: str
flags: int = 0
validator: Optional[Callable[[str], bool]] = None
# 注意:這裡故意不用 \b 當邊界,因為 Python 的 \b 是以 \w (含中文字!) 定義的,
# 中文字緊貼著英數字時 (例如「陳先生sk-xxx」中間沒有空格) \b 會判斷錯誤的起點,
# 導致抓到的字串從中間某個標點斷開、開頭少一截。改用 (?<![A-Za-z0-9_]) / (?![A-Za-z0-9_])
# 明確只把「前後緊接著英數字/底線」視為同一個詞的延續,中文字一律當作邊界,才不會切錯。
_L = r"(?<![A-Za-z0-9_])" # 左邊界
_R = r"(?![A-Za-z0-9_])" # 右邊界
RULES = [
Rule("TW_PHONE", _L + r"09\d{2}[- ]?\d{3}[- ]?\d{3}" + _R),
Rule("TW_ID", _L + r"[A-Z][12]\d{8}" + _R),
Rule("EMAIL", _L + r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}" + _R),
Rule("CREDIT_CARD", _L + r"(?:\d[ -]?){13,19}" + _R, validator=luhn_valid),
Rule("URL", _L + r"https?://[^\s\"'<>]+"),
Rule("FILE_NAME",_L + rf"[\w\-. ]+?\.(?:{FILE_EXTENSIONS})(?!\s*\()" + _R,flags=re.IGNORECASE),
Rule("CREDENTIAL_LIKE",
_L + r"(?=[A-Za-z0-9]{8,29}(?![A-Za-z0-9]))(?=[A-Za-z0-9]*[A-Za-z])(?=[A-Za-z0-9]*[0-9])[A-Za-z0-9]{8,29}" + _R),
Rule("GENERIC_SECRET",
_L + r"(?=[A-Za-z0-9_-]{30,}(?![A-Za-z0-9_-]))(?=[A-Za-z0-9_-]*[A-Za-z])(?=[A-Za-z0-9_-]*[0-9])[A-Za-z0-9_-]{30,}" + _R),
Rule("QUOTED_SECRET", r'["\'“”‘’]([^"\'“”‘’\r\n]{4,100})["\'“”‘’]', validator=quoted_sensitive_valid),
# ---- 以下是實際測試後補上的規則:涵蓋台灣/國際常見格式,Presidio 沒有內建或準確率不夠 ----
Rule("MAC_ADDRESS", _L + r"(?:[0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}" + _R),
Rule("IP_ADDRESS", _L + r"(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)" + _R),
Rule("IBAN", _L + r"[A-Z]{2}\d{2}(?:[ ]?[A-Za-z0-9]{2,4}){3,8}" + _R, validator=iban_valid),
Rule("SWIFT_CODE", _L + r"[A-Z]{4}[A-Z]{2}[A-Z0-9]{2}(?:[A-Z0-9]{3})?" + _R),
Rule("VEHICLE_VIN", _L + r"[A-HJ-NPR-Z0-9]{17}" + _R),
Rule("US_SSN", _L + r"\d{3}-\d{2}-\d{4}" + _R),
Rule("US_PHONE", _L + r"\+1[ ]?\(\d{3}\)[ ]?\d{3}-\d{4}" + _R),
Rule("GPS_COORDINATES", r"\d{1,3}\.\d+°\s*[NS]\s*,\s*\d{1,3}\.\d+°\s*[EW]"),
Rule("DATE_OF_BIRTH", r"\d{4}年\d{1,2}月\d{1,2}日"), # 中文年月日格式,也可能是其他日期,非專指生日
# ---- 專案測試資料中常見的醫療 / 保險 / 理賠識別碼 ----
# 使用明確前綴,避免把一般英數代碼大量誤判成 PII。
Rule("LICENSE_NUMBER", _L + r"TWMD-\d{7}" + _R),
Rule("MEDICAL_RECORD_NUMBER", _L + r"(?:MRN-\d{4}-\d{6}|CGH-\d{8}-\d{4})" + _R),
Rule("INSURANCE_POLICY_NUMBER", _L + r"POL-TW-\d{7}" + _R),
Rule("EMPLOYEE_ID", _L + r"EMP-\d{5}" + _R),
Rule("CASE_NUMBER", _L + r"CLM-\d{4}-\d{7}" + _R),
Rule("TAX_ID", r"(?:統一編號|统一编号)\s*\d{8}"), # 用中文標籤字詞當上下文錨點,降低純8碼數字的誤判;錨點同時認繁體 / 簡體,前處理就不必為了它把簡體轉成繁體
Rule("INSURANCE_ID", r"(?:健保投保序號|健保投保序号)\s*[A-Z]\d{10}"), # 同上,錨定標籤文字才抓,格式太泛用容易誤判
Rule("DRIVER_LICENSE", _L + r"[A-Z]\d{7}" + _R), # 格式很泛用(1字母+7位數),容易跟其他代碼誤判,可自行收緊
Rule("LICENSE_PLATE", _L + r"[A-Z]{3}-\d{4}" + _R), # 只涵蓋「英英英-數數數數」這種車牌格式,其他格式要自己加
Rule("BANK_ACCOUNT", _L + r"\d{3,4}-\d{3,4}-\d{6,9}" + _R),
# 台灣地址(市/縣 + 區/鄉/鎮 + 路/街/大道 + 段 + 號 + 樓):屬於結構性最佳嘗試,非窮舉所有地址寫法
Rule("ADDRESS",
r"[\u4e00-\u9fff]{2,4}(?:市|縣)[\u4e00-\u9fff]{2,4}(?:區|鄉|鎮)"
r"[\u4e00-\u9fff0-9]{2,10}(?:路|街|大道)(?:[一二三四五六七八九十0-9]{1,3}段)?"
r"\d{1,4}號(?:\d{1,3}樓)?"),
# 範例:新增自己的規則就照這個格式加一行,例如:
# Rule("TW_PASSPORT", _L + r"\d{9}" + _R),
]
_COMPILED = [(r.name, re.compile(r.pattern, r.flags), r.validator) for r in RULES]
def _run_rules(text: str, compiled=None) -> List[Tuple[int, int, str, Optional[float], str]]:
"""compiled 不給就用本檔的 RULES;handler_code.py 會傳自己的規則進來複用這個函式。
正則規則沒有「信心分數」的概念,統一補 None;來源固定標記 "regex",
跟 Presidio/中文 NER 的結果 (來源 "en"/"zh") 對齊,方便後面合併與重疊優先序判斷。"""
compiled = compiled if compiled is not None else _COMPILED
matches = []
for name, pattern, validator in compiled:
for m in pattern.finditer(text):
if pattern.groups >= 1:
value, start, end = m.group(1), m.start(1), m.end(1)
else:
value, start, end = m.group(0), m.start(), m.end()
if validator is None or validator(value):
matches.append((start, end, name, None, "regex"))
return matches
""" def presidio_matches(text: str) -> List[Tuple[int, int, str, Optional[float], str]]:
先永遠跑一次英文那組 (Presidio 內建的規則式辨識器如 SSN/IBAN/信用卡/加密貨幣錢包/IP
幾乎都只註冊在 language="en" 底下,不管文字裡有沒有中文都要跑,不然這些規則等於形同虛設),
偵測到中文再疊加一次中文 NER 判斷人名/地名/機構名 (不經過 Presidio/spaCy)
matches = NLP_MODELS["en"](text)
if contains_chinese(text):
matches += NLP_MODELS["zh"](text)
return matches """
def presidio_matches(text: str) -> List[Tuple[int, int, str, Optional[float], str]]:
en_matches = NLP_MODELS["en"](text)
if contains_chinese(text):
# 中文文字中,不採用英文 spaCy NER 容易誤判的語意型實體
en_matches = [
m for m in en_matches
if m[2] not in EN_NER_LABELS
]
en_matches += NLP_MODELS["zh"](text)
return en_matches
# ---------- 已知實體清單 (known_entities.txt):手動維護,格式 LABEL<Tab>值 ----------
# 手動維護的清單;跟 clipboard_map.txt (遮蔽對照表) 與 sensitive_items.json (自動記錄清單) 都是不同的檔案
KNOWN_ENTITIES_FILE = "./presidio/real/known_entities.txt"
_LABEL_OK = re.compile(r"[A-Z_]+")
def load_known_entities(path=None):
"""讀 known_entities.txt -> {"PERSON": {"邢立承", ...}, ...}。檔案不存在就回傳空 dict。"""
path = path or KNOWN_ENTITIES_FILE
known = {}
try:
with open(path, encoding="utf-8-sig") as f: # utf-8-sig:記事本存檔的 BOM 也能讀
for n, line in enumerate(f, 1):
line = line.rstrip("\r\n")
if not line.strip() or line.lstrip().startswith("#"):
continue
label, sep, value = line.partition("\t")
label, value = label.strip().upper(), value.strip()
if not sep or not value or not _LABEL_OK.fullmatch(label):
print(f"[警告] {path} 第 {n} 行格式不符 (需為 LABEL<Tab>值),已略過")
continue
known.setdefault(label, set()).add(value)
except FileNotFoundError:
pass
except OSError as e:
print(f"[警告] 讀取 {path} 失敗:{e}")
return known
def _alt(value):
"""re.escape 之外,英數開頭/結尾的值加邊界判斷,避免 Tom 命中 Tomorrow 的一部分 (中文不受影響)"""
p = re.escape(value)
if re.match(r"[A-Za-z0-9]", value[0]):
p = r"(?<![A-Za-z0-9])" + p
if re.match(r"[A-Za-z0-9]", value[-1]):
p += r"(?![A-Za-z0-9])"
return p
def build_known_patterns(known):
"""每個 label 編一個 alternation pattern 並一次編譯;值依長度由長到短排序,避免短字串搶先蓋掉長字串"""
return {
label: re.compile("|".join(_alt(v) for v in sorted(vals, key=len, reverse=True)))
for label, vals in known.items() if vals
}
def _known_mtime():
try:
return os.path.getmtime(KNOWN_ENTITIES_FILE)
except OSError:
return None
KNOWN = load_known_entities() # {"PERSON": {"邢立承", ...}, ...}
KNOWN_PATTERNS = build_known_patterns(KNOWN)
_known_loaded_mtime = _known_mtime()
if KNOWN:
print(f"[已知實體] 已載入 {sum(len(v) for v in KNOWN.values())} 筆 ({', '.join(KNOWN)})")
def reload_known_entities():
"""重新讀檔並重編 pattern (長時間執行的快速鍵服務改了清單後不用重開程式)"""
global KNOWN, KNOWN_PATTERNS, _known_loaded_mtime
KNOWN = load_known_entities()
KNOWN_PATTERNS = build_known_patterns(KNOWN)
_known_loaded_mtime = _known_mtime()
print(f"[已知實體] 重新載入,共 {sum(len(v) for v in KNOWN.values())} 筆")
def detect_known_entities(text):
"""回傳 [(start, end, label, None, "regex")]:source 用 "regex",跟既有正則規則同一個來源標籤 (優先序最高)"""
if _known_mtime() != _known_loaded_mtime: # 檔案被改過就自動重載 (一次 os.stat,成本可忽略)
reload_known_entities()
matches = []
for label, pattern in KNOWN_PATTERNS.items():
for m in pattern.finditer(text):
matches.append((m.start(), m.end(), label, None, "regex"))
return matches
def detect(text: str) -> List[Tuple[int, int, str, Optional[float], str]]:
"""main.py 呼叫的入口:回傳這段文字裡所有敏感資訊的 (start, end, label, score, source),
score 是模型判定的信心分數 (0~1),正則規則比對到的固定是 None;
source 是 "regex"/"en"/"zh" 三選一,供重疊時判斷優先序用。"""
return _run_rules(text) + detect_known_entities(text) + presidio_matches(text)