Token Classification
Transformers
Safetensors
lfm2
liquid
lfm2.5
bidirectional
masked-lm
encoder
pii
ner
privacy
multilingual
custom_code
Instructions to use LiquidAI/LFM2.5-Encoder-350M-PII-Detector with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use LiquidAI/LFM2.5-Encoder-350M-PII-Detector with Transformers:
# Use a pipeline as a high-level helper from transformers import pipeline pipe = pipeline("token-classification", model="LiquidAI/LFM2.5-Encoder-350M-PII-Detector", trust_remote_code=True)# Load model directly from transformers import AutoTokenizer, AutoModelForTokenClassification tokenizer = AutoTokenizer.from_pretrained("LiquidAI/LFM2.5-Encoder-350M-PII-Detector", trust_remote_code=True) model = AutoModelForTokenClassification.from_pretrained("LiquidAI/LFM2.5-Encoder-350M-PII-Detector", trust_remote_code=True, device_map="auto") - Notebooks
- Google Colab
- Kaggle
Upload context_cued.py with huggingface_hub
Browse files- 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
|