ner-service / app.py
tcain
Add
f63fd03
Raw
History Blame Contribute Delete
3.19 kB
"""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
@app.post("/scrub", response_model=ScrubResponse)
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)
@app.get("/health")
async def health():
return {"ok": True, "model": "en_core_web_sm", "entities": NER_ENTITIES}