GOSHUNCLE commited on
Commit
0b4b423
·
verified ·
1 Parent(s): 0b0627e

add filter

Browse files
Files changed (1) hide show
  1. inference.py +100 -24
inference.py CHANGED
@@ -17,7 +17,6 @@ Quick start:
17
  "客戶王小明來電諮詢,身分證A123456789,手機0912345678"
18
  )
19
  print(result["masked_text"])
20
- # 客戶王OO來電諮詢,身分證A1******89,手機0912******
21
 
22
  Environment variables (optional, for local development):
23
  PII_BASE_MODEL Override base model id or path
@@ -61,7 +60,6 @@ SYSTEM_PROMPT = """你是一個台灣個人資料(PII)偵測專家。分析
61
  # JSON parsing (minimal defensive)
62
  # ============================================================
63
  def _extract_json(text):
64
- """Pull the first JSON object out of the model output and parse it."""
65
  text = text.strip()
66
  start = text.find("{")
67
  end = text.rfind("}") + 1
@@ -73,13 +71,84 @@ def _extract_json(text):
73
  return None
74
 
75
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
  # ============================================================
77
  # Mask validation and correction
78
  # ============================================================
79
  def _validate_and_fix_mask(entity):
80
  """Recompute the masked field from the value field using deterministic
81
- rules. Compensates for the model's occasional miscount in star quantity.
82
- """
83
  etype = entity.get("type", "")
84
  value = entity.get("value", "")
85
  masked = entity.get("masked", "")
@@ -118,7 +187,9 @@ def _validate_and_fix_mask(entity):
118
  if idx >= 0 and idx > best_idx:
119
  best_idx = idx
120
  prefix = value[:idx + 1]
121
- if prefix:
 
 
122
  correct_mask = prefix + "*" * (len(value) - len(prefix))
123
 
124
  elif etype in ("NHI_ID", "BANK_ACCOUNT", "CREDIT_CARD"):
@@ -158,14 +229,17 @@ class PIIDetector:
158
  def detect(self, text, fix_masks=True):
159
  """Detect PII in `text`.
160
 
161
- Args:
162
- text: input text (Chinese, fullwidth, or plain ASCII all work)
163
- fix_masks: if True, recompute `masked` field using deterministic
164
- rules to guarantee correct star count.
 
 
 
165
 
166
  Returns:
167
- dict with keys: pii_found (bool), entities (list of {type, value,
168
- masked, optionally mask_corrected}), raw_response (str), parse_error (bool).
169
  """
170
  messages = [
171
  {"role": "system", "content": SYSTEM_PROMPT},
@@ -196,28 +270,30 @@ class PIIDetector:
196
  "parse_error": True,
197
  }
198
 
199
- if fix_masks and parsed.get("entities"):
200
- parsed["entities"] = [_validate_and_fix_mask(e) for e in parsed["entities"]]
201
-
 
 
 
 
 
 
 
 
 
 
202
  parsed["raw_response"] = raw_response
203
  parsed["parse_error"] = False
204
  return parsed
205
 
206
  def detect_and_replace(self, text):
207
  """Detect PII and return the input text with all detected PII replaced
208
- by their masked forms.
209
-
210
- Returns:
211
- dict with keys: original (str), masked_text (str), entities (list),
212
- pii_found (bool).
213
- """
214
  result = self.detect(text)
215
  masked_text = text
216
 
217
  if result.get("entities"):
218
- # Sort by value length descending so longer strings are replaced
219
- # first; otherwise a short value that is a substring of a longer
220
- # one would corrupt the longer match.
221
  sorted_ents = sorted(
222
  result["entities"],
223
  key=lambda e: len(e.get("value", "")),
@@ -227,7 +303,7 @@ class PIIDetector:
227
  value = e.get("value", "")
228
  masked = e.get("masked", "")
229
  if value and masked and value in masked_text:
230
- # Replace all occurrences (e.g., a name appearing twice).
231
  masked_text = masked_text.replace(value, masked)
232
 
233
  return {
 
17
  "客戶王小明來電諮詢,身分證A123456789,手機0912345678"
18
  )
19
  print(result["masked_text"])
 
20
 
21
  Environment variables (optional, for local development):
22
  PII_BASE_MODEL Override base model id or path
 
60
  # JSON parsing (minimal defensive)
61
  # ============================================================
62
  def _extract_json(text):
 
63
  text = text.strip()
64
  start = text.find("{")
65
  end = text.rfind("}") + 1
 
71
  return None
72
 
73
 
74
+ # ============================================================
75
+ # Digit-character detection (halfwidth / fullwidth / Chinese numerals)
76
+ # ============================================================
77
+ _CHINESE_DIGITS = "零一二三四五六七八九"
78
+
79
+
80
+ def _is_digit_char(c):
81
+ return c.isdigit() or c in _CHINESE_DIGITS
82
+
83
+
84
+ # ============================================================
85
+ # Filter 1: structural validation
86
+ # ------------------------------------------------------------
87
+ # Verify the entity's value matches its claimed type's structural pattern.
88
+ # Filters out misclassifications produced by the model.
89
+ # ============================================================
90
+ def _is_valid_entity(entity):
91
+ etype = entity.get("type", "")
92
+ value = entity.get("value", "")
93
+ if not value:
94
+ return False
95
+
96
+ if etype == "NAME":
97
+ return 2 <= len(value) <= 4
98
+
99
+ if etype == "EMAIL":
100
+ if "@" not in value:
101
+ return False
102
+ _, _, domain = value.partition("@")
103
+ return "." in domain
104
+
105
+ if etype == "ADDRESS":
106
+ # System prompt requires "號" or "樓" for valid full address
107
+ return "號" in value or "樓" in value
108
+
109
+ raw = value.replace("-", "").replace(" ", "")
110
+
111
+ if etype == "ROC_ID":
112
+ if len(raw) != 10:
113
+ return False
114
+ return raw[0].isalpha() and all(_is_digit_char(c) for c in raw[1:])
115
+
116
+ if etype == "PHONE":
117
+ return len(raw) == 10 and all(_is_digit_char(c) for c in raw)
118
+
119
+ if etype == "NHI_ID":
120
+ return len(raw) == 12 and all(_is_digit_char(c) for c in raw)
121
+
122
+ if etype == "BANK_ACCOUNT":
123
+ return 10 <= len(raw) <= 16 and all(_is_digit_char(c) for c in raw)
124
+
125
+ if etype == "CREDIT_CARD":
126
+ return len(raw) == 16 and all(_is_digit_char(c) for c in raw)
127
+
128
+ return True
129
+
130
+
131
+ # ============================================================
132
+ # Filter 2: provenance check (guards against hallucination)
133
+ # ------------------------------------------------------------
134
+ # The value must verbatim appear in the input text. Tolerant of
135
+ # whitespace/separator differences.
136
+ # ============================================================
137
+ def _value_in_text(value, text):
138
+ if not value:
139
+ return False
140
+ if value in text:
141
+ return True
142
+ norm = lambda s: s.replace(" ", "").replace("-", "").replace(" ", "")
143
+ return norm(value) in norm(text)
144
+
145
+
146
  # ============================================================
147
  # Mask validation and correction
148
  # ============================================================
149
  def _validate_and_fix_mask(entity):
150
  """Recompute the masked field from the value field using deterministic
151
+ rules. Compensates for the model's occasional miscount in star quantity."""
 
152
  etype = entity.get("type", "")
153
  value = entity.get("value", "")
154
  masked = entity.get("masked", "")
 
187
  if idx >= 0 and idx > best_idx:
188
  best_idx = idx
189
  prefix = value[:idx + 1]
190
+ # Guard: prefix must be strictly shorter than value
191
+ # (avoids cases like "社區" where 區 is at the end)
192
+ if prefix and len(prefix) < len(value):
193
  correct_mask = prefix + "*" * (len(value) - len(prefix))
194
 
195
  elif etype in ("NHI_ID", "BANK_ACCOUNT", "CREDIT_CARD"):
 
229
  def detect(self, text, fix_masks=True):
230
  """Detect PII in `text`.
231
 
232
+ Pipeline:
233
+ 1. Model inference
234
+ 2. JSON parse
235
+ 3. Filter 1: structural validation
236
+ 4. Filter 2: provenance check (rejects hallucinations)
237
+ 5. validate_and_fix_mask: deterministic mask recomputation
238
+ 6. Recompute pii_found (False if all entities filtered out)
239
 
240
  Returns:
241
+ dict: {"pii_found": bool, "entities": list, "raw_response": str,
242
+ "parse_error": bool}
243
  """
244
  messages = [
245
  {"role": "system", "content": SYSTEM_PROMPT},
 
270
  "parse_error": True,
271
  }
272
 
273
+ # ===== Two-stage filtering =====
274
+ entities = parsed.get("entities", [])
275
+ if entities:
276
+ # Filter 1: structural validation
277
+ entities = [e for e in entities if _is_valid_entity(e)]
278
+ # Filter 2: provenance check (rejects hallucinated/translated values)
279
+ entities = [e for e in entities if _value_in_text(e.get("value", ""), text)]
280
+ # Mask correction (only for entities passing both filters)
281
+ if fix_masks:
282
+ entities = [_validate_and_fix_mask(e) for e in entities]
283
+
284
+ parsed["entities"] = entities
285
+ parsed["pii_found"] = bool(entities)
286
  parsed["raw_response"] = raw_response
287
  parsed["parse_error"] = False
288
  return parsed
289
 
290
  def detect_and_replace(self, text):
291
  """Detect PII and return the input text with all detected PII replaced
292
+ by their masked forms."""
 
 
 
 
 
293
  result = self.detect(text)
294
  masked_text = text
295
 
296
  if result.get("entities"):
 
 
 
297
  sorted_ents = sorted(
298
  result["entities"],
299
  key=lambda e: len(e.get("value", "")),
 
303
  value = e.get("value", "")
304
  masked = e.get("masked", "")
305
  if value and masked and value in masked_text:
306
+ # Replace all occurrences
307
  masked_text = masked_text.replace(value, masked)
308
 
309
  return {