holypug1274 commited on
Commit
dc6630c
·
1 Parent(s): c949641

deploy: privacy-sanitizer backend 2026-04-27

Browse files
Dockerfile ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # HuggingFace Spaces Docker — read the doc:
2
+ # https://huggingface.co/docs/hub/spaces-sdks-docker
3
+
4
+ FROM python:3.12-slim
5
+
6
+ # HF Spaces requires a non-root user with uid 1000
7
+ RUN useradd -m -u 1000 user
8
+ USER user
9
+ ENV PATH="/home/user/.local/bin:$PATH"
10
+
11
+ WORKDIR /app
12
+
13
+ RUN apt-get update && apt-get install -y --no-install-recommends \
14
+ build-essential \
15
+ && rm -rf /var/lib/apt/lists/* || true
16
+
17
+ COPY --chown=user requirements.txt .
18
+ RUN pip install --no-cache-dir --upgrade -r requirements.txt
19
+
20
+ COPY --chown=user . /app
21
+
22
+ # Pre-download default model at build time — avoids cold-start delay
23
+ RUN python -c "\
24
+ from transformers import pipeline; \
25
+ pipeline('token-classification', \
26
+ model='iiiorg/piiranha-v1-detect-personal-information', \
27
+ aggregation_strategy='simple')"
28
+
29
+ # HuggingFace Spaces requires port 7860
30
+ ENV PORT=7860
31
+ ENV APP_ENV=staging
32
+ EXPOSE 7860
33
+
34
+ CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -1,10 +1,35 @@
1
  ---
2
- title: Privacy Sanitizer Api
3
- emoji: 📉
4
- colorFrom: yellow
5
- colorTo: pink
6
  sdk: docker
 
7
  pinned: false
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Privacy Sanitizer API
3
+ emoji: 🔒
4
+ colorFrom: purple
5
+ colorTo: blue
6
  sdk: docker
7
+ app_port: 7860
8
  pinned: false
9
+ private: true
10
  ---
11
 
12
+ # Privacy Sanitizer Backend API
13
+
14
+ FastAPI backend for the Privacy Text Sanitizer app.
15
+ NER-based PII detection using HuggingFace Transformers.
16
+
17
+ ## Endpoints
18
+
19
+ | Method | Path | Description |
20
+ | ------ | --------------- | ------------------------- |
21
+ | `GET` | `/health` | Health check |
22
+ | `GET` | `/api/models` | List available NER models |
23
+ | `POST` | `/api/sanitize` | Sanitize text |
24
+
25
+ ## Environment variables (set in Space Secrets)
26
+
27
+ | Variable | Description |
28
+ | -------------------- | ----------------------------------------------------------------- |
29
+ | `FRONTEND_URL` | Vercel frontend URL for CORS (e.g. `https://your-app.vercel.app`) |
30
+ | `DEFAULT_MODEL_NAME` | Override default NER model |
31
+
32
+ ## Deploy
33
+
34
+ This Space is deployed from `deploy/hf-space/` in the main repo.
35
+ Push only the backend `app/` and `requirements.txt` alongside this `Dockerfile`.
app/__init__.py ADDED
File without changes
app/config.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic_settings import BaseSettings
2
+
3
+
4
+ # Each entry: full metadata shown in the model picker UI
5
+ AVAILABLE_MODELS: list[dict] = [
6
+ {
7
+ "id": "iiiorg/piiranha-v1-detect-personal-information",
8
+ "label": "Piiranha v1",
9
+ "provider": "iiiorg",
10
+ "params": "110M",
11
+ "quality": "good",
12
+ "quality_score": 2,
13
+ "speed": "fast",
14
+ "entity_types": ["PER", "EMAIL", "PHONE", "ADDRESS", "ORG", "LOC", "DATE", "ID"],
15
+ "description": "Lightweight BERT-based model fine-tuned specifically for PII detection across 8+ entity types. Best for general-purpose anonymization with minimal latency.",
16
+ },
17
+ {
18
+ "id": "dslim/bert-base-NER",
19
+ "label": "BERT-base NER",
20
+ "provider": "dslim",
21
+ "params": "110M",
22
+ "quality": "good",
23
+ "quality_score": 2,
24
+ "speed": "fast",
25
+ "entity_types": ["PER", "ORG", "LOC", "MISC"],
26
+ "description": "Standard CoNLL-2003 NER model. High precision on person names, organizations and locations. Limited to 4 entity types — ideal when false positives matter more than coverage.",
27
+ },
28
+ {
29
+ "id": "Jean-Baptiste/roberta-large-ner-english",
30
+ "label": "RoBERTa-large NER",
31
+ "provider": "Jean-Baptiste",
32
+ "params": "355M",
33
+ "quality": "best",
34
+ "quality_score": 3,
35
+ "speed": "slow",
36
+ "entity_types": ["PER", "ORG", "LOC", "MISC"],
37
+ "description": "355M parameter RoBERTa fine-tuned on OntoNotes 5.0. Highest accuracy on complex/ambiguous text. Significantly slower — use when precision is critical.",
38
+ },
39
+ ]
40
+
41
+ DEFAULT_MODEL_ID: str = AVAILABLE_MODELS[0]["id"]
42
+
43
+
44
+ class Settings(BaseSettings):
45
+ # Environment: "local" | "staging" | "production"
46
+ app_env: str = "local"
47
+
48
+ default_model_name: str = DEFAULT_MODEL_ID
49
+ host: str = "0.0.0.0"
50
+ port: int = 8000
51
+
52
+ # Staging: set FRONTEND_URL=https://your-app.vercel.app in HF Space secrets
53
+ frontend_url: str = ""
54
+
55
+ # Base origins always allowed; staging/production add the deployed frontend URL
56
+ @property
57
+ def allowed_origins(self) -> list[str]:
58
+ origins = ["http://localhost:3000", "http://127.0.0.1:3000"]
59
+ if self.frontend_url:
60
+ origins.append(self.frontend_url)
61
+ return origins
62
+
63
+ model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}
64
+
65
+
66
+ settings = Settings()
app/main.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from contextlib import asynccontextmanager
2
+
3
+ from fastapi import FastAPI
4
+ from fastapi.middleware.cors import CORSMiddleware
5
+
6
+ from app.config import settings
7
+ from app.routers.sanitize import router as sanitize_router
8
+ from app.services.sanitizer import preload_pipeline
9
+
10
+
11
+ @asynccontextmanager
12
+ async def lifespan(app: FastAPI):
13
+ # Pre-load default model at startup; other models are lazy-loaded on first use
14
+ preload_pipeline(settings.default_model_name)
15
+ yield
16
+
17
+
18
+ app = FastAPI(
19
+ title="Privacy Text Sanitizer API",
20
+ version="1.0.0",
21
+ lifespan=lifespan,
22
+ )
23
+
24
+ app.add_middleware(
25
+ CORSMiddleware,
26
+ allow_origins=settings.allowed_origins,
27
+ allow_methods=["GET", "POST", "OPTIONS"],
28
+ allow_headers=["Content-Type"],
29
+ )
30
+
31
+ app.include_router(sanitize_router, prefix="/api")
32
+
33
+
34
+ @app.get("/health")
35
+ async def health() -> dict[str, str]:
36
+ return {"status": "ok"}
app/routers/__init__.py ADDED
File without changes
app/routers/sanitize.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, HTTPException
2
+
3
+ from app.config import AVAILABLE_MODELS
4
+ from app.schemas.sanitize import ModelInfo, SanitizeRequest, SanitizeResponse
5
+ from app.services.sanitizer import sanitize_text
6
+
7
+ router = APIRouter()
8
+
9
+
10
+ @router.get("/models", response_model=list[ModelInfo])
11
+ async def list_models() -> list[ModelInfo]:
12
+ return [ModelInfo(**m) for m in AVAILABLE_MODELS]
13
+
14
+
15
+ @router.post("/sanitize", response_model=SanitizeResponse)
16
+ async def sanitize(body: SanitizeRequest) -> SanitizeResponse:
17
+ try:
18
+ return sanitize_text(body.text, model_id=body.model_name)
19
+ except RuntimeError as exc:
20
+ raise HTTPException(status_code=503, detail=str(exc)) from exc
21
+ except Exception as exc:
22
+ raise HTTPException(status_code=500, detail="Model inference failed") from exc
app/schemas/__init__.py ADDED
File without changes
app/schemas/sanitize.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Optional
4
+
5
+ from pydantic import BaseModel
6
+
7
+
8
+ class SanitizeRequest(BaseModel):
9
+ text: str
10
+ model_name: Optional[str] = None
11
+
12
+
13
+ class ModelInfo(BaseModel):
14
+ id: str
15
+ label: str
16
+ provider: str
17
+ params: str
18
+ quality: str
19
+ quality_score: int
20
+ speed: str
21
+ entity_types: list[str]
22
+ description: str
23
+
24
+
25
+ class EntityDetail(BaseModel):
26
+ type: str
27
+ original_value: str
28
+ replacement: str
29
+ start: int
30
+ end: int
31
+ score: Optional[float] = None
32
+
33
+
34
+ class SanitizeStats(BaseModel):
35
+ total_entities: int
36
+ by_type: dict[str, int]
37
+ original_length: int
38
+ sanitized_length: int
39
+ sensitive_ratio: float
40
+
41
+
42
+ class SanitizeResponse(BaseModel):
43
+ original_text: str
44
+ sanitized_text: str
45
+ entities: list[EntityDetail]
46
+ stats: SanitizeStats
app/services/__init__.py ADDED
File without changes
app/services/sanitizer.py ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Sanitizer service — lazy-loads NER pipelines per model and caches them.
3
+ The default pipeline is pre-loaded at startup; additional models are loaded
4
+ on first use and kept in memory for the lifetime of the process.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any, Optional
10
+
11
+ from transformers import pipeline as hf_pipeline
12
+
13
+ from app.schemas.sanitize import EntityDetail, SanitizeResponse, SanitizeStats
14
+
15
+ # Replacement map: NER label → placeholder
16
+ LABEL_REPLACEMENTS: dict[str, str] = {
17
+ "PER": "[PRIVATE_PERSON]",
18
+ "PERSON": "[PRIVATE_PERSON]",
19
+ "EMAIL": "[PRIVATE_EMAIL]",
20
+ "PHONE": "[PRIVATE_PHONE]",
21
+ "PHONE_NUM": "[PRIVATE_PHONE]",
22
+ "ADDRESS": "[PRIVATE_ADDRESS]",
23
+ "ADDR": "[PRIVATE_ADDRESS]",
24
+ "LOC": "[PRIVATE_LOCATION]",
25
+ "LOCATION": "[PRIVATE_LOCATION]",
26
+ "GPE": "[PRIVATE_LOCATION]",
27
+ "ORG": "[PRIVATE_ORGANIZATION]",
28
+ "ORGANIZATION": "[PRIVATE_ORGANIZATION]",
29
+ "DATE": "[PRIVATE_DATE]",
30
+ "TIME": "[PRIVATE_DATE]",
31
+ "ID": "[PRIVATE_ID]",
32
+ "ID_NUM": "[PRIVATE_ID]",
33
+ "ID_NUMBER": "[PRIVATE_ID]",
34
+ }
35
+
36
+ DEFAULT_REPLACEMENT = "[PRIVATE_DATA]"
37
+
38
+ # Canonical display label per replacement (for stats grouping)
39
+ REPLACEMENT_TO_TYPE: dict[str, str] = {
40
+ "[PRIVATE_PERSON]": "PERSON",
41
+ "[PRIVATE_EMAIL]": "EMAIL",
42
+ "[PRIVATE_PHONE]": "PHONE",
43
+ "[PRIVATE_ADDRESS]": "ADDRESS",
44
+ "[PRIVATE_LOCATION]": "LOCATION",
45
+ "[PRIVATE_ORGANIZATION]": "ORGANIZATION",
46
+ "[PRIVATE_DATE]": "DATE",
47
+ "[PRIVATE_ID]": "ID_NUMBER",
48
+ "[PRIVATE_DATA]": "OTHER",
49
+ }
50
+
51
+ # Pipeline cache: model_id → loaded HuggingFace pipeline
52
+ _pipeline_cache: dict[str, Any] = {}
53
+ _default_model: Optional[str] = None
54
+
55
+
56
+ def preload_pipeline(model_id: str) -> None:
57
+ """Pre-load and cache a pipeline (called at startup for the default model)."""
58
+ global _default_model
59
+ _get_or_load(model_id)
60
+ _default_model = model_id
61
+
62
+
63
+ def _get_or_load(model_id: str) -> Any:
64
+ """Return cached pipeline or load it on first use."""
65
+ if model_id not in _pipeline_cache:
66
+ _pipeline_cache[model_id] = hf_pipeline(
67
+ "token-classification",
68
+ model=model_id,
69
+ aggregation_strategy="simple",
70
+ )
71
+ return _pipeline_cache[model_id]
72
+
73
+
74
+ # Keep backward-compatible set_pipeline for tests
75
+ def set_pipeline(pipeline: Any, model_id: str = "__test__") -> None:
76
+ global _default_model
77
+ _pipeline_cache[model_id] = pipeline
78
+ if _default_model is None:
79
+ _default_model = model_id
80
+
81
+
82
+ def sanitize_text(text: str, model_id: Optional[str] = None) -> SanitizeResponse:
83
+ if not text or not text.strip():
84
+ return _empty_response(text)
85
+
86
+ resolved_model = model_id or _default_model
87
+ if resolved_model is None:
88
+ raise RuntimeError("NER pipeline has not been initialised")
89
+
90
+ active_pipeline = _get_or_load(resolved_model)
91
+ raw_entities: list[dict] = active_pipeline(text)
92
+
93
+ # Sort by start position descending so replacements don't shift indices
94
+ raw_entities.sort(key=lambda e: e["start"], reverse=True)
95
+
96
+ sanitized = text
97
+ entity_details: list[EntityDetail] = []
98
+
99
+ for ent in raw_entities:
100
+ label: str = ent.get("entity_group", ent.get("entity", "OTHER")).upper()
101
+ replacement = LABEL_REPLACEMENTS.get(label, DEFAULT_REPLACEMENT)
102
+ start: int = ent["start"]
103
+ end: int = ent["end"]
104
+ original_value: str = text[start:end]
105
+ score: Optional[float] = ent.get("score")
106
+
107
+ sanitized = sanitized[:start] + replacement + sanitized[end:]
108
+
109
+ entity_details.append(
110
+ EntityDetail(
111
+ type=REPLACEMENT_TO_TYPE.get(replacement, "OTHER"),
112
+ original_value=original_value,
113
+ replacement=replacement,
114
+ start=start,
115
+ end=end,
116
+ score=round(score, 4) if score is not None else None,
117
+ )
118
+ )
119
+
120
+ # Re-sort entities by original position (ascending) for the response
121
+ entity_details.sort(key=lambda e: e.start)
122
+
123
+ by_type: dict[str, int] = {}
124
+ for ent in entity_details:
125
+ by_type[ent.type] = by_type.get(ent.type, 0) + 1
126
+
127
+ original_length = len(text)
128
+ sanitized_length = len(sanitized)
129
+ sensitive_chars = sum(e.end - e.start for e in entity_details)
130
+ sensitive_ratio = round(sensitive_chars / original_length, 4) if original_length > 0 else 0.0
131
+
132
+ return SanitizeResponse(
133
+ original_text=text,
134
+ sanitized_text=sanitized,
135
+ entities=entity_details,
136
+ stats=SanitizeStats(
137
+ total_entities=len(entity_details),
138
+ by_type=by_type,
139
+ original_length=original_length,
140
+ sanitized_length=sanitized_length,
141
+ sensitive_ratio=sensitive_ratio,
142
+ ),
143
+ )
144
+
145
+
146
+ def _empty_response(text: str) -> SanitizeResponse:
147
+ return SanitizeResponse(
148
+ original_text=text,
149
+ sanitized_text=text,
150
+ entities=[],
151
+ stats=SanitizeStats(
152
+ total_entities=0,
153
+ by_type={},
154
+ original_length=len(text),
155
+ sanitized_length=len(text),
156
+ sensitive_ratio=0.0,
157
+ ),
158
+ )
requirements.txt ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ fastapi==0.115.12
2
+ uvicorn[standard]==0.34.2
3
+ pydantic==2.11.4
4
+ pydantic-settings==2.9.1
5
+ transformers==4.51.3
6
+ torch==2.7.0
7
+ python-dotenv==1.1.0