Spaces:
Runtime error
Runtime error
| # utils.py β Document Sentinel v2.0 | |
| # Three-layer PII detection: Regex (structured) + NER/GLiNER (entities) + LLM (context-aware) | |
| # Verification loop, per-entity explainability, synthetic evaluation, feedback system | |
| # LLM: Groq (dev/speed) or OpenAI (jury/accuracy) via env switch | |
| import re | |
| import os | |
| import json | |
| import time | |
| import uuid | |
| import fitz | |
| import pdfplumber | |
| import pytesseract | |
| from PIL import Image | |
| from typing import List, Tuple, Dict, Optional | |
| from groq import Groq | |
| from openai import OpenAI | |
| from dotenv import load_dotenv | |
| from dataclasses import dataclass, field, asdict | |
| from enum import Enum | |
| load_dotenv() | |
| # ββ SpaCy (optional, graceful fallback for NER) ββββββββββββββββββββββββββββββ | |
| try: | |
| import spacy | |
| nlp = spacy.load("en_core_web_sm") | |
| SPACY_AVAILABLE = True | |
| except Exception: | |
| SPACY_AVAILABLE = False | |
| # ββ GLiNER (primary NER engine) ββββββββββββββββββββββββββββββββββββββββββββββ | |
| GLINER_MODEL = None | |
| GLINER_AVAILABLE = False | |
| def _load_gliner(): | |
| """Lazy-load GLiNER model on first use.""" | |
| global GLINER_MODEL, GLINER_AVAILABLE | |
| if GLINER_MODEL is not None: | |
| return GLINER_MODEL | |
| try: | |
| from gliner import GLiNER | |
| # Try PII-specific model first, fall back to general multi-PII | |
| for model_name in [ | |
| "urchade/gliner_multi_pii-v1", | |
| "knowledgator/gliner-pii-base-v1.0", | |
| "urchade/gliner_multi-v2.1", | |
| ]: | |
| try: | |
| GLINER_MODEL = GLiNER.from_pretrained(model_name) | |
| GLINER_AVAILABLE = True | |
| print(f"[NER] Loaded GLiNER model: {model_name}") | |
| return GLINER_MODEL | |
| except Exception: | |
| continue | |
| print("[NER] No GLiNER model available β falling back to spaCy/skip") | |
| return None | |
| except ImportError: | |
| print("[NER] GLiNER not installed β falling back to spaCy/skip") | |
| return None | |
| # ββ LLM Client Setup βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| LLM_PROVIDER = os.getenv("LLM_PROVIDER", "groq").lower() | |
| GROQ_API_KEY = os.getenv("GROQ_API_KEY", "") | |
| OPENAI_API_KEY = os.getenv("OPENAI_API_KEY", "") | |
| GROQ_MODEL = "llama-3.1-8b-instant" | |
| OPENAI_MODEL = "gpt-4o" | |
| CHUNK_TOKENS = 800 | |
| # ββ Detection Layer Enum βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class DetectionMethod(str, Enum): | |
| REGEX = "regex" | |
| NER = "ner" | |
| LLM = "llm" | |
| VERIFICATION = "verification" | |
| # ββ In-memory stores for metrics/feedback ββββββββββββββββββββββββββββββββββββ | |
| PIPELINE_METRICS = { | |
| "total_processed": 0, | |
| "total_time_ms": 0, | |
| "layer_counts": {"regex": 0, "ner": 0, "llm": 0, "verification": 0}, | |
| "scores": [], | |
| "entity_type_counts": {}, | |
| } | |
| PROCESSED_DOCS: Dict[str, Dict] = {} # doc_id β {entities, masked_text, score, ...} | |
| FEEDBACK_LOG: List[Dict] = [] # accumulated feedback for self-improvement | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # REGEX PATTERNS β UNTOUCHED FROM ORIGINAL (except minor additions) | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # REGEX RESPONSIBILITY: Only rigid, structural, validatable formats. | |
| # High precision (>95%), near-zero false positives. | |
| # | |
| # REMOVED from regex β moved to NER or LLM: | |
| # CVV β was matching every 3-4 digit number (73 false positives) | |
| # EXPIRY β was matching every MM/YY date fragment (22 false positives) | |
| # GENDER β needs context, not pattern ("Male" in a form vs "Male connector") | |
| # RELIGION β needs context ("Christian" as name vs religion) | |
| # AGE β needs context ("age 5" could be product version) | |
| # BLOOD_TYPE β too short, high FP ("A+" as grade vs blood type) | |
| # PENALTY_AMOUNT β needs NDA context to distinguish from regular amounts | |
| # FINANCIAL_AMOUNT β too broad, LLM should decide if amount is sensitive | |
| # DOB/DOB_ISO β bare dates are ambiguous; only keyword-anchored DOB stays | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| PII_REGEX = { | |
| # ββ TIER 1: Near-perfect precision (>99%) β unique structural formats ββ | |
| "EMAIL": r'\b[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\b', | |
| "SSN": r'\b\d{3}-\d{2}-\d{4}\b', | |
| "CREDIT_CARD": r'\b\d{4}[\s\-]\d{4}[\s\-]\d{4}[\s\-]\d{4}\b', | |
| "IP_ADDRESS": r'\b(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\b', | |
| "MAC_ADDRESS": r'\b([0-9A-Fa-f]{2}[:\-]){5}[0-9A-Fa-f]{2}\b', | |
| "URL": r'(https?://[^\s<>"\']+)', | |
| # ββ TIER 2: High precision (>95%) β keyword-anchored patterns ββ | |
| "SESSION_TOKEN": r'(user_session|session_id|auth_token|cookie)=[^\s;]+', | |
| "API_KEY": r'(?i)(api[_\-]?key|secret|token)\s*[=:]\s*[\w\-]{16,}', | |
| "PHONE": r'\b(\+?1?\s?)?(\(?\d{3}\)?[\s.\-]?\d{3}[\s.\-]?\d{4})\b', | |
| "AADHAR": r'\b\d{4}[\s\-]?\d{4}[\s\-]?\d{4}\b', | |
| "PAN": r'\b[A-Z]{5}[0-9]{4}[A-Z]\b', | |
| "SWIFT_BIC": r'\b[A-Z]{4}[A-Z]{2}[A-Z0-9]{2}(?:[A-Z0-9]{3})?\b', | |
| "MED_RECORD": r'\b\d{4}-\d{2}-\d{2}-\d{2}\b', | |
| # ββ TIER 3: Keyword-anchored only (require context word nearby) ββ | |
| "DOB": r'(?i)(?:d\.?o\.?b\.?|date\s*of\s*birth|born|birthday)[\s:]*(\d{1,2}[/\-]\d{1,2}[/\-]\d{2,4})', | |
| "PASSPORT": r'(?i)(?:passport)[\s#:]*([A-Z]\d{7,8})', | |
| "DRIVERS_LICENSE": r"(?i)(?:driver'?s?\s*(?:license|licence|lic))[\s#:]*([A-Z0-9]{5,15})", | |
| "BANK_ACCOUNT": r'(?i)(?:account|acct|a/c)[\s#:]*(\d{8,17})', | |
| "ROUTING_NUMBER": r'(?i)(?:routing|aba)[\s#:]*(\d{9})', | |
| } | |
| REGEX_ORDER = [ | |
| # Tier 1: longest/most unique first to prevent partial overlaps | |
| "SESSION_TOKEN", "API_KEY", | |
| "EMAIL", | |
| "MAC_ADDRESS", | |
| "CREDIT_CARD", "SSN", "AADHAR", "MED_RECORD", | |
| "SWIFT_BIC", | |
| "PHONE", | |
| "DOB", | |
| "IP_ADDRESS", | |
| "PAN", | |
| "PASSPORT", "DRIVERS_LICENSE", "BANK_ACCOUNT", "ROUTING_NUMBER", | |
| "URL", | |
| ] | |
| SWIFT_FALSE_POSITIVES = { | |
| "PERSONAL","INFORMATION","DETAILED","DESCRIPTION","INCLUDES","GUIDANCE", | |
| "ATTACHED","DOCUMENT","REQUEST","SUPPORT","TYPE","DATE","DAILY","FIRST", | |
| "LAST","NAME","GENDER","FAITH","ABILITY","BELIEF","LETTER","SPIRITUAL", | |
| "CHRISTIAN","MEDICAL","HISTORY","PHYSICAL","ADDRESS","NATIONAL","FEDERAL", | |
| } | |
| REASON_MAP = { | |
| "EMAIL": "Direct contact identifier β links to a real individual", | |
| "CREDIT_CARD": "Financial credential β enables fraud or unauthorised transactions", | |
| "PHONE": "Personal contact number β enables direct reach", | |
| "SSN": "US Social Security Number β highest identity theft risk", | |
| "AADHAR": "Indian national ID β highest identity theft risk", | |
| "MED_RECORD": "Medical record number β HIPAA protected health identifier", | |
| "DOB": "Date of birth β core identity verification data", | |
| "MAC_ADDRESS": "Hardware device identifier β enables physical device tracking", | |
| "IP_ADDRESS": "Network address β reveals location and infrastructure", | |
| "SWIFT_BIC": "Bank routing code β financial institution exposure", | |
| "SESSION_TOKEN": "Active session credential β enables account takeover", | |
| "API_KEY": "API credential β enables unauthorised system access", | |
| "PAN": "Indian tax ID β financial identity exposure", | |
| "PASSPORT": "Travel document identifier β identity theft risk", | |
| "DRIVERS_LICENSE": "Government ID β identity verification data", | |
| "BANK_ACCOUNT": "Financial account number β enables unauthorized access", | |
| "ROUTING_NUMBER": "Bank routing number β financial institution identifier", | |
| "URL": "Web address β may reveal internal systems or personal accounts", | |
| } | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # HELPERS β UNTOUCHED FROM ORIGINAL | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def is_overlap(start: int, end: int, spans: List[Tuple[int, int]]) -> bool: | |
| return any(s < end and start < e for s, e in spans) | |
| def _valid_swift(value: str) -> bool: | |
| if value.upper() in SWIFT_FALSE_POSITIVES: | |
| return False | |
| if len(value) not in (8, 11): | |
| return False | |
| if not re.search(r'\d', value): | |
| return False | |
| return True | |
| def chunk_text(text: str, max_tokens: int = CHUNK_TOKENS) -> List[str]: | |
| words = text.split() | |
| overlap = 50 | |
| chunks = [] | |
| i = 0 | |
| while i < len(words): | |
| chunk = words[i: i + max_tokens] | |
| chunks.append(" ".join(chunk)) | |
| i += max_tokens - overlap | |
| return chunks | |
| def remove_regex_spans(text, spans): | |
| masked = text | |
| for start, end in sorted(spans, reverse=True): | |
| masked = masked[:start] + " " * (end - start) + masked[end:] | |
| return masked | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # LAYER 1: REGEX β UNTOUCHED LOGIC | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def regex_detect(text: str) -> Tuple[List[Dict], List[Tuple[int, int]]]: | |
| entities = [] | |
| used_spans = [] | |
| for label in REGEX_ORDER: | |
| pattern = PII_REGEX.get(label) | |
| if not pattern: | |
| continue | |
| for m in re.finditer(pattern, text, re.IGNORECASE): | |
| start, end = m.start(), m.end() | |
| value = m.group().strip() | |
| if label == "SWIFT_BIC" and not _valid_swift(value): | |
| continue | |
| if not value or len(value) < 2: | |
| continue | |
| if is_overlap(start, end, used_spans): | |
| continue | |
| entities.append({ | |
| "entity": value, | |
| "label": label, | |
| "method": "regex", | |
| "confidence": 0.97, | |
| "reason": REASON_MAP.get(label, "Sensitive pattern detected"), | |
| "start": start, | |
| "end": end, | |
| }) | |
| used_spans.append((start, end)) | |
| return entities, used_spans | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # LAYER 2: NER (GLiNER + spaCy fallback) β NEW | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # GLiNER entity labels to scan for | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # NER RESPONSIBILITY: Named entities + context-dependent categories | |
| # that regex can't handle without massive false positives. | |
| # | |
| # ADDED (moved from regex): | |
| # gender, religion, age, blood type, financial amount, date, | |
| # penalty amount, expiry date | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| GLINER_PII_LABELS = [ | |
| # ββ Named entities (NER's core strength) ββ | |
| "person", | |
| "organization", | |
| "physical address", | |
| "street address", | |
| "city", | |
| "state", | |
| "country", | |
| "company name", | |
| "legal entity", | |
| # ββ Moved from regex (need context, not pattern) ββ | |
| "gender", | |
| "religion", | |
| "age", | |
| "blood type", | |
| "financial amount", | |
| "penalty amount", | |
| "date", | |
| "expiry date", | |
| "nationality", | |
| "education", | |
| "medical condition", | |
| # ββ Structural but NER as backup for regex misses ββ | |
| "phone number", | |
| "email address", | |
| "date of birth", | |
| "passport number", | |
| "driver license number", | |
| "bank account number", | |
| ] | |
| GLINER_LABEL_MAP = { | |
| "person": "PERSON_NAME", | |
| "organization": "ORGANIZATION", | |
| "phone number": "PHONE", | |
| "email address": "EMAIL", | |
| "physical address": "ADDRESS", | |
| "street address": "ADDRESS", | |
| "city": "LOCATION", | |
| "state": "LOCATION", | |
| "country": "LOCATION", | |
| "date of birth": "DOB", | |
| "passport number": "PASSPORT", | |
| "driver license number":"DRIVERS_LICENSE", | |
| "bank account number": "BANK_ACCOUNT", | |
| "company name": "ORGANIZATION", | |
| "legal entity": "ORGANIZATION", | |
| "gender": "GENDER", | |
| "religion": "RELIGION", | |
| "age": "AGE", | |
| "blood type": "BLOOD_TYPE", | |
| "financial amount": "FINANCIAL_AMOUNT", | |
| "penalty amount": "PENALTY_AMOUNT", | |
| "date": "DATE", | |
| "expiry date": "EXPIRY", | |
| "nationality": "NATIONALITY", | |
| "education": "EDUCATION", | |
| "medical condition": "MEDICAL_INFO", | |
| } | |
| SPACY_LABEL_MAP = { | |
| "PERSON": "PERSON_NAME", | |
| "ORG": "ORGANIZATION", | |
| "GPE": "LOCATION", | |
| "LOC": "LOCATION", | |
| "DATE": "DATE", | |
| "MONEY": "FINANCIAL_AMOUNT", | |
| "FAC": "LOCATION", | |
| "NORP": "NATIONALITY", | |
| } | |
| NER_REASON_MAP = { | |
| "PERSON_NAME": "Named individual β direct personal identifier", | |
| "ORGANIZATION": "Organisation name β may reveal confidential business relationships", | |
| "LOCATION": "Geographic reference β may narrow identity or reveal jurisdiction", | |
| "ADDRESS": "Physical address β direct location identifier", | |
| "NATIONALITY": "National origin β protected characteristic, discrimination risk", | |
| "EDUCATION": "Educational background β personal demographic that aids re-identification", | |
| "MEDICAL_INFO": "Health/medical information β HIPAA protected, severe privacy risk", | |
| "DATE": "Date reference β may be personally identifying in context", | |
| "GENDER": "Gender identity β protected characteristic under anti-discrimination law", | |
| "RELIGION": "Religious belief β protected characteristic, discrimination risk if exposed", | |
| "AGE": "Age identifier β personal demographic enabling re-identification", | |
| "BLOOD_TYPE": "Blood type β protected health information under HIPAA", | |
| "FINANCIAL_AMOUNT":"Monetary value β may reveal salary, transaction, or contractual terms", | |
| "PENALTY_AMOUNT": "Penalty clause amount β corporate confidential NDA term", | |
| "EXPIRY": "Expiry date β financial credential component", | |
| "DOB": "Date of birth β core identity verification data", | |
| } | |
| def ner_detect(text: str, used_spans: List[Tuple[int, int]]) -> Tuple[List[Dict], List[Tuple[int, int]]]: | |
| """ | |
| Layer 2: NER-based PII detection (OPTIMIZED). | |
| - GLiNER primary, spaCy fallback | |
| - Larger chunks (fewer model calls) | |
| - Tiered labels (core first, extended only if needed) | |
| - Fast overlap check via sorted intervals | |
| """ | |
| entities = [] | |
| new_spans = list(used_spans) | |
| model = _load_gliner() | |
| if model is not None: | |
| entities, new_spans = _gliner_detect(text, model, new_spans) | |
| elif SPACY_AVAILABLE: | |
| entities, new_spans = _spacy_detect(text, new_spans) | |
| else: | |
| print("[NER] No NER model available β skipping Layer 2") | |
| return entities, new_spans | |
| # ββ Label tiers: Core runs ALWAYS, Extended runs only for longer docs βββββββββ | |
| GLINER_LABELS_CORE = [ | |
| "person", | |
| "organization", | |
| "physical address", | |
| "city", | |
| "country", | |
| "date", | |
| "financial amount", | |
| "phone number", | |
| ] | |
| GLINER_LABELS_EXTENDED = [ | |
| "gender", | |
| "religion", | |
| "age", | |
| "blood type", | |
| "penalty amount", | |
| "expiry date", | |
| "nationality", | |
| "education", | |
| "medical condition", | |
| "street address", | |
| "state", | |
| "company name", | |
| "legal entity", | |
| "email address", | |
| "date of birth", | |
| "passport number", | |
| "driver license number", | |
| "bank account number", | |
| ] | |
| def _gliner_detect( | |
| text: str, | |
| model, | |
| used_spans: List[Tuple[int, int]], | |
| ) -> Tuple[List[Dict], List[Tuple[int, int]]]: | |
| """ | |
| Optimized GLiNER detection: | |
| 1. Single chunk up to 8K chars (GLiNER handles ~512 tokens well, that's ~6-8K chars) | |
| 2. One call with core labels; second call with extended labels only if doc > 500 chars | |
| 3. Fast overlap via sorted span set | |
| """ | |
| entities = [] | |
| # ββ Build fast overlap checker from existing spans ββββββββββββββββββββ | |
| span_set = _SpanSet(used_spans) | |
| # ββ Chunk text β use LARGER chunks to reduce model calls βββββββββββββ | |
| # GLiNER's internal tokenizer handles up to ~512 tokens (~2500 words) | |
| # Use 6000 char chunks β most documents fit in 1-2 chunks | |
| chunks = _chunk_for_ner(text, max_chars=6000, overlap=100) | |
| for chunk_offset, chunk_text in chunks: | |
| # ββ TIER 1: Core labels (always run) βββββββββββββββββββββββββ | |
| _run_gliner_batch(model, chunk_text, chunk_offset, | |
| GLINER_LABELS_CORE, 0.35, | |
| entities, span_set) | |
| # ββ TIER 2: Extended labels (only for docs with enough content) ββ | |
| if len(chunk_text) > 500: | |
| _run_gliner_batch(model, chunk_text, chunk_offset, | |
| GLINER_LABELS_EXTENDED, 0.40, # slightly higher threshold | |
| entities, span_set) | |
| print(f"[NER] GLiNER detected {len(entities)} entities") | |
| return entities, span_set.all_spans() | |
| def _run_gliner_batch( | |
| model, | |
| chunk_text: str, | |
| chunk_offset: int, | |
| labels: List[str], | |
| threshold: float, | |
| entities: List[Dict], | |
| span_set: "_SpanSet", | |
| ): | |
| """Run GLiNER predict on one chunk with given labels. Appends to entities list.""" | |
| try: | |
| predictions = model.predict_entities(chunk_text, labels, threshold=threshold) | |
| except Exception as e: | |
| print(f"[NER] GLiNER error: {e}") | |
| return | |
| for pred in predictions: | |
| pred_text = pred.get("text", "").strip() | |
| if not pred_text or len(pred_text) < 2: | |
| continue | |
| raw_label = pred.get("label", "unknown") | |
| score = pred.get("score", 0.5) | |
| label = GLINER_LABEL_MAP.get(raw_label, "OTHER_PII") | |
| rel_start = pred.get("start", 0) | |
| rel_end = pred.get("end", rel_start + len(pred_text)) | |
| abs_start = chunk_offset + rel_start | |
| abs_end = chunk_offset + rel_end | |
| # Fast overlap check | |
| if span_set.overlaps(abs_start, abs_end): | |
| continue | |
| entities.append({ | |
| "entity": pred_text, | |
| "label": label, | |
| "method": "ner", | |
| "confidence": round(score, 3), | |
| "reason": NER_REASON_MAP.get(label, f"NER model identified as '{raw_label}'"), | |
| "start": abs_start, | |
| "end": abs_end, | |
| }) | |
| span_set.add(abs_start, abs_end) | |
| class _SpanSet: | |
| """ | |
| Fast overlap detection using a sorted list of non-overlapping intervals. | |
| O(log n) overlap check instead of O(n) linear scan. | |
| """ | |
| __slots__ = ("_spans",) | |
| def __init__(self, initial_spans: List[Tuple[int, int]] = None): | |
| self._spans = sorted(initial_spans or [], key=lambda s: s[0]) | |
| def overlaps(self, start: int, end: int) -> bool: | |
| # Binary search for the insertion point | |
| lo, hi = 0, len(self._spans) | |
| while lo < hi: | |
| mid = (lo + hi) // 2 | |
| if self._spans[mid][1] <= start: | |
| lo = mid + 1 | |
| else: | |
| hi = mid | |
| # Check the span at lo and lo-1 | |
| if lo < len(self._spans) and self._spans[lo][0] < end: | |
| return True | |
| if lo > 0 and self._spans[lo - 1][1] > start: | |
| return True | |
| return False | |
| def add(self, start: int, end: int): | |
| # Insert in sorted position (bisect) | |
| lo, hi = 0, len(self._spans) | |
| while lo < hi: | |
| mid = (lo + hi) // 2 | |
| if self._spans[mid][0] < start: | |
| lo = mid + 1 | |
| else: | |
| hi = mid | |
| self._spans.insert(lo, (start, end)) | |
| def all_spans(self) -> List[Tuple[int, int]]: | |
| return list(self._spans) | |
| def _spacy_detect( | |
| text: str, | |
| used_spans: List[Tuple[int, int]], | |
| ) -> Tuple[List[Dict], List[Tuple[int, int]]]: | |
| """Fallback NER using spaCy β optimized with truncation and fast overlap.""" | |
| entities = [] | |
| span_set = _SpanSet(used_spans) | |
| # spaCy is fast but don't feed it a novel β cap at 20K chars | |
| doc = nlp(text[:20000]) | |
| for ent in doc.ents: | |
| label = SPACY_LABEL_MAP.get(ent.label_) | |
| if not label: | |
| continue | |
| if len(ent.text.strip()) < 2: | |
| continue | |
| if span_set.overlaps(ent.start_char, ent.end_char): | |
| continue | |
| entities.append({ | |
| "entity": ent.text.strip(), | |
| "label": label, | |
| "method": "ner", | |
| "confidence": 0.80, | |
| "reason": NER_REASON_MAP.get(label, f"NER: {ent.label_}"), | |
| "start": ent.start_char, | |
| "end": ent.end_char, | |
| }) | |
| span_set.add(ent.start_char, ent.end_char) | |
| print(f"[NER] spaCy detected {len(entities)} entities") | |
| return entities, span_set.all_spans() | |
| def _chunk_for_ner( | |
| text: str, max_chars: int = 4000, overlap: int = 200 | |
| ) -> List[Tuple[int, str]]: | |
| """Split text into overlapping chunks. Returns (offset, chunk_text) pairs.""" | |
| if len(text) <= max_chars: | |
| return [(0, text)] | |
| chunks = [] | |
| start = 0 | |
| while start < len(text): | |
| end = min(start + max_chars, len(text)) | |
| # Break at paragraph/sentence boundary | |
| if end < len(text): | |
| for sep in ["\n\n", "\n", ". ", "! ", "? "]: | |
| brk = text.rfind(sep, max(start + max_chars - 300, start), end) | |
| if brk > start + max_chars // 2: | |
| end = brk + len(sep) | |
| break | |
| chunks.append((start, text[start:end])) | |
| start = end - overlap if end < len(text) else end | |
| return chunks | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # LAYER 3: LLM β SINGLE UNIFIED CALL | |
| # Replaces 3 separate calls (detect + reason + verify) with 1 call. | |
| # Before: detect(2-4s) + reason(2-3s) + verify(2-3s) = 6-10s | |
| # After: unified(2-4s) = 2-4s total | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| LLM_UNIFIED_PROMPT = """You are a privacy and confidentiality expert AI performing THREE tasks in ONE pass. | |
| The document has ALREADY been scanned by: | |
| - Regex (caught: emails, SSNs, credit cards, phones, IPs, URLs, API keys) | |
| - NER model (caught: person names, organisations, addresses, locations, dates, gender, religion, ages, financial amounts) | |
| You will receive: | |
| A) The original document text | |
| B) List of entities already detected by regex and NER | |
| You must do ALL THREE tasks and return ONE JSON response: | |
| βββ TASK 1: DETECT MISSED ENTITIES βββ | |
| Find sensitive information that regex and NER missed: | |
| - NDA/contract specifics: party roles, jurisdiction, governing law, arbitration | |
| - Implicit identifiers: unique attribute combos that could identify someone | |
| - Written-out numbers: "two million dollars", "fifteen years" | |
| - Relationship references: "his wife", "her employer" | |
| - Medical context: diagnoses, treatments, prescriptions | |
| - Proprietary info: trade secrets, codenames | |
| - Contextual sensitivity: data sensitive ONLY because of surrounding text | |
| βββ TASK 2: EXPLAIN EVERY ENTITY βββ | |
| For ALL entities (both already-detected AND your new ones), provide a | |
| context-specific reason WHY it is sensitive in THIS document. | |
| BAD: "This is a phone number and phone numbers are PII" | |
| GOOD: "This phone number is listed as the primary contact for the NDA signatory, directly linking it to a named party in a confidential agreement" | |
| βββ TASK 3: VERIFY COMPLETENESS βββ | |
| After considering all entities, rate the sanitization completeness. | |
| βββ RESPONSE FORMAT βββ | |
| Return ONLY this JSON structure: | |
| { | |
| "new_entities": [ | |
| {"entity": "<exact text>", "label": "<SHORT_LABEL>", "reason": "<context reason>", "confidence": 0.0-1.0} | |
| ], | |
| "entity_reasons": [ | |
| {"entity": "<text>", "label": "<LABEL>", "reason": "<context-specific reason>"} | |
| ], | |
| "verification": { | |
| "sanitization_score": 0-100, | |
| "is_clean": true/false, | |
| "issues": ["any remaining concerns"] | |
| } | |
| } | |
| If no new entities found, use empty array for new_entities. | |
| entity_reasons should cover ALL entities β the ones already detected AND your new ones.""" | |
| def _get_client(): | |
| """Get reusable LLM client + model name.""" | |
| if LLM_PROVIDER == "groq": | |
| return Groq(api_key=GROQ_API_KEY), GROQ_MODEL | |
| else: | |
| return OpenAI(api_key=OPENAI_API_KEY), OPENAI_MODEL | |
| def llm_unified_call( | |
| text: str, | |
| existing_entities: List[Dict], | |
| used_spans: List[Tuple[int, int]], | |
| ) -> Tuple[List[Dict], Dict[Tuple[str,str], str], Dict]: | |
| """ | |
| SINGLE LLM call that replaces detect + reason + verify. | |
| Returns: | |
| new_entities β list of newly detected entities (by LLM) | |
| reason_map β {(entity_text, label): reason} for ALL entities | |
| verification β {"sanitization_score": N, "is_clean": bool, "issues": [...]} | |
| """ | |
| if not GROQ_API_KEY and not OPENAI_API_KEY: | |
| print("[LLM] No API key β skipping LLM layer") | |
| return [], {}, {"sanitization_score": 75, "is_clean": True, "issues": ["LLM skipped: no API key"]} | |
| client, model = _get_client() | |
| # Build existing entity summary (compact) | |
| existing_summary = "" | |
| if existing_entities: | |
| seen = set() | |
| lines = [] | |
| for e in existing_entities: | |
| key = f"{e['label']}: {e['entity']}" | |
| if key not in seen: | |
| seen.add(key) | |
| lines.append(f" [{e['method']}] {key}") | |
| existing_summary = "\n".join(lines[:40]) # Cap at 40 to save tokens | |
| # Truncate text for token limits | |
| max_text = 8000 | |
| text_sample = text[:max_text] | |
| user_message = f"""Already detected entities: | |
| {existing_summary if existing_summary else "None yet."} | |
| --- DOCUMENT TEXT --- | |
| {text_sample} | |
| --- END --- | |
| Perform all 3 tasks: detect missed entities, explain every entity, verify completeness. Return JSON only.""" | |
| try: | |
| kwargs = { | |
| "model": model, | |
| "messages": [ | |
| {"role": "system", "content": LLM_UNIFIED_PROMPT}, | |
| {"role": "user", "content": user_message}, | |
| ], | |
| "temperature": 0.1, | |
| "max_tokens": 2000, | |
| } | |
| # OpenAI supports response_format | |
| if LLM_PROVIDER != "groq": | |
| kwargs["response_format"] = {"type": "json_object"} | |
| response = client.chat.completions.create(**kwargs) | |
| raw = response.choices[0].message.content.strip() | |
| raw = raw.replace("```json", "").replace("```", "").strip() | |
| parsed = json.loads(raw) | |
| except Exception as e: | |
| print(f"[LLM UNIFIED ERROR]: {e}") | |
| return [], {}, {"sanitization_score": 75, "is_clean": True, "issues": [str(e)]} | |
| # ββ Parse new entities ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| new_entities = [] | |
| span_set = _SpanSet(used_spans) | |
| seen_vals = {e["entity"].lower() for e in existing_entities} | |
| for item in parsed.get("new_entities", []): | |
| val = (item.get("entity") or "").strip() | |
| if not val or len(val) < 3 or val.lower() in seen_vals: | |
| continue | |
| idx = text.find(val) | |
| if idx == -1: | |
| idx = text.lower().find(val.lower()) | |
| if idx == -1: | |
| continue | |
| end_idx = idx + len(val) | |
| if span_set.overlaps(idx, end_idx): | |
| continue | |
| label = (item.get("label") or "SENSITIVE").upper().replace(" ", "_") | |
| new_entities.append({ | |
| "entity": val, | |
| "label": label, | |
| "method": "llm", | |
| "confidence": float(item.get("confidence", 0.80)), | |
| "reason": item.get("reason", "Detected by contextual AI analysis"), | |
| "start": idx, | |
| "end": end_idx, | |
| }) | |
| span_set.add(idx, end_idx) | |
| seen_vals.add(val.lower()) | |
| # ββ Parse reason map ββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| reason_map = {} | |
| for item in parsed.get("entity_reasons", []): | |
| ent_text = (item.get("entity") or "").strip() | |
| label = (item.get("label") or "").strip() | |
| reason = (item.get("reason") or "").strip() | |
| if ent_text and reason: | |
| reason_map[(ent_text, label)] = reason | |
| # ββ Parse verification ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| v = parsed.get("verification", {}) | |
| verification = { | |
| "sanitization_score": float(v.get("sanitization_score", 80)), | |
| "is_clean": v.get("is_clean", True), | |
| "missed": [], # Already captured in new_entities | |
| "issues": v.get("issues", []), | |
| } | |
| print(f"[LLM] Unified call: {len(new_entities)} new entities, " | |
| f"{len(reason_map)} reasons, score={verification['sanitization_score']}") | |
| return new_entities, reason_map, verification | |
| # ββ Keep old functions as thin wrappers for backward compatibility βββββββββ | |
| def llm_detect(chunk: str) -> List[Dict]: | |
| """Legacy wrapper β use llm_unified_call instead.""" | |
| try: | |
| client, model = _get_client() | |
| messages = [ | |
| {"role": "system", "content": LLM_UNIFIED_PROMPT.split("βββ TASK 1")[1].split("βββ TASK 2")[0]}, | |
| {"role": "user", "content": f"Find sensitive data:\n\n{chunk}"}, | |
| ] | |
| response = client.chat.completions.create( | |
| model=model, messages=messages, temperature=0.1, max_tokens=800, | |
| ) | |
| raw = response.choices[0].message.content.strip() | |
| parsed = json.loads(raw) | |
| if isinstance(parsed, list): return parsed | |
| for val in parsed.values(): | |
| if isinstance(val, list): return val | |
| return [] | |
| except Exception as e: | |
| print(f"[LLM ERROR]: {e}") | |
| return [] | |
| def llm_detect_full(text, used_spans): | |
| """Legacy wrapper β called from evaluate.py when running without unified call.""" | |
| clean_text = remove_regex_spans(text, used_spans) | |
| chunks = chunk_text(clean_text) | |
| all_ents = [] | |
| new_spans = list(used_spans) | |
| seen = set() | |
| for chunk in chunks: | |
| for e in llm_detect(chunk): | |
| val = (e.get("entity") or "").strip() | |
| if not val or val in seen or len(val) < 3: | |
| continue | |
| idx = text.find(val) | |
| if idx == -1: | |
| idx = text.lower().find(val.lower()) | |
| if idx == -1: continue | |
| end_idx = idx + len(val) | |
| if is_overlap(idx, end_idx, new_spans): | |
| continue | |
| seen.add(val) | |
| label = (e.get("label") or "SENSITIVE").upper().replace(" ", "_") | |
| all_ents.append({ | |
| "entity": val, "label": label, "method": "llm", | |
| "confidence": float(e.get("confidence", 0.80)), | |
| "reason": e.get("reason", "Detected by AI"), | |
| "start": idx, "end": end_idx, | |
| }) | |
| new_spans.append((idx, end_idx)) | |
| print(f"[LLM] Detected {len(all_ents)} entities") | |
| return all_ents, new_spans | |
| def llm_generate_reasons(entities, document_text=""): | |
| """Legacy no-op β reasoning is now done inside unified call.""" | |
| return [] | |
| def verify_redaction(masked_text, entities): | |
| """Legacy no-op β verification is now done inside unified call.""" | |
| return {"sanitization_score": 85, "is_clean": True, "missed": [], "issues": []} | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # DOCUMENT CLASSIFICATION β NEW | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def classify_document(text: str) -> str: | |
| """Classify document as nda/transcript/paperwork/unknown using heuristics + LLM.""" | |
| lower = text.lower() | |
| nda_kw = ["non-disclosure", "confidential", "nda", "agreement", | |
| "governing law", "jurisdiction", "liquidated damages", "party"] | |
| transcript_kw = ["speaker", "interviewer", "q:", "a:", "said", "replied", | |
| "conversation", "transcript", "recording"] | |
| nda_score = sum(1 for kw in nda_kw if kw in lower) | |
| transcript_score = sum(1 for kw in transcript_kw if kw in lower) | |
| if nda_score >= 3: | |
| return "nda" | |
| if transcript_score >= 3: | |
| return "transcript" | |
| return "paperwork" | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # MERGE ALL LAYERS β UPGRADED | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def merge_entities( | |
| regex_ents: List[Dict], | |
| ner_ents: List[Dict], | |
| llm_ents: List[Dict], | |
| text: str, | |
| ) -> List[Dict]: | |
| """ | |
| Merge entities from all three layers. | |
| Priority: Regex > NER > LLM for overlapping spans. | |
| All entities get character positions. | |
| """ | |
| seen_values = set() | |
| merged = [] | |
| # Layer priority order | |
| for source_ents in [regex_ents, ner_ents, llm_ents]: | |
| for ent in source_ents: | |
| val = ent["entity"].strip() | |
| val_lower = val.lower() | |
| if val_lower in seen_values: | |
| continue | |
| if len(val) < 2: | |
| continue | |
| # Ensure positions exist | |
| if "start" not in ent or ent["start"] is None: | |
| idx = text.find(val) | |
| if idx == -1: | |
| continue | |
| ent["start"] = idx | |
| ent["end"] = idx + len(val) | |
| merged.append(ent) | |
| seen_values.add(val_lower) | |
| merged.sort(key=lambda x: x.get("start", 0)) | |
| return merged | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # METRICS COMPUTATION β NEW | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def compute_metrics(gt: List[Dict], pred: List[Dict]) -> Dict: | |
| """Compute precision, recall, F1 comparing predictions to ground truth.""" | |
| def norm(e): | |
| return (e["entity"].strip().lower(), e["label"].upper()) | |
| gt_set = set(norm(e) for e in gt) | |
| pred_set = set(norm(e) for e in pred) | |
| TP = len(gt_set & pred_set) | |
| FP = len(pred_set - gt_set) | |
| FN = len(gt_set - pred_set) | |
| precision = TP / (TP + FP) if TP + FP else 0 | |
| recall = TP / (TP + FN) if TP + FN else 0 | |
| f1 = 2 * precision * recall / (precision + recall) if precision + recall else 0 | |
| return { | |
| "precision": round(precision, 4), | |
| "recall": round(recall, 4), | |
| "f1": round(f1, 4), | |
| "tp": TP, "fp": FP, "fn": FN, | |
| } | |
| def compute_layer_metrics(entities: List[Dict]) -> Dict: | |
| """Compute per-layer contribution breakdown.""" | |
| layer_counts = {"regex": 0, "ner": 0, "llm": 0, "verification": 0} | |
| layer_types = {"regex": {}, "ner": {}, "llm": {}, "verification": {}} | |
| layer_conf = {"regex": [], "ner": [], "llm": [], "verification": []} | |
| for e in entities: | |
| method = e.get("method", "unknown") | |
| if method in layer_counts: | |
| layer_counts[method] += 1 | |
| label = e.get("label", "UNKNOWN") | |
| layer_types[method][label] = layer_types[method].get(label, 0) + 1 | |
| layer_conf[method].append(e.get("confidence", 0.0)) | |
| total = sum(layer_counts.values()) or 1 | |
| result = {} | |
| for layer in ["regex", "ner", "llm", "verification"]: | |
| confs = layer_conf[layer] | |
| result[layer] = { | |
| "count": layer_counts[layer], | |
| "percentage": round(layer_counts[layer] / total * 100, 1), | |
| "entity_types": layer_types[layer], | |
| "avg_confidence": round(sum(confs) / max(len(confs), 1), 3), | |
| } | |
| return result | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # SYNTHETIC DATA GENERATOR β NEW (for evaluation without annotations) | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def generate_synthetic_docs(n: int = 20) -> List[Dict]: | |
| """ | |
| Generate synthetic documents with known PII for evaluation. | |
| Returns list of {text, doc_type, ground_truth: [{entity, label}]}. | |
| """ | |
| import random | |
| try: | |
| from faker import Faker | |
| fake = Faker() | |
| Faker.seed(42) | |
| except ImportError: | |
| print("[EVAL] Faker not installed β cannot generate synthetic data") | |
| return [] | |
| random.seed(42) | |
| docs = [] | |
| for i in range(n): | |
| doc_type = random.choice(["nda", "transcript", "paperwork"]) | |
| if doc_type == "nda": | |
| docs.append(_synth_nda(fake, random)) | |
| elif doc_type == "transcript": | |
| docs.append(_synth_transcript(fake, random)) | |
| else: | |
| docs.append(_synth_paperwork(fake, random)) | |
| return docs | |
| def _synth_nda(fake, random) -> Dict: | |
| party_a = fake.company() | |
| party_b = fake.company() | |
| person_a = fake.name() | |
| person_b = fake.name() | |
| state = fake.state() | |
| penalty = f"${random.randint(10,500) * 1000:,}" | |
| eff_date = fake.date_this_year().strftime("%m/%d/%Y") | |
| email_a = fake.email() | |
| phone_a = f"({random.randint(200,999)}) {random.randint(200,999)}-{random.randint(1000,9999)}" | |
| text = f"""NON-DISCLOSURE AGREEMENT | |
| This Non-Disclosure Agreement is entered into as of {eff_date} by and between: | |
| Party A: {party_a}, represented by {person_a} | |
| Email: {email_a} Phone: {phone_a} | |
| Party B: {party_b}, represented by {person_b} | |
| 1. CONFIDENTIAL INFORMATION | |
| All proprietary information shared shall be considered Confidential. | |
| 2. REMEDIES | |
| Breach penalty: liquidated damages of {penalty}. | |
| 3. GOVERNING LAW | |
| Governed by the laws of the State of {state}. | |
| Signed: {person_a}, CEO of {party_a} | |
| Signed: {person_b}, Director of {party_b} | |
| Date: {eff_date} | |
| """ | |
| gt = [] | |
| for val, label in [ | |
| (party_a, "ORGANIZATION"), (party_b, "ORGANIZATION"), | |
| (person_a, "PERSON_NAME"), (person_b, "PERSON_NAME"), | |
| (state, "JURISDICTION"), (penalty, "PENALTY_AMOUNT"), | |
| (email_a, "EMAIL"), (phone_a, "PHONE"), | |
| (eff_date, "DATE"), | |
| ]: | |
| if val in text: | |
| gt.append({"entity": val, "label": label}) | |
| return {"text": text, "doc_type": "nda", "ground_truth": gt} | |
| def _synth_transcript(fake, random) -> Dict: | |
| name_a = fake.name() | |
| name_b = fake.name() | |
| phone = f"({random.randint(200,999)}) {random.randint(200,999)}-{random.randint(1000,9999)}" | |
| email = fake.email() | |
| ssn = f"{random.randint(100,999)}-{random.randint(10,99)}-{random.randint(1000,9999)}" | |
| dob = fake.date_of_birth(minimum_age=20, maximum_age=70).strftime("%m/%d/%Y") | |
| company = fake.company() | |
| city = fake.city() | |
| text = f"""TRANSCRIPT | |
| {name_a}: Hello, my name is {name_a}, calling from {company}. | |
| {name_b}: Hi {name_a.split()[0]}, this is {name_b}. How can I help? | |
| {name_a}: I need to update my phone to {phone}. | |
| {name_b}: Can you confirm your date of birth? | |
| {name_a}: It's {dob}. | |
| {name_b}: And your SSN for verification? | |
| {name_a}: {ssn} | |
| {name_b}: Email on file? | |
| {name_a}: {email} | |
| {name_b}: And you're located in {city}? | |
| {name_a}: That's correct. | |
| """ | |
| gt = [] | |
| for val, label in [ | |
| (name_a, "PERSON_NAME"), (name_b, "PERSON_NAME"), | |
| (phone, "PHONE"), (email, "EMAIL"), (ssn, "SSN"), | |
| (dob, "DOB"), (company, "ORGANIZATION"), (city, "LOCATION"), | |
| ]: | |
| if val in text: | |
| gt.append({"entity": val, "label": label}) | |
| return {"text": text, "doc_type": "transcript", "ground_truth": gt} | |
| def _synth_paperwork(fake, random) -> Dict: | |
| name = fake.name() | |
| ssn = f"{random.randint(100,999)}-{random.randint(10,99)}-{random.randint(1000,9999)}" | |
| dob = fake.date_of_birth(minimum_age=18, maximum_age=80).strftime("%m/%d/%Y") | |
| email = fake.email() | |
| phone = f"({random.randint(200,999)}) {random.randint(200,999)}-{random.randint(1000,9999)}" | |
| addr = fake.street_address() | |
| company = fake.company() | |
| ip = f"{random.randint(1,255)}.{random.randint(0,255)}.{random.randint(0,255)}.{random.randint(1,254)}" | |
| cc = f"{random.randint(4000,4999)} {random.randint(1000,9999)} {random.randint(1000,9999)} {random.randint(1000,9999)}" | |
| gender = random.choice(["Male", "Female"]) | |
| text = f"""PERSONAL INFORMATION FORM | |
| Name: {name} | |
| DOB: {dob} | |
| Gender: {gender} | |
| SSN: {ssn} | |
| Email: {email} | |
| Phone: {phone} | |
| Address: {addr} | |
| Employer: {company} | |
| Credit Card: {cc} | |
| IP Address: {ip} | |
| """ | |
| gt = [] | |
| for val, label in [ | |
| (name, "PERSON_NAME"), (ssn, "SSN"), (dob, "DOB"), | |
| (email, "EMAIL"), (phone, "PHONE"), (addr, "ADDRESS"), | |
| (company, "ORGANIZATION"), (cc, "CREDIT_CARD"), | |
| (ip, "IP_ADDRESS"), | |
| ]: | |
| if val in text: | |
| gt.append({"entity": val, "label": label}) | |
| return {"text": text, "doc_type": "paperwork", "ground_truth": gt} | |
| def run_evaluation(n_docs: int = 20, use_llm: bool = True) -> Dict: | |
| """ | |
| Full evaluation pipeline: | |
| 1. Generate synthetic docs with known ground truth | |
| 2. Run detection pipeline on each | |
| 3. Compute per-entity-type and per-layer P/R/F1 | |
| Returns detailed evaluation report. | |
| """ | |
| docs = generate_synthetic_docs(n_docs) | |
| if not docs: | |
| return {"error": "Could not generate synthetic data"} | |
| all_preds = [] | |
| all_truths = [] | |
| per_doc = [] | |
| total_time = 0 | |
| for i, doc in enumerate(docs): | |
| print(f"[EVAL] Processing doc {i+1}/{n_docs} ({doc['doc_type']})...") | |
| start = time.time() | |
| # Run detection (without masking/pdf β just text) | |
| regex_ents, used_spans = regex_detect(doc["text"]) | |
| ner_ents, used_spans = ner_detect(doc["text"], used_spans) | |
| if use_llm: | |
| llm_ents, used_spans = llm_detect_full(doc["text"], used_spans) | |
| else: | |
| llm_ents = [] | |
| merged = merge_entities(regex_ents, ner_ents, llm_ents, doc["text"]) | |
| elapsed = (time.time() - start) * 1000 | |
| total_time += elapsed | |
| # Per-doc metrics | |
| doc_metrics = compute_metrics(doc["ground_truth"], merged) | |
| doc_metrics["doc_type"] = doc["doc_type"] | |
| doc_metrics["time_ms"] = round(elapsed, 0) | |
| doc_metrics["num_pred"] = len(merged) | |
| doc_metrics["num_truth"] = len(doc["ground_truth"]) | |
| doc_metrics["layers"] = compute_layer_metrics(merged) | |
| per_doc.append(doc_metrics) | |
| all_preds.extend(merged) | |
| all_truths.extend(doc["ground_truth"]) | |
| # Overall metrics | |
| overall = compute_metrics(all_truths, all_preds) | |
| overall_layers = compute_layer_metrics(all_preds) | |
| # Per entity-type breakdown | |
| type_metrics = {} | |
| for etype in set(e["label"] for e in all_truths): | |
| type_gt = [e for e in all_truths if e["label"] == etype] | |
| type_pred = [e for e in all_preds if e.get("label") == etype] | |
| type_metrics[etype] = compute_metrics(type_gt, type_pred) | |
| return { | |
| "overall": overall, | |
| "per_entity_type": type_metrics, | |
| "layer_breakdown": overall_layers, | |
| "per_document": per_doc, | |
| "summary": { | |
| "total_docs": n_docs, | |
| "total_time_ms": round(total_time, 0), | |
| "avg_time_per_doc": round(total_time / n_docs, 0), | |
| "layers_used": ["regex", "ner"] + (["llm"] if use_llm else []), | |
| }, | |
| } | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # MAIN ENTRY POINT β UPGRADED (3 layers + verification + feedback) | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def detect_and_mask( | |
| text: str, | |
| enable_verification: bool = True, | |
| enable_ner: bool = True, | |
| ) -> Tuple[str, List[Dict], Dict]: | |
| """ | |
| Full three-layer hybrid pipeline with unified LLM call. | |
| OPTIMIZED: Detection + Reasoning + Verification in 1 LLM call. | |
| Before: 3 sequential calls = 6-10s | |
| After: 1 unified call = 2-4s | |
| Returns: | |
| masked_text β original text with PII replaced by [LABEL] placeholders | |
| entity_list β list of all detected entities with label, reason, method, confidence | |
| report β dict with layer_breakdown, verification result, doc_type, metrics | |
| """ | |
| start_time = time.time() | |
| doc_id = str(uuid.uuid4())[:8] | |
| # ββ Classify document βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| doc_type = classify_document(text) | |
| print(f"[{doc_id}] Document type: {doc_type}") | |
| # ββ Layer 1: Regex ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| regex_ents, used_spans = regex_detect(text) | |
| print(f"[{doc_id}] Regex: {len(regex_ents)} entities") | |
| # ββ Layer 2: NER ββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| ner_ents = [] | |
| if enable_ner: | |
| ner_ents, used_spans = ner_detect(text, used_spans) | |
| print(f"[{doc_id}] NER: {len(ner_ents)} entities") | |
| # ββ Layer 3: UNIFIED LLM call (detect + reason + verify) ββββββββββββββ | |
| # Merge regex+ner first so LLM sees what's already found | |
| pre_merge = merge_entities(regex_ents, ner_ents, [], text) | |
| llm_ents, reason_map, verification = llm_unified_call( | |
| text, pre_merge, used_spans | |
| ) | |
| print(f"[{doc_id}] LLM: {len(llm_ents)} new entities") | |
| # ββ Final merge (all 3 layers) ββββββββββββββββββββββββββββββββββββββββ | |
| all_entities = merge_entities(regex_ents, ner_ents, llm_ents, text) | |
| print(f"[{doc_id}] Total after merge: {len(all_entities)} entities") | |
| # ββ Apply LLM reasons to all entities βββββββββββββββββββββββββββββββββ | |
| clean_entities = [] | |
| for e in all_entities: | |
| # Try exact match first, then fuzzy key match | |
| llm_reason = reason_map.get((e["entity"], e["label"])) | |
| if not llm_reason: | |
| # Try matching just by entity text (label might differ slightly) | |
| for (ent_text, _), reason in reason_map.items(): | |
| if ent_text.lower() == e["entity"].lower(): | |
| llm_reason = reason | |
| break | |
| clean_entities.append({ | |
| "entity": e["entity"], | |
| "label": e["label"], | |
| "method": e["method"], | |
| "confidence": e.get("confidence", 0.9), | |
| "reason": llm_reason if llm_reason else e.get("reason", "Sensitive data"), | |
| }) | |
| # ββ Build masked text βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| masked = text | |
| positioned = [] | |
| for e in all_entities: | |
| idx = masked.find(e["entity"]) | |
| if idx != -1: | |
| positioned.append((idx, idx + len(e["entity"]), e)) | |
| positioned.sort(key=lambda x: x[0], reverse=True) | |
| for start, end, e in positioned: | |
| masked = masked[:start] + f"[{e['label']}]" + masked[end:] | |
| # ββ Handle verification misses (from unified call) ββββββββββββββββββββ | |
| if verification.get("missed"): | |
| for missed in verification["missed"]: | |
| val = missed.get("entity", "").strip() | |
| if not val or len(val) < 2: | |
| continue | |
| if val.lower() in {e["entity"].lower() for e in all_entities}: | |
| continue | |
| idx = text.find(val) | |
| if idx == -1: | |
| continue | |
| new_ent = { | |
| "entity": val, | |
| "label": missed.get("label", "SENSITIVE"), | |
| "method": "verification", | |
| "confidence": 0.85, | |
| "reason": missed.get("reason", "Caught by verification"), | |
| } | |
| clean_entities.append(new_ent) | |
| # Re-mask | |
| m_idx = masked.find(val) | |
| if m_idx != -1: | |
| masked = masked[:m_idx] + f"[{new_ent['label']}]" + masked[m_idx + len(val):] | |
| # ββ Compute layer metrics βββββββββββββββββββββββββββββββββββββββββββββ | |
| layer_breakdown = compute_layer_metrics(clean_entities) | |
| elapsed_ms = (time.time() - start_time) * 1000 | |
| # ββ Build report ββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| report = { | |
| "document_id": doc_id, | |
| "document_type": doc_type, | |
| "total_entities": len(clean_entities), | |
| "extraction_method": "text", | |
| "layer_breakdown": layer_breakdown, | |
| "verification": verification, | |
| "processing_time_ms": round(elapsed_ms, 0), | |
| "sanitization_score": verification.get("sanitization_score", 85.0), | |
| } | |
| # ββ Update global metrics βββββββββββββββββββββββββββββββββββββββββββββ | |
| _update_global_metrics(clean_entities, elapsed_ms, verification) | |
| # ββ Store for feedback retrieval ββββββββββββββββββββββββββββββββββββββ | |
| PROCESSED_DOCS[doc_id] = { | |
| "entities": clean_entities, | |
| "masked_text": masked, | |
| "report": report, | |
| } | |
| print(f"[{doc_id}] Pipeline complete: {len(clean_entities)} entities in {elapsed_ms:.0f}ms") | |
| return masked, clean_entities, report | |
| return masked, clean_entities, report | |
| def _update_global_metrics(entities, elapsed_ms, verification): | |
| """Update running pipeline metrics.""" | |
| PIPELINE_METRICS["total_processed"] += 1 | |
| PIPELINE_METRICS["total_time_ms"] += elapsed_ms | |
| if verification: | |
| PIPELINE_METRICS["scores"].append(verification.get("sanitization_score", 80)) | |
| for e in entities: | |
| method = e.get("method", "unknown") | |
| if method in PIPELINE_METRICS["layer_counts"]: | |
| PIPELINE_METRICS["layer_counts"][method] += 1 | |
| label = e.get("label", "UNKNOWN") | |
| PIPELINE_METRICS["entity_type_counts"][label] = \ | |
| PIPELINE_METRICS["entity_type_counts"].get(label, 0) + 1 | |
| def get_pipeline_metrics() -> Dict: | |
| """Get aggregated pipeline metrics.""" | |
| m = PIPELINE_METRICS | |
| total = max(m["total_processed"], 1) | |
| total_ents = sum(m["layer_counts"].values()) or 1 | |
| return { | |
| "total_documents_processed": m["total_processed"], | |
| "avg_sanitization_score": round( | |
| sum(m["scores"]) / max(len(m["scores"]), 1), 1 | |
| ), | |
| "avg_processing_time_ms": round(m["total_time_ms"] / total, 0), | |
| "layer_contribution": { | |
| layer: f"{count / total_ents * 100:.1f}%" | |
| for layer, count in m["layer_counts"].items() | |
| }, | |
| "entity_type_distribution": m["entity_type_counts"], | |
| "verification_pass_rate": round( | |
| sum(1 for s in m["scores"] if s >= 90) / max(len(m["scores"]), 1) * 100, 1 | |
| ), | |
| } | |
| def get_feedback(doc_id: str) -> Dict: | |
| """Get per-entity feedback for a processed document.""" | |
| if doc_id not in PROCESSED_DOCS: | |
| return {"error": "Document not found"} | |
| doc = PROCESSED_DOCS[doc_id] | |
| return { | |
| "document_id": doc_id, | |
| "total_entities": len(doc["entities"]), | |
| "feedback": doc["entities"], | |
| "report": doc["report"], | |
| } | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # PDF HELPERS β COMPLETELY UNTOUCHED FROM ORIGINAL | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def extract_text_from_pdf(pdf_path: str) -> str: | |
| """Primary: pdfplumber. Fallback: Tesseract OCR.""" | |
| text = "" | |
| try: | |
| with pdfplumber.open(pdf_path) as pdf: | |
| text = "\n".join(p.extract_text() or "" for p in pdf.pages) | |
| except Exception: | |
| pass | |
| if len(text.strip()) < 30: | |
| try: | |
| doc = fitz.open(pdf_path) | |
| parts = [] | |
| for page in doc: | |
| pix = page.get_pixmap(dpi=300) | |
| img = Image.frombytes("RGB", [pix.width, pix.height], pix.samples) | |
| parts.append(pytesseract.image_to_string(img)) | |
| text = "\n".join(parts) | |
| except Exception: | |
| pass | |
| return text.strip() | |
| def redact_pdf(input_path: str, output_path: str, entities: List[Dict]) -> None: | |
| """ | |
| White-out each entity in the original PDF and | |
| write its [LABEL] placeholder in dark red at the same position. | |
| """ | |
| doc = fitz.open(input_path) | |
| for page in doc: | |
| span_map = [] | |
| for block in page.get_text("dict")["blocks"]: | |
| if block["type"] != 0: | |
| continue | |
| for line in block["lines"]: | |
| for span in line["spans"]: | |
| span_map.append((fitz.Rect(span["bbox"]), span["size"])) | |
| def font_at(rect: fitz.Rect) -> float: | |
| for sr, sz in span_map: | |
| if sr.intersects(rect): | |
| return sz | |
| return 9.0 | |
| for ent in entities: | |
| value = (ent.get("entity") or "").strip() | |
| if not value or len(value) < 2: | |
| continue | |
| for rect in page.search_for(value): | |
| fs = min(max(font_at(rect) - 1, 6), 9) | |
| page.draw_rect(rect, color=(1,1,1), fill=(1,1,1)) | |
| page.insert_text( | |
| (rect.x0, rect.y1 - 1), | |
| f"[{ent['label']}]", | |
| fontsize=fs, | |
| color=(0.85, 0, 0), | |
| ) | |
| doc.save(output_path) | |
| doc.close() |