Spaces:
Sleeping
Sleeping
| """Server-side NER scrubbing service. | |
| Runs Presidio + spaCy to catch names and locations | |
| that client-side regex scrubbing can't detect. Deployed on HuggingFace | |
| Spaces (free tier) as a Docker SDK Space. | |
| The client already handles structured PII (emails, phones, SSNs, IPs, | |
| file paths, API keys). This service is a defense-in-depth layer that | |
| catches unstructured PII (names mentioned in conversation text). | |
| """ | |
| import logging | |
| import os | |
| import re | |
| import unicodedata | |
| from fastapi import FastAPI, Header, HTTPException, Request | |
| from fastapi.responses import JSONResponse | |
| from presidio_analyzer import AnalyzerEngine | |
| from presidio_analyzer.nlp_engine import NlpEngineProvider | |
| from presidio_anonymizer import AnonymizerEngine | |
| from presidio_anonymizer.entities import OperatorConfig | |
| from pydantic import BaseModel | |
| logger = logging.getLogger(__name__) | |
| # --- Unicode normalization (adversarial PII evasion defense) --- | |
| # Homoglyphs (Cyrillic а vs Latin a), zero-width characters, and bidi | |
| # overrides can bypass NER. NFKC normalization + control character | |
| # stripping defeats these attacks. Must run before any NER analysis. | |
| _INVISIBLE_RE = re.compile( | |
| "[" | |
| "\u061c" # arabic letter mark (bidi control) | |
| "\u200b" # zero-width space | |
| "\u200c" # zero-width non-joiner | |
| "\u200d" # zero-width joiner | |
| "\u200e" # left-to-right mark | |
| "\u200f" # right-to-left mark | |
| "\u202a" # left-to-right embedding | |
| "\u202b" # right-to-left embedding | |
| "\u202c" # pop directional formatting | |
| "\u202d" # left-to-right override | |
| "\u202e" # right-to-left override | |
| "\u2060" # word joiner | |
| "\u2061" # function application | |
| "\u2062" # invisible times | |
| "\u2063" # invisible separator | |
| "\u2064" # invisible plus | |
| "\u2066" # left-to-right isolate | |
| "\u2067" # right-to-left isolate | |
| "\u2068" # first strong isolate | |
| "\u2069" # pop directional isolate | |
| "\ufeff" # byte order mark / zero-width no-break space | |
| "\ufff9" # interlinear annotation anchor | |
| "\ufffa" # interlinear annotation separator | |
| "\ufffb" # interlinear annotation terminator | |
| "]+", | |
| ) | |
| def _normalize_text(text: str) -> str: | |
| """Normalize text to defeat adversarial PII evasion. | |
| NFKC normalization maps homoglyphs to canonical Latin forms. | |
| Invisible character stripping removes zero-width spaces, joiners, | |
| and bidi overrides that break NER tokenization. | |
| """ | |
| text = unicodedata.normalize("NFKC", text) | |
| text = _INVISIBLE_RE.sub("", text) | |
| return text | |
| app = FastAPI(title="Common Parlance NER Service", docs_url=None, redoc_url=None) | |
| # Initialize once at startup (not per-request). | |
| # SPACY_MODEL env var allows using en_core_web_lg locally for better | |
| # accuracy while keeping en_core_web_sm on HF Spaces (free tier RAM). | |
| SPACY_MODEL = os.environ.get("SPACY_MODEL", "en_core_web_sm") | |
| nlp_provider = NlpEngineProvider( | |
| nlp_configuration={ | |
| "nlp_engine_name": "spacy", | |
| "models": [{"lang_code": "en", "model_name": SPACY_MODEL}], | |
| } | |
| ) | |
| analyzer = AnalyzerEngine(nlp_engine=nlp_provider.create_engine()) | |
| anonymizer = AnonymizerEngine() | |
| logger.info("Loaded spaCy model: %s", SPACY_MODEL) | |
| API_KEY = os.environ.get("API_KEY", "") | |
| if not API_KEY: | |
| logger.warning( | |
| "API_KEY not set — /scrub will REJECT all requests (fail closed). " | |
| "Set API_KEY to enable the endpoint." | |
| ) | |
| # Hard cap on the request body. Enforced here (a middleware that checks | |
| # Content-Length up front) rather than via a uvicorn flag — uvicorn has no such | |
| # option, so the previously-documented limit did not exist. | |
| MAX_REQUEST_BYTES = 2 * 1024 * 1024 # 2MB | |
| async def limit_body_size(request: Request, call_next): | |
| # Fast path: reject a declared Content-Length over the cap up front. | |
| cl = request.headers.get("content-length") | |
| if cl is not None and cl.isdigit() and int(cl) > MAX_REQUEST_BYTES: | |
| return JSONResponse(status_code=413, content={"detail": "Request too large"}) | |
| # Robust path: a chunked or absent/non-numeric Content-Length would bypass | |
| # the header check, so also bound the body as we read it. Buffer up to the | |
| # cap and cache it on the request so the route can still parse it (the | |
| # stream is consumed here); reject the moment the cap is exceeded. | |
| body = b"" | |
| async for chunk in request.stream(): | |
| body += chunk | |
| if len(body) > MAX_REQUEST_BYTES: | |
| return JSONResponse( | |
| status_code=413, content={"detail": "Request too large"} | |
| ) | |
| request._body = body | |
| return await call_next(request) | |
| # Only detect entity types that regex can't handle. | |
| # Emails, phones, IPs, etc. are already scrubbed client-side. | |
| # ORGANIZATION is intentionally excluded: spaCy ORG NER is noisy | |
| # (misclassifies products/tools), org names are high-utility and low-risk in | |
| # coding data, and sensitive internal names are caught at review. Keep in | |
| # sync with scrub.py's analyzed entity set. | |
| NER_ENTITIES = ["PERSON", "LOCATION"] | |
| # Programming terms spaCy NER misclassifies as PERSON/LOCATION ("Django", "Go", | |
| # "Jenkins"). Presidio's spaCy recognizer stamps every NER hit with a fixed | |
| # score (0.85), so score thresholds can't separate a library name from a real | |
| # person — only an allow_list can. Without this the server re-redacts code terms | |
| # the client deliberately kept (scrub.py passes the same allow_list), so a | |
| # reviewed "Django" silently becomes "[NAME_1]" in the published trace. | |
| # DUPLICATED from scrub.py:_PROGRAMMING_ALLOW_LIST as a stopgap until the shared | |
| # cp-scrub engine (Go-To-Market roadmap Phase 1). Keep the two lists in sync. | |
| _PROGRAMMING_ALLOW_LIST = [ | |
| # languages | |
| "Python", | |
| "Java", | |
| "Ruby", | |
| "Rust", | |
| "Swift", | |
| "Kotlin", | |
| "Scala", | |
| "Julia", | |
| "Perl", | |
| "Lua", | |
| "Dart", | |
| "Elixir", | |
| "Fortran", | |
| "Pascal", | |
| "Haskell", | |
| "Erlang", | |
| "Clojure", | |
| "Groovy", | |
| "C", | |
| "R", | |
| "Go", | |
| # tools / platforms | |
| "Git", | |
| "Docker", | |
| "Kubernetes", | |
| "Terraform", | |
| "Ansible", | |
| "Jenkins", | |
| "Gradle", | |
| "Maven", | |
| "Cargo", | |
| "Helm", | |
| "Vagrant", | |
| "Nginx", | |
| "Apache", | |
| "Redis", | |
| "Kafka", | |
| "Celery", | |
| "Pandas", | |
| "NumPy", | |
| "Flask", | |
| "Django", | |
| "FastAPI", | |
| "Rails", | |
| "Spring", | |
| "Node", | |
| "Deno", | |
| "Bun", | |
| # CS terms | |
| "Boolean", | |
| "Lambda", | |
| "Mutex", | |
| "Regex", | |
| # AI/ML | |
| "Transformer", | |
| "BERT", | |
| "GPT", | |
| "LLM", | |
| "CUDA", | |
| "PyTorch", | |
| "TensorFlow", | |
| "Keras", | |
| "Llama", | |
| "Claude", | |
| "Gemini", | |
| # frameworks / libraries | |
| "React", | |
| "Angular", | |
| "Vue", | |
| "Svelte", | |
| "jQuery", | |
| "Bootstrap", | |
| "Tailwind", | |
| "Express", | |
| "Nest", | |
| "Next", | |
| "Nuxt", | |
| "Remix", | |
| # math / algorithms | |
| "Fibonacci", | |
| "Dijkstra", | |
| "Euler", | |
| ] | |
| # Presidio's allow_list is case-sensitive, so include original + lowercase forms. | |
| _ALLOW_LIST = sorted( | |
| {t for term in _PROGRAMMING_ALLOW_LIST for t in (term, term.lower())} | |
| ) | |
| # Per-entity score thresholds, mirroring scrub.py's _filter_results. spaCy NER | |
| # is fixed at 0.85, so these are mostly a guard for pattern/context-scored | |
| # entities; kept for parity with the client's documented thresholds. | |
| _ENTITY_SCORE_THRESHOLDS = {"PERSON": 0.85, "LOCATION": 0.70} | |
| _DEFAULT_SCORE_THRESHOLD = 0.5 | |
| def build_operators(results: list, text: str) -> dict: | |
| """Operators for a single turn. PERSON entities get consistent numbered | |
| [NAME_1]/[NAME_2] placeholders (reading order) to preserve coreference, | |
| matching scrub.py and the documented design; LOCATION/ORG stay flat. | |
| """ | |
| mapping: dict[str, str] = {} | |
| persons = sorted( | |
| (r for r in results if r.entity_type == "PERSON"), | |
| key=lambda r: r.start, | |
| ) | |
| for r in persons: | |
| key = " ".join(text[r.start : r.end].split()).casefold() | |
| if key not in mapping: | |
| mapping[key] = f"[NAME_{len(mapping) + 1}]" | |
| def _replace(value: str) -> str: | |
| key = " ".join(value.split()).casefold() | |
| placeholder = mapping.get(key) | |
| if placeholder is None: | |
| placeholder = f"[NAME_{len(mapping) + 1}]" | |
| mapping[key] = placeholder | |
| return placeholder | |
| return { | |
| "PERSON": OperatorConfig("custom", {"lambda": _replace}), | |
| "LOCATION": OperatorConfig("replace", {"new_value": "[LOCATION]"}), | |
| } | |
| def _filter_by_threshold(results: list) -> list: | |
| """Drop entities scoring below their per-entity threshold. | |
| Parity with scrub.py._filter_results — over-redaction guard for any | |
| pattern/context-scored entity that comes back below the cutoff. | |
| """ | |
| return [ | |
| r | |
| for r in results | |
| if r.score | |
| >= _ENTITY_SCORE_THRESHOLDS.get(r.entity_type, _DEFAULT_SCORE_THRESHOLD) | |
| ] | |
| class ScrubRequest(BaseModel): | |
| turns: list[dict] | |
| class ScrubResponse(BaseModel): | |
| turns: list[dict] | |
| entities_found: int | |
| entities_per_turn: list[int] | |
| MAX_TURNS = 200 | |
| MAX_CONTENT_LENGTH = 100_000 # 100KB per turn | |
| async def scrub( | |
| payload: ScrubRequest, | |
| x_api_key: str = Header(None), | |
| ): | |
| # Fail closed: with no API_KEY configured, reject everything (don't run as | |
| # an open public Presidio endpoint). Body size is bounded by the | |
| # limit_body_size middleware. | |
| if not API_KEY or x_api_key != API_KEY: | |
| raise HTTPException(status_code=401, detail="Invalid API key") | |
| # Best-effort: process up to MAX_TURNS, skip the rest | |
| turns_to_process = payload.turns[:MAX_TURNS] | |
| scrubbed_turns = [] | |
| per_turn_counts = [] | |
| for turn in turns_to_process: | |
| text = turn.get("content", "") | |
| role = turn.get("role", "") | |
| # Unicode normalization before NER (defeats homoglyph/zero-width evasion) | |
| text = _normalize_text(text) | |
| # For oversized turns, run NER on the first chunk only. | |
| # The full content is still passed through — we just scan | |
| # what we can. A missed entity past the limit is acceptable | |
| # since client-side regex already handled structured PII. | |
| scan_text = ( | |
| text[:MAX_CONTENT_LENGTH] if len(text) > MAX_CONTENT_LENGTH else text | |
| ) | |
| try: | |
| results = analyzer.analyze( | |
| text=scan_text, | |
| entities=NER_ENTITIES, | |
| language="en", | |
| score_threshold=_DEFAULT_SCORE_THRESHOLD, | |
| allow_list=_ALLOW_LIST, | |
| ) | |
| # Allow-list + per-entity thresholds for parity with scrub.py, so the | |
| # server doesn't re-redact code terms the client deliberately kept. | |
| results = _filter_by_threshold(results) | |
| if results: | |
| if len(text) <= MAX_CONTENT_LENGTH: | |
| # Normal case: scrub the full text | |
| anonymized = anonymizer.anonymize( | |
| text=text, | |
| analyzer_results=results, | |
| operators=build_operators(results, text), | |
| ) | |
| text = anonymized.text | |
| else: | |
| # Oversized: scrub the scanned prefix, reattach the tail | |
| tail = text[MAX_CONTENT_LENGTH:] | |
| anonymized = anonymizer.anonymize( | |
| text=scan_text, | |
| analyzer_results=results, | |
| operators=build_operators(results, scan_text), | |
| ) | |
| text = anonymized.text + tail | |
| except Exception: | |
| logger.error( | |
| "Presidio error on turn, passing through unscrubbed", exc_info=True | |
| ) | |
| results = [] | |
| per_turn_counts.append(len(results) if results else 0) | |
| scrubbed_turns.append({"role": role, "content": text}) | |
| # Truncate turns beyond MAX_TURNS rather than passing them unscrubbed | |
| if len(payload.turns) > MAX_TURNS: | |
| logger.warning( | |
| "Truncated %d turns beyond MAX_TURNS (%d)", | |
| len(payload.turns) - MAX_TURNS, | |
| MAX_TURNS, | |
| ) | |
| return ScrubResponse( | |
| turns=scrubbed_turns, | |
| entities_found=sum(per_turn_counts), | |
| entities_per_turn=per_turn_counts, | |
| ) | |
| async def health(): | |
| return {"ok": True, "model": SPACY_MODEL, "entities": NER_ENTITIES} | |