n1th1sh commited on
Commit
54b31e4
Β·
verified Β·
1 Parent(s): fdf4039

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +91 -50
app.py CHANGED
@@ -1,17 +1,38 @@
1
  """
2
- CLOAK PII Demo β€” ONNX INT8 CPU inference + Gradio UI + REST /detect API.
3
- Downloads model.int8.onnx from HF Hub at startup.
4
- Set HF_TOKEN secret in Space settings if the repo is private.
5
- The /detect endpoint is what the CLOAK API (Next.js) calls via callHfSpace:
6
- POST /detect Authorization: Bearer <API_SECRET> {"text": "..."}
7
- -> {entities, nested, redacted_text, model, processing_time_ms}
 
 
 
 
 
 
 
 
 
 
 
 
 
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 = 512
33
- STRIDE = 128
34
- THRESHOLD = 0.0
 
 
35
 
36
- # ── Thread count: read cgroup CPU quota, not sched_getaffinity ────────────────
37
- # sched_getaffinity returns the host's full CPU set inside Docker containers.
38
- # The real allocated vCPU count lives in the cgroup CFS quota.
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
- for w in range(all_offsets.shape[0]):
109
- feed = {
110
- "input_ids": enc["input_ids"][w:w + 1].astype(np.int64),
111
- "attention_mask": enc["attention_mask"][w:w + 1].astype(np.int64),
112
- }
113
- logits = _sess.run(["logits"], feed)[0][0] # [classes, L, L]
114
- offsets = all_offsets[w]
115
-
116
- cidx, sidx, eidx = np.where(logits > threshold)
117
- if len(cidx) == 0:
118
- continue
 
119
 
120
- scores = 1.0 / (1.0 + np.exp(-logits[cidx, sidx, eidx]))
121
- cs = offsets[sidx, 0]
122
- ce = offsets[eidx, 1]
123
- keep = cs < ce
124
- for cid, c0, c1, sc in zip(cidx[keep], cs[keep], ce[keep], scores[keep]):
125
- key = (int(cid), int(c0), int(c1))
126
- if key not in best or sc > best[key]["score"]:
127
- best[key] = {
128
- "entity_group": _id2label[int(cid)],
129
- "start": int(c0), "end": int(c1),
130
- "score": float(sc),
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
- # ── REST API for the CLOAK backend (callHfSpace contract) ─────────────────────
 
300
 
301
- api = FastAPI(title="CLOAK PII API", version="1.0.0")
 
 
 
302
 
303
 
304
  class DetectRequest(BaseModel):
305
- text: str = Field(..., min_length=1, max_length=20_000)
306
 
307
 
308
  def _fmt(text: str, e: dict) -> dict:
309
- """Shape one entity to the contract the CLOAK API expects."""
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
- if not API_SECRET or token != API_SECRET:
 
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": int((time.time() - t0) * 1000),
359
  }
360
 
361
 
362
- # Mount the Gradio UI at "/" on the same FastAPI app, so one Space serves both
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")))