pii-masking-zh-tw / inference.py
GOSHUNCLE's picture
add filter
0b4b423 verified
Raw
History Blame
12.7 kB
#!/usr/bin/env python3
"""
PII Masking ZH-TW — inference module
Detects and masks 8 types of Taiwan-specific personal information (PII)
using a fine-tuned Qwen2.5-1.5B-Instruct + LoRA adapter, returning structured
JSON output.
Supported types: NAME, ROC_ID, PHONE, EMAIL, ADDRESS, NHI_ID, BANK_ACCOUNT,
CREDIT_CARD.
Quick start:
from inference import PIIDetector
detector = PIIDetector()
result = detector.detect_and_replace(
"客戶王小明來電諮詢,身分證A123456789,手機0912345678"
)
print(result["masked_text"])
Environment variables (optional, for local development):
PII_BASE_MODEL Override base model id or path
PII_ADAPTER_REPO Override adapter repo id or local path
"""
import json
import os
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
from peft import PeftModel
# ============================================================
# Configuration
# ============================================================
BASE_MODEL = os.environ.get("PII_BASE_MODEL", "Qwen/Qwen2.5-1.5B-Instruct")
ADAPTER_REPO = os.environ.get("PII_ADAPTER_REPO", "GOSHUNCLE/pii-masking-zh-tw")
SYSTEM_PROMPT = """你是一個台灣個人資料(PII)偵測專家。分析輸入文本,找出所有個人可識別資訊(PII),以嚴格 JSON 格式回傳。
偵測類型與遮罩規則:
- ROC_ID:身分證字號(格式:1英文字母+9數字)→ 保留首2碼與末2碼,其餘以*替代
- NAME:姓名(依語意判斷,或依特徵:2-4個中文字且首字為常見姓氏)→ 保留姓氏,名字以O替代
- PHONE:手機號碼(09開頭,10碼)→ 保留前4碼,第5碼起全部以*替代
- EMAIL:電子郵件 → 保留首2字元與@後域名,中間以*替代
- ADDRESS:完整地址(須包含「號」或「樓」才視為完整地址)→ 保留至「區」,區之後以*替代
- NHI_ID:健保卡號(12碼數字)→ 保留前4碼,其餘以*替代
- BANK_ACCOUNT:銀行帳號 → 保留前4碼,其餘以*替代
- CREDIT_CARD:信用卡號(16碼)→ 保留前4碼,其餘以*替代
輸出格式:
{"pii_found": true/false, "entities": [{"type": "類型", "value": "原始值", "masked": "遮罩值"}]}
規則:
1. 無PII時回傳 {"pii_found": false, "entities": []}
2. 同一文本中有多個PII須全部列出
3. 只回傳JSON,不加任何說明文字"""
# ============================================================
# JSON parsing (minimal defensive)
# ============================================================
def _extract_json(text):
text = text.strip()
start = text.find("{")
end = text.rfind("}") + 1
if start < 0 or end <= start:
return None
try:
return json.loads(text[start:end])
except json.JSONDecodeError:
return None
# ============================================================
# Digit-character detection (halfwidth / fullwidth / Chinese numerals)
# ============================================================
_CHINESE_DIGITS = "零一二三四五六七八九"
def _is_digit_char(c):
return c.isdigit() or c in _CHINESE_DIGITS
# ============================================================
# Filter 1: structural validation
# ------------------------------------------------------------
# Verify the entity's value matches its claimed type's structural pattern.
# Filters out misclassifications produced by the model.
# ============================================================
def _is_valid_entity(entity):
etype = entity.get("type", "")
value = entity.get("value", "")
if not value:
return False
if etype == "NAME":
return 2 <= len(value) <= 4
if etype == "EMAIL":
if "@" not in value:
return False
_, _, domain = value.partition("@")
return "." in domain
if etype == "ADDRESS":
# System prompt requires "號" or "樓" for valid full address
return "號" in value or "樓" in value
raw = value.replace("-", "").replace(" ", "")
if etype == "ROC_ID":
if len(raw) != 10:
return False
return raw[0].isalpha() and all(_is_digit_char(c) for c in raw[1:])
if etype == "PHONE":
return len(raw) == 10 and all(_is_digit_char(c) for c in raw)
if etype == "NHI_ID":
return len(raw) == 12 and all(_is_digit_char(c) for c in raw)
if etype == "BANK_ACCOUNT":
return 10 <= len(raw) <= 16 and all(_is_digit_char(c) for c in raw)
if etype == "CREDIT_CARD":
return len(raw) == 16 and all(_is_digit_char(c) for c in raw)
return True
# ============================================================
# Filter 2: provenance check (guards against hallucination)
# ------------------------------------------------------------
# The value must verbatim appear in the input text. Tolerant of
# whitespace/separator differences.
# ============================================================
def _value_in_text(value, text):
if not value:
return False
if value in text:
return True
norm = lambda s: s.replace(" ", "").replace("-", "").replace(" ", "")
return norm(value) in norm(text)
# ============================================================
# Mask validation and correction
# ============================================================
def _validate_and_fix_mask(entity):
"""Recompute the masked field from the value field using deterministic
rules. Compensates for the model's occasional miscount in star quantity."""
etype = entity.get("type", "")
value = entity.get("value", "")
masked = entity.get("masked", "")
correct_mask = None
if etype == "ROC_ID" and len(value) == 10:
correct_mask = value[:2] + "*" * 6 + value[-2:]
elif etype == "NAME" and len(value) >= 2:
correct_mask = value[0] + "O" * (len(value) - 1)
elif etype == "PHONE":
raw = value.replace("-", "").replace(" ", "")
if len(raw) == 10:
correct_mask = raw[:4] + "*" * 6
elif etype == "EMAIL" and "@" in value:
local, domain = value.split("@", 1)
if len(local) >= 2:
correct_mask = local[:2] + "*" * (len(local) - 2) + "@" + domain
elif etype == "ADDRESS":
# Find the deepest admin division as the cut point.
# Priority: 區 / 鎮 / 鄉 first, fallback to 市 / 縣.
prefix = None
best_idx = -1
for keyword in ["區", "鎮", "鄉"]:
idx = value.find(keyword)
if idx >= 0 and idx > best_idx:
best_idx = idx
prefix = value[:idx + 1]
if prefix is None:
for keyword in ["市", "縣"]:
idx = value.find(keyword)
if idx >= 0 and idx > best_idx:
best_idx = idx
prefix = value[:idx + 1]
# Guard: prefix must be strictly shorter than value
# (avoids cases like "社區" where 區 is at the end)
if prefix and len(prefix) < len(value):
correct_mask = prefix + "*" * (len(value) - len(prefix))
elif etype in ("NHI_ID", "BANK_ACCOUNT", "CREDIT_CARD"):
if len(value) >= 4:
correct_mask = value[:4] + "*" * (len(value) - 4)
if correct_mask and correct_mask != masked:
entity["masked"] = correct_mask
entity["mask_corrected"] = True
return entity
# ============================================================
# PIIDetector
# ============================================================
class PIIDetector:
def __init__(self, base_model=BASE_MODEL, adapter_repo=ADAPTER_REPO):
print("Loading model...")
self.tokenizer = AutoTokenizer.from_pretrained(
base_model,
trust_remote_code=True,
padding_side="left",
)
if self.tokenizer.pad_token is None:
self.tokenizer.pad_token = self.tokenizer.eos_token
base = AutoModelForCausalLM.from_pretrained(
base_model,
dtype=torch.float32,
device_map="cpu",
trust_remote_code=True,
)
self.model = PeftModel.from_pretrained(base, adapter_repo)
self.model.eval()
print("Model loaded.")
def detect(self, text, fix_masks=True):
"""Detect PII in `text`.
Pipeline:
1. Model inference
2. JSON parse
3. Filter 1: structural validation
4. Filter 2: provenance check (rejects hallucinations)
5. validate_and_fix_mask: deterministic mask recomputation
6. Recompute pii_found (False if all entities filtered out)
Returns:
dict: {"pii_found": bool, "entities": list, "raw_response": str,
"parse_error": bool}
"""
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": text},
]
prompt = self.tokenizer.apply_chat_template(
messages, tokenize=False, add_generation_prompt=True,
)
inputs = self.tokenizer(prompt, return_tensors="pt")
with torch.no_grad():
outputs = self.model.generate(
**inputs,
max_new_tokens=512,
do_sample=False,
temperature=1.0,
pad_token_id=self.tokenizer.pad_token_id,
)
new_tokens = outputs[0][inputs["input_ids"].shape[1]:]
raw_response = self.tokenizer.decode(new_tokens, skip_special_tokens=True).strip()
parsed = _extract_json(raw_response)
if parsed is None:
return {
"pii_found": False,
"entities": [],
"raw_response": raw_response,
"parse_error": True,
}
# ===== Two-stage filtering =====
entities = parsed.get("entities", [])
if entities:
# Filter 1: structural validation
entities = [e for e in entities if _is_valid_entity(e)]
# Filter 2: provenance check (rejects hallucinated/translated values)
entities = [e for e in entities if _value_in_text(e.get("value", ""), text)]
# Mask correction (only for entities passing both filters)
if fix_masks:
entities = [_validate_and_fix_mask(e) for e in entities]
parsed["entities"] = entities
parsed["pii_found"] = bool(entities)
parsed["raw_response"] = raw_response
parsed["parse_error"] = False
return parsed
def detect_and_replace(self, text):
"""Detect PII and return the input text with all detected PII replaced
by their masked forms."""
result = self.detect(text)
masked_text = text
if result.get("entities"):
sorted_ents = sorted(
result["entities"],
key=lambda e: len(e.get("value", "")),
reverse=True,
)
for e in sorted_ents:
value = e.get("value", "")
masked = e.get("masked", "")
if value and masked and value in masked_text:
# Replace all occurrences
masked_text = masked_text.replace(value, masked)
return {
"original": text,
"masked_text": masked_text,
"entities": result.get("entities", []),
"pii_found": result.get("pii_found", False),
}
# ============================================================
# CLI
# ============================================================
if __name__ == "__main__":
detector = PIIDetector()
print("\n" + "=" * 60)
print("PII Masking ZH-TW — interactive mode")
print("Type text and press Enter to detect; type 'q' to exit.")
print("=" * 60)
while True:
print()
try:
user_input = input("> ").strip()
except (EOFError, KeyboardInterrupt):
print("\nBye.")
break
if user_input.lower() in ("q", "quit", "exit"):
print("Bye.")
break
if not user_input:
continue
result = detector.detect_and_replace(user_input)
print(f"\n original: {result['original']}")
print(f" masked : {result['masked_text']}")
if result["entities"]:
print(f" found {len(result['entities'])} PII:")
for e in result["entities"]:
corrected = " (corrected)" if e.get("mask_corrected") else ""
print(f" [{e['type']}] {e['value']} -> {e['masked']}{corrected}")
else:
print(" no PII detected")