Update app.py
Browse files
app.py
CHANGED
|
@@ -1,17 +1,38 @@
|
|
| 1 |
"""
|
| 2 |
-
CLOAK PII
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
"""
|
| 9 |
|
|
|
|
| 10 |
import html as _html
|
| 11 |
import json
|
| 12 |
import os
|
|
|
|
| 13 |
import time
|
| 14 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
import gradio as gr
|
| 16 |
import numpy as np
|
| 17 |
import onnxruntime as ort
|
|
@@ -25,29 +46,27 @@ from transformers import AutoTokenizer
|
|
| 25 |
|
| 26 |
MODEL_REPO = os.getenv("MODEL_REPO", "Wardline/CLOAK_V3.6")
|
| 27 |
HF_TOKEN = os.getenv("HF_TOKEN")
|
| 28 |
-
# Bearer token the CLOAK API must send. callHfSpace sends `Bearer ${HF_TOKEN}`,
|
| 29 |
-
# so default API_SECRET to HF_TOKEN β the same secret works for both unless set.
|
| 30 |
API_SECRET = os.getenv("API_SECRET", HF_TOKEN)
|
| 31 |
|
| 32 |
-
MAX_LEN
|
| 33 |
-
STRIDE
|
| 34 |
-
THRESHOLD
|
|
|
|
|
|
|
| 35 |
|
| 36 |
-
# ββ Thread count:
|
| 37 |
-
# sched_getaffinity
|
| 38 |
-
#
|
| 39 |
|
| 40 |
def _container_cpu_count() -> int:
|
| 41 |
-
# cgroup v2
|
| 42 |
-
try:
|
| 43 |
with open("/sys/fs/cgroup/cpu.max") as f:
|
| 44 |
quota, period = f.read().split()
|
| 45 |
if quota != "max":
|
| 46 |
return max(1, round(int(quota) / int(period)))
|
| 47 |
except Exception:
|
| 48 |
pass
|
| 49 |
-
# cgroup v1
|
| 50 |
-
try:
|
| 51 |
with open("/sys/fs/cgroup/cpu/cpu.cfs_quota_us") as f:
|
| 52 |
quota = int(f.read())
|
| 53 |
with open("/sys/fs/cgroup/cpu/cpu.cfs_period_us") as f:
|
|
@@ -58,6 +77,7 @@ def _container_cpu_count() -> int:
|
|
| 58 |
pass
|
| 59 |
return os.cpu_count() or 2
|
| 60 |
|
|
|
|
| 61 |
_n_threads = _container_cpu_count()
|
| 62 |
print(f"Container CPU count: {_n_threads} (os.cpu_count={os.cpu_count()})")
|
| 63 |
|
|
@@ -81,6 +101,11 @@ _opts.inter_op_num_threads = 1
|
|
| 81 |
_opts.execution_mode = ort.ExecutionMode.ORT_SEQUENTIAL
|
| 82 |
_sess = ort.InferenceSession(_int8_path, sess_options=_opts, providers=["CPUExecutionProvider"])
|
| 83 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
print(f"ONNX INT8 session ready β {_n_threads} threads, {len(_id2label)} classes")
|
| 85 |
|
| 86 |
|
|
@@ -104,31 +129,34 @@ def detect(text: str, threshold: float = THRESHOLD):
|
|
| 104 |
all_offsets = enc.pop("offset_mapping")
|
| 105 |
enc.pop("overflow_to_sample_mapping", None)
|
| 106 |
|
|
|
|
|
|
|
| 107 |
best: dict = {}
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
|
|
|
| 119 |
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
|
| 133 |
doc_len = max(1, len(text))
|
| 134 |
cands = [e for e in best.values() if (e["end"] - e["start"]) <= 0.5 * doc_len]
|
|
@@ -158,6 +186,7 @@ COLOR_MAP = {
|
|
| 158 |
_FALLBACK = ["#45B7D1", "#96CEB4", "#FFEAA7", "#DDA0DD", "#F7DC6F", "#85C1E9"]
|
| 159 |
_extra: dict = {}
|
| 160 |
|
|
|
|
| 161 |
def _label_color(label: str) -> str:
|
| 162 |
if label in COLOR_MAP:
|
| 163 |
return COLOR_MAP[label]
|
|
@@ -213,6 +242,7 @@ def run_analyze(text: str, threshold: float):
|
|
| 213 |
if not text or not text.strip():
|
| 214 |
return "<div style='color:#888'>Paste text above and click Analyze.</div>", pd.DataFrame(), "", ""
|
| 215 |
|
|
|
|
| 216 |
t0 = time.time()
|
| 217 |
flat, cands = detect(text, threshold)
|
| 218 |
elapsed = round(time.time() - t0, 2)
|
|
@@ -296,17 +326,21 @@ with gr.Blocks(title="CLOAK PII Detector", theme=gr.themes.Soft()) as demo:
|
|
| 296 |
outputs=[txt_input, entity_table, redacted_out, summary_box],
|
| 297 |
)
|
| 298 |
|
| 299 |
-
#
|
|
|
|
| 300 |
|
| 301 |
-
|
|
|
|
|
|
|
|
|
|
| 302 |
|
| 303 |
|
| 304 |
class DetectRequest(BaseModel):
|
| 305 |
-
text: str = Field(..., min_length=1, max_length=
|
| 306 |
|
| 307 |
|
| 308 |
def _fmt(text: str, e: dict) -> dict:
|
| 309 |
-
"""Shape one entity to the contract the CLOAK
|
| 310 |
return {
|
| 311 |
"type": e["entity_group"],
|
| 312 |
"text": text[e["start"]:e["end"]].strip(),
|
|
@@ -321,14 +355,16 @@ def _verify(request: Request):
|
|
| 321 |
if not auth.startswith("Bearer "):
|
| 322 |
raise HTTPException(status_code=401, detail="Missing Authorization header")
|
| 323 |
token = auth.removeprefix("Bearer ").strip()
|
| 324 |
-
|
|
|
|
| 325 |
raise HTTPException(status_code=401, detail="Invalid token")
|
| 326 |
|
| 327 |
|
| 328 |
@api.get("/health")
|
| 329 |
def health():
|
| 330 |
return {"status": "ok", "model": MODEL_REPO, "engine": "onnx-int8-cpu",
|
| 331 |
-
"threads": _n_threads, "classes": len(_id2label)
|
|
|
|
| 332 |
|
| 333 |
|
| 334 |
@api.post("/detect")
|
|
@@ -338,6 +374,8 @@ def detect_api(body: DetectRequest, request: Request):
|
|
| 338 |
try:
|
| 339 |
flat, cands = detect(body.text)
|
| 340 |
except Exception as exc:
|
|
|
|
|
|
|
| 341 |
raise HTTPException(status_code=503, detail=f"Inference failed: {exc}")
|
| 342 |
|
| 343 |
entities = [_fmt(body.text, e) for e in flat
|
|
@@ -350,20 +388,23 @@ def detect_api(body: DetectRequest, request: Request):
|
|
| 350 |
for e in sorted(flat, key=lambda x: x["start"], reverse=True):
|
| 351 |
redacted = redacted[:e["start"]] + f'[{e["entity_group"]}]' + redacted[e["end"]:]
|
| 352 |
|
|
|
|
|
|
|
|
|
|
| 353 |
return {
|
| 354 |
"entities": entities,
|
| 355 |
"nested": nested,
|
| 356 |
"redacted_text": redacted,
|
| 357 |
"model": MODEL_REPO,
|
| 358 |
-
"processing_time_ms":
|
| 359 |
}
|
| 360 |
|
| 361 |
|
| 362 |
-
# Mount the Gradio UI at "/" on the same FastAPI app
|
| 363 |
# the demo UI and the /detect + /health API.
|
| 364 |
app = gr.mount_gradio_app(api, demo, path="/")
|
| 365 |
|
| 366 |
|
| 367 |
if __name__ == "__main__":
|
| 368 |
import uvicorn
|
| 369 |
-
uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PORT", "7860")))
|
|
|
|
| 1 |
"""
|
| 2 |
+
CLOAK PII API β ONNX INT8 CPU inference + Gradio demo UI, tuned for the CLOAK
|
| 3 |
+
DLP server (extension analyze, email/chat/drive DLP, and the Drive DSPM crawl).
|
| 4 |
+
|
| 5 |
+
Endpoints (FastAPI, mounted alongside the Gradio UI):
|
| 6 |
+
GET /health -> {status, model, engine, threads, classes}
|
| 7 |
+
POST /detect Bearer <API_SECRET> {"text": "..."}
|
| 8 |
+
-> {entities, nested, redacted_text, model, processing_time_ms}
|
| 9 |
+
|
| 10 |
+
Contract notes for the CLOAK server (server/src/lib/pii.ts):
|
| 11 |
+
* entities: [{type, text, start, end, confidence}] β offsets into the sent text.
|
| 12 |
+
* 401 on a bad key, 503 on inference failure, 422 on oversize body β the
|
| 13 |
+
server records these as scan ERRORS, never as "clean". Do not return
|
| 14 |
+
200-with-empty on failure paths.
|
| 15 |
+
* MAX_TEXT_CHARS here must match DETECT_MAX_CHARS in pii.ts (30_000).
|
| 16 |
+
|
| 17 |
+
Space secrets:
|
| 18 |
+
HF_TOKEN β read access to the private model repo (also the default secret).
|
| 19 |
+
API_SECRET β Bearer token CLOAK sends; defaults to HF_TOKEN when unset.
|
| 20 |
+
MODEL_REPO β override the model repo id (default Wardline/CLOAK_V3.6).
|
| 21 |
"""
|
| 22 |
|
| 23 |
+
import hmac
|
| 24 |
import html as _html
|
| 25 |
import json
|
| 26 |
import os
|
| 27 |
+
import threading
|
| 28 |
import time
|
| 29 |
|
| 30 |
+
# HF's Gradio-Space image sets GRADIO_SSR_MODE=true, which spawns a second
|
| 31 |
+
# (Node SSR) server on port 7861 β it double-binds under the
|
| 32 |
+
# mount_gradio_app + uvicorn pattern and crashes the Space (exit 3).
|
| 33 |
+
# Must be forced off BEFORE gradio is imported.
|
| 34 |
+
os.environ["GRADIO_SSR_MODE"] = "false"
|
| 35 |
+
|
| 36 |
import gradio as gr
|
| 37 |
import numpy as np
|
| 38 |
import onnxruntime as ort
|
|
|
|
| 46 |
|
| 47 |
MODEL_REPO = os.getenv("MODEL_REPO", "Wardline/CLOAK_V3.6")
|
| 48 |
HF_TOKEN = os.getenv("HF_TOKEN")
|
|
|
|
|
|
|
| 49 |
API_SECRET = os.getenv("API_SECRET", HF_TOKEN)
|
| 50 |
|
| 51 |
+
MAX_LEN = 512 # model window (tokens)
|
| 52 |
+
STRIDE = 128 # overlap between windows β no blind spots at joins
|
| 53 |
+
THRESHOLD = 0.0 # logit threshold (UI slider can tighten it)
|
| 54 |
+
MAX_TEXT_CHARS = 30_000 # keep in sync with DETECT_MAX_CHARS in pii.ts
|
| 55 |
+
MAX_WINDOWS = 96 # hard ceiling on windows per request (runaway guard)
|
| 56 |
|
| 57 |
+
# ββ Thread count: cgroup CPU quota, not sched_getaffinity βββββββββββββββββββββ
|
| 58 |
+
# sched_getaffinity reports the HOST's cores inside a container; the real vCPU
|
| 59 |
+
# allocation lives in the cgroup CFS quota.
|
| 60 |
|
| 61 |
def _container_cpu_count() -> int:
|
| 62 |
+
try: # cgroup v2
|
|
|
|
| 63 |
with open("/sys/fs/cgroup/cpu.max") as f:
|
| 64 |
quota, period = f.read().split()
|
| 65 |
if quota != "max":
|
| 66 |
return max(1, round(int(quota) / int(period)))
|
| 67 |
except Exception:
|
| 68 |
pass
|
| 69 |
+
try: # cgroup v1
|
|
|
|
| 70 |
with open("/sys/fs/cgroup/cpu/cpu.cfs_quota_us") as f:
|
| 71 |
quota = int(f.read())
|
| 72 |
with open("/sys/fs/cgroup/cpu/cpu.cfs_period_us") as f:
|
|
|
|
| 77 |
pass
|
| 78 |
return os.cpu_count() or 2
|
| 79 |
|
| 80 |
+
|
| 81 |
_n_threads = _container_cpu_count()
|
| 82 |
print(f"Container CPU count: {_n_threads} (os.cpu_count={os.cpu_count()})")
|
| 83 |
|
|
|
|
| 101 |
_opts.execution_mode = ort.ExecutionMode.ORT_SEQUENTIAL
|
| 102 |
_sess = ort.InferenceSession(_int8_path, sess_options=_opts, providers=["CPUExecutionProvider"])
|
| 103 |
|
| 104 |
+
# One inference at a time. ONNX already saturates every allotted core per run β
|
| 105 |
+
# concurrent runs would thrash the CPU and slow BOTH callers down. Waiters queue
|
| 106 |
+
# here (FastAPI runs sync endpoints in a threadpool, so requests just block).
|
| 107 |
+
_infer_lock = threading.Lock()
|
| 108 |
+
|
| 109 |
print(f"ONNX INT8 session ready β {_n_threads} threads, {len(_id2label)} classes")
|
| 110 |
|
| 111 |
|
|
|
|
| 129 |
all_offsets = enc.pop("offset_mapping")
|
| 130 |
enc.pop("overflow_to_sample_mapping", None)
|
| 131 |
|
| 132 |
+
n_windows = min(all_offsets.shape[0], MAX_WINDOWS)
|
| 133 |
+
|
| 134 |
best: dict = {}
|
| 135 |
+
with _infer_lock:
|
| 136 |
+
for w in range(n_windows):
|
| 137 |
+
feed = {
|
| 138 |
+
"input_ids": enc["input_ids"][w:w + 1].astype(np.int64),
|
| 139 |
+
"attention_mask": enc["attention_mask"][w:w + 1].astype(np.int64),
|
| 140 |
+
}
|
| 141 |
+
logits = _sess.run(["logits"], feed)[0][0] # [classes, L, L]
|
| 142 |
+
offsets = all_offsets[w]
|
| 143 |
+
|
| 144 |
+
cidx, sidx, eidx = np.where(logits > threshold)
|
| 145 |
+
if len(cidx) == 0:
|
| 146 |
+
continue
|
| 147 |
|
| 148 |
+
scores = 1.0 / (1.0 + np.exp(-logits[cidx, sidx, eidx]))
|
| 149 |
+
cs = offsets[sidx, 0]
|
| 150 |
+
ce = offsets[eidx, 1]
|
| 151 |
+
keep = cs < ce
|
| 152 |
+
for cid, c0, c1, sc in zip(cidx[keep], cs[keep], ce[keep], scores[keep]):
|
| 153 |
+
key = (int(cid), int(c0), int(c1))
|
| 154 |
+
if key not in best or sc > best[key]["score"]:
|
| 155 |
+
best[key] = {
|
| 156 |
+
"entity_group": _id2label[int(cid)],
|
| 157 |
+
"start": int(c0), "end": int(c1),
|
| 158 |
+
"score": float(sc),
|
| 159 |
+
}
|
| 160 |
|
| 161 |
doc_len = max(1, len(text))
|
| 162 |
cands = [e for e in best.values() if (e["end"] - e["start"]) <= 0.5 * doc_len]
|
|
|
|
| 186 |
_FALLBACK = ["#45B7D1", "#96CEB4", "#FFEAA7", "#DDA0DD", "#F7DC6F", "#85C1E9"]
|
| 187 |
_extra: dict = {}
|
| 188 |
|
| 189 |
+
|
| 190 |
def _label_color(label: str) -> str:
|
| 191 |
if label in COLOR_MAP:
|
| 192 |
return COLOR_MAP[label]
|
|
|
|
| 242 |
if not text or not text.strip():
|
| 243 |
return "<div style='color:#888'>Paste text above and click Analyze.</div>", pd.DataFrame(), "", ""
|
| 244 |
|
| 245 |
+
text = text[:MAX_TEXT_CHARS]
|
| 246 |
t0 = time.time()
|
| 247 |
flat, cands = detect(text, threshold)
|
| 248 |
elapsed = round(time.time() - t0, 2)
|
|
|
|
| 326 |
outputs=[txt_input, entity_table, redacted_out, summary_box],
|
| 327 |
)
|
| 328 |
|
| 329 |
+
# Cap simultaneous UI jobs so demo users can't starve the REST API.
|
| 330 |
+
demo.queue(default_concurrency_limit=1, max_size=8)
|
| 331 |
|
| 332 |
+
|
| 333 |
+
# ββ REST API for the CLOAK backend ββββββββββββββββββββββββββββββββββββββββββββ
|
| 334 |
+
|
| 335 |
+
api = FastAPI(title="CLOAK PII API", version="1.1.0")
|
| 336 |
|
| 337 |
|
| 338 |
class DetectRequest(BaseModel):
|
| 339 |
+
text: str = Field(..., min_length=1, max_length=MAX_TEXT_CHARS)
|
| 340 |
|
| 341 |
|
| 342 |
def _fmt(text: str, e: dict) -> dict:
|
| 343 |
+
"""Shape one entity to the contract the CLOAK server expects."""
|
| 344 |
return {
|
| 345 |
"type": e["entity_group"],
|
| 346 |
"text": text[e["start"]:e["end"]].strip(),
|
|
|
|
| 355 |
if not auth.startswith("Bearer "):
|
| 356 |
raise HTTPException(status_code=401, detail="Missing Authorization header")
|
| 357 |
token = auth.removeprefix("Bearer ").strip()
|
| 358 |
+
# Constant-time compare β a plain != leaks timing.
|
| 359 |
+
if not API_SECRET or not hmac.compare_digest(token, API_SECRET):
|
| 360 |
raise HTTPException(status_code=401, detail="Invalid token")
|
| 361 |
|
| 362 |
|
| 363 |
@api.get("/health")
|
| 364 |
def health():
|
| 365 |
return {"status": "ok", "model": MODEL_REPO, "engine": "onnx-int8-cpu",
|
| 366 |
+
"threads": _n_threads, "classes": len(_id2label),
|
| 367 |
+
"max_chars": MAX_TEXT_CHARS}
|
| 368 |
|
| 369 |
|
| 370 |
@api.post("/detect")
|
|
|
|
| 374 |
try:
|
| 375 |
flat, cands = detect(body.text)
|
| 376 |
except Exception as exc:
|
| 377 |
+
# 503, never 200-with-empty: the CLOAK server must record scan errors,
|
| 378 |
+
# not false "clean" results.
|
| 379 |
raise HTTPException(status_code=503, detail=f"Inference failed: {exc}")
|
| 380 |
|
| 381 |
entities = [_fmt(body.text, e) for e in flat
|
|
|
|
| 388 |
for e in sorted(flat, key=lambda x: x["start"], reverse=True):
|
| 389 |
redacted = redacted[:e["start"]] + f'[{e["entity_group"]}]' + redacted[e["end"]:]
|
| 390 |
|
| 391 |
+
ms = int((time.time() - t0) * 1000)
|
| 392 |
+
print(f"[detect] {len(body.text)} chars -> {len(entities)} entities in {ms}ms")
|
| 393 |
+
|
| 394 |
return {
|
| 395 |
"entities": entities,
|
| 396 |
"nested": nested,
|
| 397 |
"redacted_text": redacted,
|
| 398 |
"model": MODEL_REPO,
|
| 399 |
+
"processing_time_ms": ms,
|
| 400 |
}
|
| 401 |
|
| 402 |
|
| 403 |
+
# Mount the Gradio UI at "/" on the same FastAPI app β one Space serves both
|
| 404 |
# the demo UI and the /detect + /health API.
|
| 405 |
app = gr.mount_gradio_app(api, demo, path="/")
|
| 406 |
|
| 407 |
|
| 408 |
if __name__ == "__main__":
|
| 409 |
import uvicorn
|
| 410 |
+
uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PORT", "7860")))
|