yungisimon commited on
Commit
50f364e
·
verified ·
1 Parent(s): 8197e2e

Upload context_cued.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. context_cued.py +550 -0
context_cued.py ADDED
@@ -0,0 +1,550 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Context-cued ID detection layer (standalone, multilingual).
2
+
3
+ The #1 failure of the PII token classifier is the family of *arbitrary
4
+ alphanumeric IDs* (passport, tax_id, national_id, drivers_license, medical_record,
5
+ health_plan_id, bank_account, case_number, login_credentials, password, username,
6
+ device_id). These have NO learnable shape — a passport number, a chart number and a
7
+ purchase-order number are byte-for-byte indistinguishable — so the model gets
8
+ ~0 recall on them. But in real documents they almost always appear immediately
9
+ after a *field label / cue* ("Passport No.:", "Reisepass:", "MRN:", "Case No.",
10
+ "Versichertennummer:", "n° de compte", "护照号", ...).
11
+
12
+ This module detects the lead-in CUE, then captures the following VALUE token(s) as
13
+ that type. The cue GATES the match -> high precision: it will not fire on a bare
14
+ `PO#778231` / `JIRA-4821` / `ISBN ...` because those cues are not PII field labels.
15
+
16
+ Design:
17
+ * Per type, a list of multilingual cue phrases (regex-escaped, ws-flexible),
18
+ covering the 33 eval languages (en, de, fr, es, it, nl, pt, pl, ru, zh, ja, ko,
19
+ ar, hi, id, vi, th, sv, fi, da, el, ro, hu, cs, sk, bg, lt, lv, et, ga, mt).
20
+ * After a cue, allow <=3 chars of separators (colon/hash/dot/dash/space and a few
21
+ i18n colons),
22
+ then a VALUE: an alphanumeric token allowing internal spaces/hyphens/slashes/dots,
23
+ ``[A-Z0-9][A-Z0-9 ./-]{2,24}[A-Z0-9]``, trimmed, longest plausible.
24
+ * Robust to value being on the same line as the cue, and (for credentials) to an
25
+ inline secondary cue inside the value (e.g. "User: sa Pass: Master#01").
26
+
27
+ Public API:
28
+ context_cued_spans(text) -> [{start,end,type,text}, ...]
29
+ hybrid_with_context(text, model_spans) -> hybrid_spans + the cued tier (authoritative)
30
+
31
+ Order in the full decode: AUTH (shape) -> CONTEXT (these IDs, cue-gated) ->
32
+ SNAP (phone/date/amount/postal) -> model spans for the rest.
33
+ """
34
+ from __future__ import annotations
35
+ import re
36
+
37
+ # --------------------------------------------------------------------------- #
38
+ # VALUE: an arbitrary alphanumeric ID token. Must start & end on an alnum char,
39
+ # may carry internal spaces / hyphens / slashes / dots (so "1 90 12 33 123 456 78",
40
+ # "2:20-cv-09876-ABC", "12.345.678/0001-95", "20-00-00 acc: 55779911" all survive).
41
+ # Letters incl. accented Latin so "53219876S", "FK123456", "C01X00T47" survive; we
42
+ # stay Latin-only inside the value (CJK IDs are digit/Latin in this eval).
43
+ _VAL = r"[A-Za-z0-9@.#][A-Za-z0-9 ./_:@()#\-]{2,40}[A-Za-z0-9)]"
44
+ # Credential VALUE: passwords/secret tokens carry special chars (&!#$%@*?) and no
45
+ # spaces. Used ONLY for password / login-credential cues so the wider char set never
46
+ # leaks into the high-precision ID matching. Single token, no internal whitespace.
47
+ _CRED_VAL = re.compile(r"""["']?(?!@)([^\s"'@][^\s"']{3,59})["']?""")
48
+ # separators allowed between cue and value (incl. i18n colons / no.-abbreviations /
49
+ # quotes for `password = "..."`). Slightly longer cap to span ` = "`.
50
+ _SEP = r"""[\s:#=.№ :\-"']{0,6}"""
51
+ # secrets embedded in a connection string: scheme://user:PASSWORD(@host | end). The
52
+ # password must NOT be all-digits (that's a :PORT, e.g. redis://host:6379) and stops
53
+ # before '@host'.
54
+ _CONN_PW = re.compile(r"(?i)\b[a-z][a-z0-9+.\-]*://[^\s:@/]+:(?!\d+(?:[@\s\"']|$))([^\s:@/]{3,})(?=@|[\s\"']|$)")
55
+
56
+ # A trailing value must contain at least one DIGIT *or* be a credential-style token.
57
+ # IDs in this schema are numeric or alphanumeric-with-digits; pure-word tails after a
58
+ # cue ("Passport: Required") would otherwise false-fire. Credentials/usernames are
59
+ # exempt (passwords/usernames can be all-letters: "hunter2"? has a digit; "password"
60
+ # does not -> we exempt them, gated tightly by the credential cues).
61
+ _HAS_DIGIT = re.compile(r"\d")
62
+ _HAS_ALNUM = re.compile(r"[A-Za-z0-9]")
63
+
64
+ # --------------------------------------------------------------------------- #
65
+ # Cue phrases per type. Authored as raw alternation fragments; matched
66
+ # case-insensitively, with flexible internal whitespace. Keep them SPECIFIC to PII
67
+ # field labels so the precision traps (PO#, WO-, PROJ-, JIRA-, TICKET-, RMA-, INC,
68
+ # CHG, EPIC-, BUG-, GH-, ISBN, DOI, PMID, ICD-10, Rule/Section/Chapter/Title,
69
+ # port/commit/tag/Flight/Gate/Lane/Building/Channel/tracking/Receipt/invoice/INV-/
70
+ # ORD-/REF/PURCHASE) do NOT match.
71
+ _CUES: dict[str, list[str]] = {
72
+ "identity.passport": [
73
+ r"passport(?:\s*(?:no|number|num|nr|#))?",
74
+ r"reisepass(?:nr|nummer)?", r"reisepass\s*-?\s*nr",
75
+ r"n[°ºo]\.?\s*(?:de\s*)?passeport", r"passeport\s*n[°ºo]?",
76
+ r"n[.°ºo]*\s*(?:de\s*)?pasaporte", r"pasaporte\s*n[°ºo]?",
77
+ r"passaporto", r"n[°ºo]\.?\s*(?:de\s*)?passaporte", r"paspoort",
78
+ r"numer\s*paszportu", r"paszport",
79
+ r"パスポート(?:番号)?", # パスポート(番号)
80
+ r"여권(?:번호)?", # 여권(번호)
81
+ r"护照号?", # 护照(号)
82
+ r"رقم\s*جواز\s*السفر", r"جواز\s*سفر(?:\s*رقم)?", # رقم جواز السفر / جواز سفر رقم
83
+ r"पासपोर्ट", # पासपोर्ट (hi)
84
+ r"паспорт", # паспорт (ru/bg)
85
+ r"диабатирио", # (filler-safe)
86
+ r"διαβατήριο", # διαβατήριο (el)
87
+ r"pasul?(?:uri)?", # ro passport-ish (guarded by digit value)
88
+ r"h[oó]\s*chi[ếe]u", r"so\s*h[oó]\s*chi[ếe]u", # hộ chiếu (vi)
89
+ r"หนังสือเดินทาง", # หนังสือเดินทาง (th)
90
+ r"pase", r"pas\s*nr", r"reisedokument", r"uütlevee", # da/sv/et-ish
91
+ ],
92
+ "identity.tax_id": [
93
+ r"tax\s*(?:id|identification|no|number|#)?", r"\bTIN\b", r"\bEIN\b",
94
+ r"vat\s*(?:id|no|number|reg|registration|#)?", r"\bVAT\b",
95
+ r"steuer\s*-?\s*id", r"steuernummer", r"steuer\s*-?\s*nr", r"ust\s*-?\s*idnr",
96
+ r"umsatzsteuer", r"\bNIF\b", r"\bCIF\b", r"\bCPF\b", r"\bCNPJ\b", r"\bRFC\b",
97
+ r"\bNIP\b", r"\bPAN\b", r"पैन", r"\bΑΦΜ\b", r"αφμ", r"codice\s*fiscale", r"partita\s*iva",
98
+ r"num[ée]ro\s*(?:fiscal|de\s*tva)", r"identifiant\s*fiscal",
99
+ r"momsnr", r"momsregistrerings", r"btw\s*-?\s*nr", r"\bDPH\b",
100
+ r"税号", r"纳税人识别号", # 税号 / 纳税人识别号
101
+ r"расчётный\s*номер", # ru tax-ish
102
+ r"ИНН", r"инн", # ИНН (ru tax id)
103
+ r"マイナンバー", # マイナンバー (ja)
104
+ r"税務", r"세금", r"사업자등록번호",
105
+ r"رقم\s*ضريبي", # رقم ضريبي (ar)
106
+ r"adoazonos[ií]t[oó]", r"ad[oó]sz[aá]m", # hu tax id
107
+ r"daňov[eé]\s*č[ií]slo", r"ičo\s*dph",
108
+ r"mok[ėe]t[oų]jo\s*kodas", r"pvm", # lt vat
109
+ ],
110
+ "identity.national_id": [
111
+ r"national\s*(?:id|identity|insurance)\s*(?:no|number|#)?",
112
+ r"\bNIN\b", r"\bNINO\b", r"\bDNI\b", r"\bNIE\b", r"\bNIF\b",
113
+ r"personalausweis(?:nr|nummer)?", r"ausweis\s*-?\s*nr",
114
+ r"identit[ée]\s*nationale", r"carte\s*nationale", r"num[ée]ro\s*national",
115
+ r"s[ée]curit[ée]\s*sociale", r"num[ée]ro\s*de\s*s[ée]curit[ée]\s*sociale",
116
+ r"n[°ºo]\.?\s*(?:de\s*)?s[ée]curit[ée]", r"insee",
117
+ r"documento\s*nacional", r"documento\s*de\s*identidad", r"c[ée]dula",
118
+ r"carta\s*d['’]?identit[aà]", r"codice\s*identit[aà]",
119
+ r"\bBSN\b", r"burgerservicenummer", r"\bPESEL\b", r"\brodn[eé]\s*č[ií]slo\b",
120
+ r"personnummer", r"henkil[öo]tunnus", r"cpr\s*-?\s*nr", r"\bCPR\b",
121
+ r"isikukood", r"personas\s*kods", r"asmens\s*kodas",
122
+ r"身份证(?:号|号码)?", # 身份证(号)
123
+ r"身份證(?:字號)?", # 身份證(字號)
124
+ r"주민(?:등록)?번호", # 주민(등록)번호
125
+ r"マイナンバー", # マイナンバー
126
+ r"рациональный", # национальный
127
+ r"номер\s*паспорта",
128
+ r"आधार(?:\s*संख्या|\s*नंबर)?", # आधार (संख्या) (hi)
129
+ r"رقم\s*وطني", r"الرقم\s*الوطني", # رقم وطني
130
+ r"บัตรประชาชน", # บัตรประชาชน (th)
131
+ r"ΑΔΤ", r"αριθμ[όο]ς\s*ταυτ[όο]τητας", # ΑΔΤ
132
+ r"cnp", r"cod\s*numeric\s*personal", # ro
133
+ r"szem[eé]lyi\s*(?:azonos[ií]t[oó]|igazolv[aá]ny)", # hu
134
+ r"so\s*cmnd", r"can\s*cu[oơ]c", r"cmnd", r"cccd", # vi
135
+ r"เลขบัตร", # th id
136
+ r"\bNRIC\b", r"\bKTP\b", r"nomor\s*induk\s*kependudukan", r"\bNIK\b", # id
137
+ ],
138
+ "identity.drivers_license": [
139
+ r"driver'?s?\s*licen[cs]e\s*(?:no|number|#)?", r"\bDL\b\s*#?",
140
+ r"driving\s*licen[cs]e", r"f[üu]hrerschein(?:nr|nummer)?",
141
+ r"permis\s*de\s*conduire", r"permis\s*conduire",
142
+ r"permiso\s*de\s*conducir", r"carnet\s*de\s*conducir", r"licencia\s*de\s*conducir",
143
+ r"patente\s*(?:di\s*guida|nr)?", r"rijbewijs",
144
+ r"prawo\s*jazdy", r"k[öo]rkort", r"ajokortti", r"f[øo]rerbevis", r"k[øo]rekort",
145
+ r"运转驾驶证", r"驾驶证", # 驾驶证 (zh)
146
+ r"運転免許", r"免許", # 運転免許 (ja)
147
+ r"운전면허(?:증)?", # 운전면허(증)
148
+ r"рукавительское", # filler
149
+ r"водительское\s*удостоверение", # ru
150
+ r"رخصة\s*(?:ال)?قيادة", # رخصة القيادة
151
+ r"permis\s*de\s*conducere", r"vezet[őo]i\s*enged[eé]ly", # ro/hu
152
+ r"ใบขับขี่", # ใบขับขี่ (th)
153
+ r"gi[aâ]y\s*ph[eé]p\s*l[aá]i\s*xe", r"b[aă]ng\s*l[aá]i", # vi
154
+ ],
155
+ "healthcare.medical_record": [
156
+ r"\bMRN\b", r"medical\s*record\s*(?:no|number|#)?", r"med\.?\s*rec\.?\s*(?:no|#)?",
157
+ r"chart\s*(?:no|number|#)?", r"patient\s*(?:id|no|number|#)",
158
+ r"\bUR\s*(?:number|no|#)?\b", r"\bNHS\s*(?:no|number)?\b", r"health\s*record",
159
+ r"aktenzeichen", r"patientennummer", r"fallnummer", r"patienten\s*-?\s*id",
160
+ r"n[°ºo]\.?\s*(?:de\s*)?dossier(?:\s*m[ée]dical)?", r"dossier\s*m[ée]dical",
161
+ r"dossiernummer", r"nr\.?\s*dosar(?:\s*medical)?", r"dosar\s*medical",
162
+ r"nr\.?\s*karty", r"medicininės\s*kortelės\s*nr", r"kortelės\s*nr",
163
+ r"n[ºo]\.?\s*(?:de\s*)?historia\s*cl[ií]nica", r"historia\s*cl[ií]nica",
164
+ r"numero\s*de\s*historia", r"n[uú]mero\s*de\s*historia",
165
+ r"cartella\s*clinica", r"numero\s*cartella",
166
+ r"prontu[áa]rio", r"pacientennummer", r"pati[ëe]ntnummer",
167
+ r"病历号", r"病歷號", r"医疗记录", # 病历号
168
+ r"カルテ番号", r"患者番号", # カルテ番号 (ja)
169
+ r"차트번호", r"환자번호", # 차트번호 (ko)
170
+ r"регистрационный", # filler
171
+ r"номер\s*медицинской\s*карты", # ru
172
+ r"رقم\s*الملف\s*الطبي", # رقم الملف الطبي
173
+ r"เวชระเบียน", # th medical record
174
+ ],
175
+ "healthcare.health_plan_id": [
176
+ r"health\s*plan\s*(?:id|no|number|#)?(?:\s*is)?", r"member\s*(?:id|no|number|#)",
177
+ r"id\s*plan\s*de\s*s[aă]n[aă]tate", r"plan\s*de\s*s[aă]n[aă]tate",
178
+ r"convenio", r"conv[êe]nio", r"स्वास्थ्य\s*योजना(?:\s*संख्या)?",
179
+ r"policy\s*(?:no|number|#|id)", r"insurance\s*(?:id|no|number|#)",
180
+ r"plan\s*id", r"subscriber\s*(?:id|no|#)", r"group\s*(?:no|number|#)\s*id",
181
+ r"versichertennummer", r"versicherten\s*-?\s*nr", r"krankenversicherung",
182
+ r"versicherungsnummer", r"\bAOK\b\s*versichert",
183
+ r"n[°ºo]\.?\s*(?:de\s*)?mutuelle", r"num[ée]ro\s*d['’]?assur[ée]", r"\bCPAM\b",
184
+ r"n[ºo]\.?\s*(?:de\s*)?(?:p[óo]liza|seguro)", r"n[uú]mero\s*de\s*afiliaci[óo]n",
185
+ r"tessera\s*sanitaria", r"polizza", r"numero\s*assicurato",
186
+ r"zorgverzekering", r"polisnummer",
187
+ r"保险号", r"医保号", r"医疗保险", # 保险号
188
+ r"保険証番号", r"被保険者番号", # 保険証番号 (ja)
189
+ r"보험증번호", r"건강보험", # 보험증번호 (ko)
190
+ r"номер\s*полиса", r"ОМС", # номер полиса / ОМС
191
+ r"رقم\s*(?:الت[أا])?مين", # رقم التأمين
192
+ r"szem[eé]lyi\s*biztos[ií]t", # hu insurance-ish
193
+ ],
194
+ "financial.bank_account": [
195
+ r"bank\s*account\s*(?:no|number|#)?", r"\baccount\s*(?:no|number|#)",
196
+ r"\bacct\b\.?\s*(?:no|#)?", r"\bacc\b\.?\s*(?:no|#)?", r"\ba/?c\b\s*(?:no|#)?",
197
+ r"checking\s*(?:account|no|#)?", r"savings\s*(?:account|no|#)?",
198
+ r"sort\s*code", r"routing\s*(?:no|number|#)?", r"\bABA\b", r"transit\s*(?:no|#)?",
199
+ r"konto(?:nummer|nr)?", r"konto\s*-?\s*nr", r"bankverbindung",
200
+ r"n[°ºo]\.?\s*(?:de\s*)?compte", r"compte\s*bancaire", r"\bRIB\b",
201
+ r"n[ºo]\.?\s*(?:de\s*)?cuenta", r"cuenta\s*bancaria", r"numero\s*de\s*cuenta",
202
+ r"conto\s*(?:corrente|bancario)?", r"numero\s*di\s*conto",
203
+ r"conta\s*(?:banc[áa]ria|corrente)?", r"n[uú]mero\s*da\s*conta",
204
+ r"rekeningnummer", r"bankrekening",
205
+ r"numer\s*konta", r"nr\s*konta", r"kontonr", r"bankkonto",
206
+ r"kontonummer", r"tilinumero", r"pankkitili", r"konto\s*nr",
207
+ r"银行账号", r"账号", r"帐号", r"銀行口座", # 银行账号
208
+ r"口座番号", r"銀行口座", # 口座番号 (ja)
209
+ r"계좌번호", r"은행계좌", # 계좌번호 (ko)
210
+ r"номер\s*счета", r"расчетный\s*счет", # номер счета
211
+ r"рахм\s*алхисаб", # رقم الحساب-ish
212
+ r"رقم\s*الحساب", # رقم الحساب (ar)
213
+ r"cont\s*bancar", r"num[aă]r\s*de\s*cont", # ro
214
+ r"banksz[aá]mla", r"sz[aá]mlasz[aá]m", # hu
215
+ r"so\s*t[aà]i\s*kho[aả]n", r"t[aà]i\s*kho[aả]n", # vi
216
+ r"เลขที่บัญชี", # เลขที่บัญชี (th)
217
+ ],
218
+ "legal.case_number": [
219
+ r"case\s*(?:no|number|#)", r"docket\s*(?:no|number|#)?",
220
+ r"cause\s*(?:no|number)", r"indictment\s*(?:no|number)", r"file\s*(?:no|number)",
221
+ r"aktenzeichen", r"gesch[äa]ftsnummer", r"\bAz\.?\s*:",
222
+ r"n[°ºo]\.?\s*(?:de\s*)?(?:r[ôo]le|dossier|affaire)", r"r[ée]f[ée]rence\s*affaire",
223
+ r"\bR\.?\s*G\.?\s*n", r"numero\s*di\s*ruolo", r"procedimento\s*n",
224
+ r"n[ºo]\.?\s*(?:de\s*)?(?:expediente|procedimiento|causa)", r"autos\s*n",
225
+ r"sygnatura(?:\s*akt)?", r"\bsygn\.?\s*akt\b",
226
+ r"zaaknummer", r"rolnummer", r"m[ åa]lnummer", r"sagsnr", r"asianumero",
227
+ r"案件号", r"案号", r"案件编号", # 案件号 (zh)
228
+ r"事件��号", r"裁判番号", # 事件番号 (ja)
229
+ r"사건번호", # 사건번호 (ko)
230
+ r"номер\s*дела", r"дело\s*№", # номер дела
231
+ r"رقم\s*القضية", # رقم القضية (ar)
232
+ r"num[aă]r\s*(?:dosar|de\s*[ií]nregistrare)", r"dosar\s*nr", # ro
233
+ r"[üu]gysz[aá]m", r"\bb[ií]r[oó]s[aá]gi\b", # hu
234
+ r"so\s*v[uụ]\s*[aá]n", r"so\s*h[oồ]\s*s[oơ]", # vi
235
+ r"αριθμ[όο]ς\s*υπ[οό]θεσης", # el
236
+ r"spr[aá]vne\s*č[ií]slo", r"č[ií]slo\s*jednac[ií]", # cs
237
+ ],
238
+ "developer.login_credentials": [
239
+ r"login\s*credentials?", r"credentials?", r"login\s*=?", r"logon",
240
+ r"oauth_token", r"oauth_secret", r"access[_\s]*token", r"auth\s*token",
241
+ r"anmeldedaten", r"zugangsdaten", r"identifiants?\s*de\s*connexion",
242
+ r"credenciales", r"credenziali", r"inloggegevens", r"dane\s*logowania",
243
+ r"登录凭据", r"ログイン情報",
244
+ r"인증정보", r"бианиевые",
245
+ r"учётные\s*данные", # учётные данные (ru)
246
+ ],
247
+ "credential.password": [
248
+ r"password", r"passwd", r"\bpwd\b", r"pass\b", r"passphrase",
249
+ r"passwort", r"kennwort", r"mot\s*de\s*passe", r"contrase[ñn]a",
250
+ r"senha", r"wachtwoord", r"has[łl]o", r"heslo", r"l[öo]senord", r"salasana", r"adgangskode",
251
+ r"密码", r"パスワード", r"비밀번호",
252
+ r"пароль", # пароль (ru/bg)
253
+ r"كلمة\s*(?:ال)?سر", # كلمة السر (ar)
254
+ r"รหัสผ่าน", # รหัสผ่าน (th)
255
+ r"m[aậ]t\s*kh[aẩ]u", r"jelsz[oó]", r"parol[aă]", r"sl[aā]žvārds",
256
+ ],
257
+ "online.username": [
258
+ r"username", r"user\s*name", r"\buser\b", r"\buserid\b", r"user\s*id",
259
+ r"handle", r"account\s*name", r"\bacct\s*name\b", r"screen\s*name",
260
+ r"login\s*name", r"nick(?:name)?", r"\bid\s*utilisateur\b",
261
+ r"benutzername", r"benutzer\b", r"nom\s*d['’]?utilisateur", r"identifiant",
262
+ r"nombre\s*de\s*usuario", r"usuario", r"nome\s*utente", r"nome\s*de\s*usu[áa]rio",
263
+ r"gebruikersnaam", r"nazwa\s*u[żz]ytkownika", r"u[żz]ytkownik",
264
+ r"anv[äa]ndarnamn", r"k[äa]ytt[äa]j[äa]tunnus", r"vartotojas", r"brugernavn",
265
+ r"用户名", r"ユーザー名", r"사용자명", r"아이디", # 用户名 / ユーザー名
266
+ r"имя\s*пользователя", r"пользователь", r"логин", # имя пользователя / пользователь / логин
267
+ r"اسم\s*المستخدم", # اسم المستخدم
268
+ r"ชื่อผู้ใช้", # ชื่อผู้ใช้ (th)
269
+ r"t[eê]n\s*(?:đăng\s*nh[aậ]p|ng[uư][oờ]i\s*d[uù]ng)", # vi
270
+ r"felhaszn[aá]l[oó]n[eé]v", # hu
271
+ ],
272
+ "developer.device_id": [
273
+ r"device\s*(?:id|asset\s*tag|serial)?", r"device\s*asset\s*tag", r"asset\s*tag",
274
+ r"\bIMEI\b", r"\bUDID\b", r"\bESN\b", r"\bMEID\b", r"\bSN\b\s*:?",
275
+ r"serial\s*(?:no|number|#)?", r"seriennummer", r"ger[äa]te\s*-?\s*id",
276
+ r"asset\s*-?\s*nr", r"asset\s*-?\s*nummer", r"ger[äa]tenummer",
277
+ r"num[ée]ro\s*de\s*s[ée]rie", r"identifiant\s*(?:de\s*l['’]?)?appareil",
278
+ r"n[uú]mero\s*de\s*serie", r"identificador\s*de\s*dispositivo",
279
+ r"numero\s*di\s*serie", r"id\s*dispositivo", r"apparaat\s*id",
280
+ r"设备号", r"设备标识", r"序列号", # 设备号
281
+ r"デバイス番号", r"シリアル番号", # デバイス番号
282
+ r"장치아이디", r"일련번호", # 장치 아이디 (ko)
283
+ r"номер\s*устройства", # номер устройства
284
+ r"رقم\s*الجهاز", # رقم الجهاز (ar)
285
+ ],
286
+ }
287
+
288
+ # Inline secondary cues for credential pairs: inside a login_credentials VALUE the
289
+ # user/pass tokens carry their own micro-cues. We keep the whole "User: x Pass: y"
290
+ # region as one login_credentials span (matches the gold which does the same).
291
+ _LOGIN_PAIR = re.compile(
292
+ r"(?i)\b(?:user|usuario|benutzer|utilisateur|u|login|name)\b\s*[:=]?\s*\S+"
293
+ r".{0,8}?\b(?:pass(?:word)?|pwd|passwort|mot\s*de\s*passe|p|token|secret)\b\s*[:=]?\s*\S+"
294
+ )
295
+
296
+ # Compile: (type, compiled cue regex). Longer/more-specific cues first within a type
297
+ # so the alternation prefers the most specific label.
298
+ def _compile(cues: list[str]) -> re.Pattern:
299
+ # sort by descending raw length so e.g. "national insurance no" beats "id"
300
+ ordered = sorted(cues, key=len, reverse=True)
301
+ return re.compile(r"(?i)(?:" + r"|".join(ordered) + r")")
302
+
303
+ _CUE_RX = [(t, _compile(cs)) for t, cs in _CUES.items()]
304
+ _VAL_RX = re.compile(_VAL)
305
+
306
+ # types whose value may be all-letters (no digit required)
307
+ _LETTER_OK = {"credential.password", "online.username", "developer.login_credentials"}
308
+
309
+ # a username/handle: single token (handles may carry @ . _ -), NOT a capitalized prose
310
+ # word ("Holder", "Statement", "Bank"). Rejects multi-word values and Title-case words.
311
+ _PROSE_WORD = re.compile(r"^[A-ZÀ-Þ][a-zà-ÿ]+$")
312
+ def _username_ok(val: str, sep: str) -> bool:
313
+ if " " in val: # usernames don't contain spaces
314
+ return False
315
+ if _PROSE_WORD.match(val): # 'Holder', 'Statement', 'Bank', 'Sort'
316
+ return False
317
+ # require a real label delimiter (':' '=') OR an '@'-handle: a bare "User cannot"
318
+ # (cue + space + word) is prose, not a labelled field.
319
+ if not (":" in sep or "=" in sep or val.startswith("@")):
320
+ return False
321
+ return True
322
+ # login_credentials standalone value must look credential-ish (has digit/symbol or '='),
323
+ # not a bare prose word ('attempts', 'success', 'cannot').
324
+ def _login_ok(val: str) -> bool:
325
+ if _PROSE_WORD.match(val) or (val.isalpha() and val.islower()):
326
+ return False
327
+ return True
328
+
329
+ # password-context English words that follow the cue 'password' in prose
330
+ # ("password authentication failed", "password expired") -> not a password value.
331
+ _PW_STOP = {"authentication", "expired", "required", "reset", "change", "changed",
332
+ "incorrect", "invalid", "failed", "failure", "policy", "rotation",
333
+ "manager", "protected", "field", "must", "should", "cannot", "and", "for",
334
+ "the", "is", "was", "has", "not", "verification", "recovery", "strength"}
335
+ def _password_ok(val: str) -> bool:
336
+ if " " in val:
337
+ return False
338
+ return val.lower() not in _PW_STOP
339
+
340
+ # A short stop-list of cue *contexts* that are traps even though a sub-cue matched.
341
+ # e.g. "ISBN", "DOI", "PMID", "PO#", "JIRA-" never become our types because their
342
+ # cue strings are simply not in _CUES — so no extra guard needed there. But a few
343
+ # generic English words ("user", "pass", "acc", "id", "SN") can appear in non-PII
344
+ # contexts; we gate them by requiring a plausible value right after.
345
+
346
+ # value characters that, if the value is ENTIRELY one of these shapes, indicate a
347
+ # version/path/non-PII tail we should reject even after a cue (rare; cue already gates).
348
+ _VERSIONISH = re.compile(r"^v?\d+(?:\.\d+){2,}$") # 1.29.2, v2.0.0
349
+
350
+
351
+ def _trim(text: str, s: int, e: int) -> tuple[int, int]:
352
+ while s < e and not _HAS_ALNUM.match(text[s]):
353
+ s += 1
354
+ while e > s and not _HAS_ALNUM.match(text[e - 1]):
355
+ e -= 1
356
+ return s, e
357
+
358
+
359
+ # country/jurisdiction & abbreviation connector tokens that legitimately sit INSIDE an
360
+ # id value ("DL: WA: SMITHJ123AB", "sort: 20-00-00 acc: 55779911", "No. 1 90 12 ...").
361
+ _ID_CONNECTORS = {"no", "no.", "nr", "nr.", "acc", "acc:", "acct", "sort", "bsb",
362
+ "transit", "routing", "checking", "savings", "de", "fr", "id",
363
+ "uid", "udid", "sn", "esn", "imei", "tva", "vat", "nip", "iva"}
364
+ _LOWER_WORD = re.compile(r"^[a-zà-öø-ÿ]+$")
365
+ def _id_like_chunk(c: str) -> bool:
366
+ """A space-separated chunk that plausibly continues an arbitrary-ID value:
367
+ contains a digit, is all-caps, or is a known connector/jurisdiction token. A plain
368
+ lowercase word ('email', 'phone', 'oder', 'etwas', 'next') is prose -> ends value."""
369
+ if not c:
370
+ return False
371
+ if _HAS_DIGIT.search(c):
372
+ return True
373
+ cc = c.rstrip(".:#-/")
374
+ if not cc:
375
+ return False
376
+ if cc.lower() in _ID_CONNECTORS:
377
+ return True
378
+ if _LOWER_WORD.match(cc): # pure lowercase word -> prose, stop
379
+ return False
380
+ if cc.isupper(): # DNI, CPAM, BCBS, WA, NHS, SMITHJ ...
381
+ return True
382
+ return False # mixed-case word w/o digit -> stop
383
+
384
+
385
+ def _bound_value(text: str, vs: int, ve: int) -> int:
386
+ """Stop a multi-token value at the first non-ID-like (prose) chunk, so a value on
387
+ the same line as following prose ('C01X00T47 email [email protected]') is not over-captured.
388
+ The first chunk after the cue is always kept (it IS the id head)."""
389
+ seg = text[vs:ve]
390
+ if " " not in seg:
391
+ return ve
392
+ parts = seg.split(" ")
393
+ keep = 1
394
+ for c in parts[1:]:
395
+ if _id_like_chunk(c):
396
+ keep += 1
397
+ else:
398
+ break
399
+ if keep == len(parts):
400
+ return ve
401
+ new = vs + len(" ".join(parts[:keep]))
402
+ return new
403
+
404
+
405
+ def context_cued_spans(text: str) -> list[dict]:
406
+ """Detect cue -> value ID spans. Returns [{start,end,type,text}], non-overlapping,
407
+ cue-gated (high precision)."""
408
+ out = []
409
+ claimed = [False] * len(text)
410
+
411
+ # Within a type, find every cue occurrence and grab the following value.
412
+ # Process types in a priority order so that when two cues overlap (e.g. "user"
413
+ # for username vs "User:" inside a login pair) the more specific wins. We let
414
+ # login_credentials pairs be detected first (they subsume user/pass micro-cues).
415
+ candidates = [] # (start, end, type, specificity)
416
+ sep_rx = re.compile(_SEP)
417
+ for typ, rx in _CUE_RX:
418
+ # credential secrets are single tokens with special chars -> use _CRED_VAL
419
+ cred = typ in ("credential.password", "developer.login_credentials")
420
+ for m in rx.finditer(text):
421
+ cue_end = m.end()
422
+ # capture value starting within _SEP chars after the cue
423
+ sep = sep_rx.match(text, cue_end)
424
+ val_start = sep.end() if sep else cue_end
425
+ if cred:
426
+ vm = _CRED_VAL.match(text, val_start)
427
+ if not vm:
428
+ continue
429
+ vs, ve = vm.start(1), vm.end(1)
430
+ else:
431
+ vm = _VAL_RX.match(text, val_start)
432
+ if not vm:
433
+ continue
434
+ vs, ve = _trim(text, vm.start(), vm.end())
435
+ ve = _bound_value(text, vs, ve)
436
+ vs, ve = _trim(text, vs, ve)
437
+ if ve - vs < 3:
438
+ continue
439
+ val = text[vs:ve]
440
+ # gating
441
+ if typ not in _LETTER_OK and not _HAS_DIGIT.search(val):
442
+ continue
443
+ if _VERSIONISH.match(val.replace(" ", "")):
444
+ continue
445
+ if typ == "online.username" and not _username_ok(val, text[cue_end:val_start]):
446
+ continue
447
+ if typ == "developer.login_credentials" and not _login_ok(val):
448
+ continue
449
+ if typ == "credential.password" and not _password_ok(val):
450
+ continue
451
+ # specificity = length of matched cue (longer cue = more specific label)
452
+ spec = m.end() - m.start()
453
+ candidates.append((vs, ve, typ, spec, val))
454
+
455
+ # Login-credential pairs: capture the whole user/pass region as one span.
456
+ for m in _LOGIN_PAIR.finditer(text):
457
+ s, e = _trim(text, m.start(), m.end())
458
+ if e - s >= 3:
459
+ candidates.append((s, e, "developer.login_credentials", 9999, text[s:e]))
460
+
461
+ # Connection-string embedded password: scheme://user:PASSWORD@host
462
+ for m in _CONN_PW.finditer(text):
463
+ vs, ve = m.start(1), m.end(1)
464
+ if ve - vs >= 3:
465
+ candidates.append((vs, ve, "credential.password", 5000, text[vs:ve]))
466
+
467
+ # Resolve overlaps: prefer higher specificity, then longer span.
468
+ candidates.sort(key=lambda c: (-c[3], -(c[1] - c[0])))
469
+ for vs, ve, typ, spec, val in candidates:
470
+ if any(claimed[vs:ve]):
471
+ continue
472
+ for i in range(vs, ve):
473
+ claimed[i] = True
474
+ out.append({"start": vs, "end": ve, "type": typ, "text": text[vs:ve]})
475
+
476
+ out.sort(key=lambda d: (d["start"], d["end"]))
477
+ return out
478
+
479
+
480
+ # --------------------------------------------------------------------------- #
481
+ # Hybrid variant: AUTH -> CONTEXT (these IDs, authoritative) -> SNAP -> model.
482
+ # We reuse the shipped self-contained decode (hybrid_spans + _AUTH_TYPES) by loading
483
+ # it from the v8 model dir, so AUTH/SNAP logic is never re-implemented here.
484
+ _BASE_DECODE = None
485
+ def _load_base_decode():
486
+ global _BASE_DECODE
487
+ if _BASE_DECODE is None:
488
+ import importlib.util
489
+ path = "/lambdafs/simon/models/pii-detect-v8/pii_hybrid_decode.py"
490
+ spec = importlib.util.spec_from_file_location("pii_hybrid_decode_v8", path)
491
+ mod = importlib.util.module_from_spec(spec)
492
+ spec.loader.exec_module(mod)
493
+ _BASE_DECODE = (mod.hybrid_spans, mod._AUTH_TYPES)
494
+ return _BASE_DECODE
495
+
496
+
497
+ def hybrid_with_context(text: str, model_spans: list[dict]) -> list[dict]:
498
+ """Run the existing hybrid decode, then layer the cue-gated CONTEXT tier on top.
499
+ CONTEXT spans are authoritative for their (Group-A) types: they REPLACE any model
500
+ span of the same type that they overlap, and own their boundaries. AUTH still wins
501
+ over CONTEXT on overlap (shape-bearing formats take priority)."""
502
+ hybrid_spans, _AUTH_TYPES = _load_base_decode()
503
+
504
+ base = hybrid_spans(text, model_spans)
505
+ cued = context_cued_spans(text)
506
+ if not cued:
507
+ return base
508
+
509
+ cued_types = set(_CUES)
510
+ # mark char ranges owned by AUTH spans (AUTH > CONTEXT)
511
+ auth_claim = [False] * len(text)
512
+ for sp in base:
513
+ if sp["type"] in _AUTH_TYPES:
514
+ for i in range(sp["start"], sp["end"]):
515
+ auth_claim[i] = True
516
+
517
+ kept_cued = []
518
+ for c in cued:
519
+ if any(auth_claim[c["start"]:c["end"]]):
520
+ continue # AUTH owns this region
521
+ kept_cued.append(c)
522
+
523
+ cued_ranges = [(c["start"], c["end"]) for c in kept_cued]
524
+
525
+ def _overlaps_cued(sp):
526
+ for s, e in cued_ranges:
527
+ if min(e, sp["end"]) > max(s, sp["start"]):
528
+ return True
529
+ return False
530
+
531
+ out = []
532
+ for sp in base:
533
+ # drop model/SNAP spans of a CONTEXT type that overlap a cued span
534
+ if sp["type"] in cued_types and _overlaps_cued(sp):
535
+ continue
536
+ out.append(sp)
537
+ out.extend(kept_cued)
538
+
539
+ seen, uniq = set(), []
540
+ for sp in sorted(out, key=lambda s: (s["start"], s["end"])):
541
+ k = (sp["start"], sp["end"], sp["type"])
542
+ if k in seen:
543
+ continue
544
+ if len(text[sp["start"]:sp["end"]].strip()) < 3:
545
+ continue
546
+ seen.add(k)
547
+ uniq.append(sp)
548
+ return uniq
549
+
550
+ CONTEXT_TYPES = set(_CUES) # the 12 Group-A types this tier owns