Spaces:
Sleeping
Sleeping
| """Server-side NER scrubbing service. | |
| Runs Presidio + spaCy to catch names, locations, and organizations | |
| 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 os | |
| from fastapi import FastAPI, Header, HTTPException | |
| 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 | |
| app = FastAPI(title="Common Parlance NER Service", docs_url=None, redoc_url=None) | |
| # Initialize once at startup (not per-request) | |
| nlp_provider = NlpEngineProvider(nlp_configuration={ | |
| "nlp_engine_name": "spacy", | |
| "models": [{"lang_code": "en", "model_name": "en_core_web_sm"}], | |
| }) | |
| analyzer = AnalyzerEngine(nlp_engine=nlp_provider.create_engine()) | |
| anonymizer = AnonymizerEngine() | |
| API_KEY = os.environ.get("API_KEY", "") | |
| # Only detect entity types that regex can't handle. | |
| # Emails, phones, IPs, etc. are already scrubbed client-side. | |
| NER_ENTITIES = ["PERSON", "LOCATION", "ORGANIZATION"] | |
| OPERATORS = { | |
| "PERSON": OperatorConfig("replace", {"new_value": "[NAME]"}), | |
| "LOCATION": OperatorConfig("replace", {"new_value": "[LOCATION]"}), | |
| "ORGANIZATION": OperatorConfig("replace", {"new_value": "[ORG]"}), | |
| } | |
| class ScrubRequest(BaseModel): | |
| turns: list[dict] | |
| class ScrubResponse(BaseModel): | |
| turns: list[dict] | |
| entities_found: int | |
| MAX_TURNS = 200 | |
| MAX_CONTENT_LENGTH = 100_000 # 100KB per turn | |
| async def scrub(payload: ScrubRequest, x_api_key: str = Header(None)): | |
| if API_KEY and x_api_key != API_KEY: | |
| raise HTTPException(status_code=401, detail="Invalid API key") | |
| if len(payload.turns) > MAX_TURNS: | |
| raise HTTPException(status_code=413, detail=f"Too many turns (max {MAX_TURNS})") | |
| total_entities = 0 | |
| scrubbed_turns = [] | |
| for turn in payload.turns: | |
| if len(turn.get("content", "")) > MAX_CONTENT_LENGTH: | |
| raise HTTPException( | |
| status_code=413, | |
| detail=f"Turn content too large (max {MAX_CONTENT_LENGTH} bytes)", | |
| ) | |
| text = turn.get("content", "") | |
| role = turn.get("role", "") | |
| results = analyzer.analyze( | |
| text=text, | |
| entities=NER_ENTITIES, | |
| language="en", | |
| score_threshold=0.5, | |
| ) | |
| if results: | |
| anonymized = anonymizer.anonymize( | |
| text=text, | |
| analyzer_results=results, | |
| operators=OPERATORS, | |
| ) | |
| text = anonymized.text | |
| total_entities += len(results) | |
| scrubbed_turns.append({"role": role, "content": text}) | |
| return ScrubResponse(turns=scrubbed_turns, entities_found=total_entities) | |
| async def health(): | |
| return {"ok": True, "model": "en_core_web_sm", "entities": NER_ENTITIES} | |