Spaces:
Runtime error
Runtime error
| """ | |
| PIIRedactor — wraps Microsoft Presidio to redact PII from complaint text locally. | |
| Runs in-process before any text is sent to the Anthropic API or DL models. | |
| Detected spans are replaced with <ENTITY_TYPE> placeholders. | |
| Entity types detected: | |
| PERSON, PHONE_NUMBER, EMAIL_ADDRESS, CREDIT_CARD, IBAN_CODE, | |
| US_BANK_NUMBER, IN_AADHAAR, IN_PAN, IN_VEHICLE_REGISTRATION | |
| Failure mode: fail-open — on any error the original text is returned unchanged | |
| so the pipeline is never blocked. | |
| Output dataclass: | |
| RedactionResult(redacted_text: str, pii_types_found: list[str], pii_redacted: bool) | |
| """ | |
| from __future__ import annotations | |
| import logging | |
| import re | |
| from dataclasses import dataclass, field | |
| from typing import Optional | |
| from presidio_analyzer import AnalyzerEngine, PatternRecognizer, Pattern | |
| from presidio_analyzer.nlp_engine import NlpEngineProvider | |
| from presidio_anonymizer import AnonymizerEngine | |
| from presidio_anonymizer.entities import OperatorConfig | |
| logger = logging.getLogger(__name__) | |
| # --------------------------------------------------------------------------- | |
| # Entity types to detect — order matters for operator dict construction only. | |
| # --------------------------------------------------------------------------- | |
| _ENTITY_TYPES: list[str] = [ | |
| "PERSON", | |
| "PHONE_NUMBER", | |
| "EMAIL_ADDRESS", | |
| "CREDIT_CARD", | |
| "IBAN_CODE", | |
| "US_BANK_NUMBER", | |
| "IN_AADHAAR", | |
| "IN_PAN", | |
| "IN_VEHICLE_REGISTRATION", | |
| ] | |
| # --------------------------------------------------------------------------- | |
| # Complaint reference ID deny-list recognizer. | |
| # | |
| # INTENTIONALLY excluded from _ENTITY_TYPES — COMPLAINT_REF_ID spans are | |
| # registered with high confidence (0.99) so Presidio's conflict-resolution | |
| # logic reserves those character positions before US_BANK_NUMBER can claim | |
| # them. Because COMPLAINT_REF_ID is not in _ENTITY_TYPES, the anonymizer | |
| # never replaces these spans; they pass through verbatim. | |
| # --------------------------------------------------------------------------- | |
| def _complaint_id_deny_recognizer() -> PatternRecognizer: | |
| """Protect REF-/TRN-/TXN-/OD-/BK-/CLM-/CASE-/CMP-/LN-/POL-/CR- IDs from redaction.""" | |
| return PatternRecognizer( | |
| supported_entity="COMPLAINT_REF_ID", | |
| patterns=[ | |
| Pattern( | |
| name="complaint_ref_id", | |
| regex=( | |
| r"\b(?:REF|TRN|TXN|OD|BK|CLM|CASE|CMP|LN|POL|CR)" | |
| r"[-]?[A-Z0-9][-A-Z0-9]*\b" | |
| ), | |
| score=0.99, | |
| ) | |
| ], | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # Custom recognizers for Indian-specific identifiers not in Presidio's default | |
| # set. Patterns sourced from official format specifications. | |
| # --------------------------------------------------------------------------- | |
| def _aadhaar_recognizer() -> PatternRecognizer: | |
| """12-digit Aadhaar UID; first digit 2-9; optional spaces or hyphens.""" | |
| return PatternRecognizer( | |
| supported_entity="IN_AADHAAR", | |
| patterns=[ | |
| Pattern( | |
| name="aadhaar_spaced", | |
| regex=r"\b[2-9]\d{3}[ -]?\d{4}[ -]?\d{4}\b", | |
| score=0.85, | |
| ) | |
| ], | |
| context=["aadhaar", "uid", "unique identification"], | |
| ) | |
| def _pan_recognizer() -> PatternRecognizer: | |
| """PAN card: 5 uppercase letters · 4 digits · 1 uppercase letter (e.g. ABCDE1234F). | |
| global_regex_flags omits re.IGNORECASE so only uppercase PANs match — | |
| lowercase input is not a valid PAN format per official spec. | |
| """ | |
| return PatternRecognizer( | |
| supported_entity="IN_PAN", | |
| patterns=[ | |
| Pattern( | |
| name="pan_card", | |
| regex=r"\b[A-Z]{5}[0-9]{4}[A-Z]\b", | |
| score=0.85, | |
| ) | |
| ], | |
| context=["pan", "permanent account number", "income tax"], | |
| global_regex_flags=re.DOTALL | re.MULTILINE, | |
| ) | |
| def _vehicle_registration_recognizer() -> PatternRecognizer: | |
| """Indian vehicle registration number (e.g. MH01AB1234, DL3CAF0001).""" | |
| return PatternRecognizer( | |
| supported_entity="IN_VEHICLE_REGISTRATION", | |
| patterns=[ | |
| Pattern( | |
| name="vehicle_reg", | |
| # State code (2 letters) · district (1-2 digits) · series (1-3 letters) · number (4 digits) | |
| regex=r"\b[A-Z]{2}[0-9]{1,2}[A-Z]{1,3}[0-9]{4}\b", | |
| score=0.75, | |
| ) | |
| ], | |
| context=["vehicle", "registration", "number plate", "reg no"], | |
| ) | |
| def _self_introduced_name_recognizer() -> PatternRecognizer: | |
| """Catch first names that spaCy mislabels when they follow a self-introduction. | |
| spaCy en_core_web_lg frequently tags Indian first names (e.g. "Vikas") as | |
| ORG rather than PERSON, so they slip past the default PERSON recognizer and | |
| reach the API un-redacted. These high-precision context patterns force a | |
| PERSON span for a Capitalised token immediately following a self-identifying | |
| phrase. The leading word must be capitalised, so lowercase continuations such | |
| as "I am writing" or "this is regarding" are not matched. | |
| Emits the PERSON entity so the existing <PERSON> operator handles replacement. | |
| """ | |
| name = r"[A-Z][a-z]+" | |
| phrases = [ | |
| ("name_after_my_name_is", "my name is", 0.95), | |
| ("name_after_name_is", "name is", 0.90), | |
| ("name_after_myself", "myself", 0.85), | |
| ("name_after_i_am", "i am", 0.85), | |
| ("name_after_im", "i'm", 0.85), | |
| ("name_after_this_is", "this is", 0.80), | |
| ] | |
| return PatternRecognizer( | |
| supported_entity="PERSON", | |
| patterns=[ | |
| # Fixed-width scoped-case lookbehind so only the name token is the span. | |
| Pattern(name=pname, regex=rf"(?<=(?i:{phrase}) ){name}", score=score) | |
| for pname, phrase, score in phrases | |
| ], | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # RedactionResult | |
| # --------------------------------------------------------------------------- | |
| class RedactionSpan: | |
| """A single piece of PII that was detected and replaced. | |
| *original* is the raw substring from the user's text; it is returned ONLY to | |
| the same user's browser (it never leaves for any third-party API) so the UI | |
| can show a side-by-side "what we protected" reveal. | |
| """ | |
| entity_type: str | |
| original: str | |
| placeholder: str | |
| start: int | |
| end: int | |
| class RedactionResult: | |
| """Result of a single redaction pass.""" | |
| redacted_text: str | |
| pii_types_found: list[str] = field(default_factory=list) | |
| pii_redacted: bool = False | |
| spans: list[RedactionSpan] = field(default_factory=list) | |
| # --------------------------------------------------------------------------- | |
| # PIIRedactor | |
| # --------------------------------------------------------------------------- | |
| class PIIRedactor: | |
| """ | |
| Local PII redactor using presidio-analyzer + presidio-anonymizer. | |
| Loads spaCy en_core_web_lg on construction (~750 MB, one-time cost). | |
| Call init_redactor() at server startup so the model load happens at a | |
| known, controlled moment rather than on the first request. | |
| """ | |
| def __init__(self) -> None: | |
| nlp_config = { | |
| "nlp_engine_name": "spacy", | |
| "models": [{"lang_code": "en", "model_name": "en_core_web_lg"}], | |
| } | |
| nlp_engine = NlpEngineProvider(nlp_configuration=nlp_config).create_engine() | |
| self._analyzer = AnalyzerEngine( | |
| nlp_engine=nlp_engine, | |
| supported_languages=["en"], | |
| ) | |
| # Register custom recognizers: Indian-specific PII + complaint ID deny-list | |
| for recognizer in ( | |
| _aadhaar_recognizer(), | |
| _pan_recognizer(), | |
| _vehicle_registration_recognizer(), | |
| _self_introduced_name_recognizer(), | |
| _complaint_id_deny_recognizer(), | |
| ): | |
| self._analyzer.registry.add_recognizer(recognizer) | |
| self._anonymizer = AnonymizerEngine() | |
| # Build a static operator dict so every entity type maps to its own | |
| # <ENTITY_TYPE> placeholder string. COMPLAINT_REF_ID uses "keep" so | |
| # its spans are preserved verbatim while still blocking US_BANK_NUMBER | |
| # from claiming the same character positions. | |
| self._operators: dict[str, OperatorConfig] = { | |
| entity: OperatorConfig("replace", {"new_value": f"<{entity}>"}) | |
| for entity in _ENTITY_TYPES | |
| } | |
| self._operators["COMPLAINT_REF_ID"] = OperatorConfig("keep") | |
| def redact(self, text: str) -> RedactionResult: | |
| """ | |
| Redact PII from *text* and return a RedactionResult. | |
| On any exception the original text is returned unchanged (fail-open). | |
| """ | |
| if not text or not text.strip(): | |
| return RedactionResult(redacted_text=text) | |
| try: | |
| analyzer_results = self._analyzer.analyze( | |
| text=text, | |
| entities=_ENTITY_TYPES + ["COMPLAINT_REF_ID"], | |
| language="en", | |
| ) | |
| if not analyzer_results: | |
| return RedactionResult(redacted_text=text) | |
| anonymized = self._anonymizer.anonymize( | |
| text=text, | |
| analyzer_results=analyzer_results, | |
| operators=self._operators, | |
| ) | |
| # COMPLAINT_REF_ID spans use the "keep" operator: they are preserved | |
| # verbatim AND, being higher-confidence (0.99), they win Presidio's | |
| # conflict resolution over any lower-score entity that overlaps them | |
| # (e.g. an 8-digit run inside "REF-20260501-001" mis-detected as | |
| # US_BANK_NUMBER). Such overlapping entities are therefore NEVER | |
| # replaced in the output. We must drop them from the reported spans — | |
| # otherwise the UI claims a redaction that did not happen and the | |
| # audit leak-check sees the "redacted" value still present in the | |
| # transmitted text and falsely flags a leak. | |
| ref_spans = [ | |
| (r.start, r.end) | |
| for r in analyzer_results | |
| if r.entity_type == "COMPLAINT_REF_ID" | |
| ] | |
| def _overlaps_kept_ref(r) -> bool: | |
| return any(r.start < end and start < r.end for start, end in ref_spans) | |
| # Real, actually-redacted results: not the internal sentinel, and not | |
| # swallowed by a kept COMPLAINT_REF_ID span. | |
| real_results = [ | |
| r | |
| for r in analyzer_results | |
| if r.entity_type != "COMPLAINT_REF_ID" and not _overlaps_kept_ref(r) | |
| ] | |
| pii_types_found = sorted({r.entity_type for r in real_results}) | |
| # Build the per-span reveal list (original substring + placeholder), | |
| # sorted by position so the UI can render them in reading order. | |
| spans = [ | |
| RedactionSpan( | |
| entity_type=r.entity_type, | |
| original=text[r.start:r.end], | |
| placeholder=f"<{r.entity_type}>", | |
| start=r.start, | |
| end=r.end, | |
| ) | |
| for r in sorted(real_results, key=lambda r: r.start) | |
| ] | |
| return RedactionResult( | |
| redacted_text=anonymized.text, | |
| pii_types_found=pii_types_found, | |
| # True only when real PII was actually replaced — a message whose | |
| # only matches were kept ref-IDs (or ref-ID-overlapping noise) is | |
| # an unmodified passthrough, not a redaction. | |
| pii_redacted=bool(real_results), | |
| spans=spans, | |
| ) | |
| except Exception: | |
| logger.warning( | |
| "PIIRedactor.redact failed — returning original text unchanged", | |
| exc_info=True, | |
| ) | |
| return RedactionResult(redacted_text=text) | |
| # --------------------------------------------------------------------------- | |
| # Singleton accessors | |
| # --------------------------------------------------------------------------- | |
| _redactor: Optional[PIIRedactor] = None | |
| def init_redactor() -> PIIRedactor: | |
| """ | |
| Initialise (or reinitialise) the module-level PIIRedactor singleton. | |
| Call this once at server startup so the spaCy model is loaded eagerly | |
| rather than on the first request. | |
| """ | |
| global _redactor | |
| logger.info("Loading PIIRedactor (spaCy en_core_web_lg)…") | |
| _redactor = PIIRedactor() | |
| logger.info("PIIRedactor ready.") | |
| return _redactor | |
| def get_redactor() -> PIIRedactor: | |
| """ | |
| Return the PIIRedactor singleton, initialising it lazily if needed. | |
| Prefer calling init_redactor() explicitly at startup for predictable | |
| load-time behaviour. | |
| """ | |
| global _redactor | |
| if _redactor is None: | |
| init_redactor() | |
| return _redactor | |