iamleonie mlabonne claude opus 4.8 (1m context) commited on
Commit
f645508
·
0 Parent(s):

Initial public commit

Browse files

Co-authored-by: mlabonne <mlabonne@users.noreply.huggingface.co>
Co-authored-by: claude opus 4.8 (1m context) <claude opus 4.8 (1m context)@users.noreply.huggingface.co>

Files changed (10) hide show
  1. .dockerignore +6 -0
  2. .gitattributes +35 -0
  3. Dockerfile +30 -0
  4. README.md +28 -0
  5. app.py +67 -0
  6. index.html +240 -0
  7. lliquid.gif +0 -0
  8. pii.py +119 -0
  9. requirements.txt +6 -0
  10. style.css +161 -0
.dockerignore ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ .git
2
+ .gitattributes
3
+ __pycache__/
4
+ *.pyc
5
+ .venv/
6
+ *.md
.gitattributes ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
28
+ *.tar filter=lfs diff=lfs merge=lfs -text
29
+ *.tflite filter=lfs diff=lfs merge=lfs -text
30
+ *.tgz filter=lfs diff=lfs merge=lfs -text
31
+ *.wasm filter=lfs diff=lfs merge=lfs -text
32
+ *.xz filter=lfs diff=lfs merge=lfs -text
33
+ *.zip filter=lfs diff=lfs merge=lfs -text
34
+ *.zst filter=lfs diff=lfs merge=lfs -text
35
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
Dockerfile ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.12-slim
2
+
3
+ # non-root user (HF Spaces convention; gives a writable HOME for the HF cache the
4
+ # model is pulled into at boot).
5
+ RUN useradd -m -u 1000 user
6
+ USER user
7
+ ENV HOME=/home/user \
8
+ PATH=/home/user/.local/bin:$PATH \
9
+ HF_HOME=/home/user/.cache/huggingface \
10
+ PYTHONUNBUFFERED=1 \
11
+ TMPDIR=/tmp
12
+
13
+ WORKDIR /home/user/app
14
+
15
+ # CPU-only torch (the default PyPI wheel is CUDA and huge); transformers 5.11 is the
16
+ # stack pii-detect's bidirectional LFM2 remote code was validated against.
17
+ RUN pip install --no-cache-dir --user torch==2.12.0 --index-url https://download.pytorch.org/whl/cpu
18
+
19
+ COPY --chown=user requirements.txt .
20
+ RUN pip install --no-cache-dir --user -r requirements.txt
21
+
22
+ COPY --chown=user . .
23
+
24
+ EXPOSE 7860
25
+
26
+ # One worker (single shared model), threads for concurrent static/detect conns.
27
+ # No --preload: the worker pulls + loads the model in its own process (avoids the
28
+ # torch/OpenMP fork-after-threads deadlock). --timeout 0 keeps the ~1.4GB first-boot
29
+ # download from tripping the worker timeout.
30
+ CMD ["gunicorn", "-w", "1", "--threads", "8", "-k", "gthread", "-b", "0.0.0.0:7860", "--timeout", "0", "app:app"]
README.md ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: PII Detection
3
+ emoji: 🕵️
4
+ colorFrom: purple
5
+ colorTo: blue
6
+ sdk: docker
7
+ app_port: 7860
8
+ header: mini
9
+ pinned: false
10
+ private: true
11
+ short_description: Multilingual PII detection with LFM2.5, server-side CPU
12
+ ---
13
+
14
+ # PII Detection
15
+
16
+ Server-side PII detection powered by [`LiquidAI/LFM2.5-Encoder-350M-PII-Detector`](https://huggingface.co/LiquidAI/LFM2.5-Encoder-350M-PII-Detector): a bidirectional LFM2.5 encoder (354M) with a token-classification head, covering 40 PII types across 16 languages. It runs on the Space's CPU; the browser only sends text to `/api/detect` and highlights the returned spans, so nothing is downloaded to the visitor.
17
+
18
+ ## How it works
19
+
20
+ - `pii.py` — loads the model via its custom modeling code (`trust_remote_code`) and runs the model's own inference-time **hybrid regex decode** (`pii_hybrid_decode.py` + `context_cued.py`, fetched from the model repo): the token classifier locates PII, a validator-gated regex layer owns format-bound types (email/IBAN/credit-card/JWT/…), and cue-gated rules recover structure-less IDs. Spans are enriched with a domain (color), a pretty label, and a redaction tag.
21
+ - `app.py` — Flask server: loads the model once, serves the page, and answers `POST /api/detect {text}` with `{spans, redacted, count, ms}`.
22
+ - `index.html` / `style.css` — the UI: highlight or redacted view, a per-domain legend, and detection stats.
23
+
24
+ f32 on CPU (not fp16: x86 has no fast half path; not int8: token-classification boundaries are precision-sensitive) — the same reasoning as the spellchecker / policy-linting Spaces. Threads are matched to the container's CPU quota.
25
+
26
+ ## Setup
27
+
28
+ The model repo is private, so the Space needs an **`HF_TOKEN`** secret with read access to `LiquidAI/LFM2.5-Encoder-350M-PII-Detector`.
app.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """PII detection — server-side CPU inference.
2
+
3
+ LiquidAI/LFM2.5-Encoder-350M-PII-Detector (LFM2.5 encoder + token-classification head + hybrid regex
4
+ decode) runs on the Space's CPU. The browser POSTs {text} to /api/detect and
5
+ gets back char-anchored PII spans (type/domain/label) + a redacted string, then
6
+ highlights them. Nothing is downloaded to the visitor.
7
+
8
+ The model repo is private, so an HF_TOKEN Space secret with read access is
9
+ required to pull it.
10
+ """
11
+
12
+ import os
13
+ import time
14
+
15
+ import torch
16
+ from flask import Flask, request, send_from_directory
17
+
18
+ import pii as P
19
+
20
+ APP_DIR = os.path.dirname(os.path.abspath(__file__))
21
+ # Match threads to the container's cgroup CPU quota, not the host core count.
22
+ _CPUS = P.effective_cpus()
23
+ torch.set_num_threads(_CPUS)
24
+ try:
25
+ torch.set_num_interop_threads(1)
26
+ except RuntimeError:
27
+ pass
28
+
29
+ app = Flask(__name__)
30
+
31
+ print(f"[boot] loading pii-detect on {_CPUS} CPU thread(s), f32…", flush=True)
32
+ MODEL = P.PiiDetector()
33
+ MODEL.detect("Warm up: email jane@example.com, SSN 123-45-6789.") # warm the graph
34
+ print("[boot] detector ready", flush=True)
35
+
36
+
37
+ @app.route("/")
38
+ def index():
39
+ return send_from_directory(APP_DIR, "index.html")
40
+
41
+
42
+ @app.route("/<path:name>")
43
+ def static_file(name):
44
+ if name in ("style.css", "lliquid.gif"):
45
+ return send_from_directory(APP_DIR, name)
46
+ return ("not found", 404)
47
+
48
+
49
+ @app.route("/api/ready")
50
+ def ready():
51
+ return {"ready": MODEL is not None}
52
+
53
+
54
+ @app.route("/api/detect", methods=["POST"])
55
+ def api_detect():
56
+ data = request.get_json(silent=True) or {}
57
+ text = (data.get("text") or "").replace("\r", "")
58
+ if not text.strip():
59
+ return {"spans": [], "redacted": "", "count": 0, "ms": 0}
60
+ t0 = time.perf_counter()
61
+ out = MODEL.detect(text)
62
+ out["ms"] = round((time.perf_counter() - t0) * 1000)
63
+ return out
64
+
65
+
66
+ if __name__ == "__main__":
67
+ app.run(host="0.0.0.0", port=int(os.environ.get("PORT", 7860)), threaded=True)
index.html ADDED
@@ -0,0 +1,240 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>PII detection — Liquid AI</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
+ <link href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
10
+ <link rel="stylesheet" href="style.css">
11
+ <script>
12
+ // Private Space auth: HF signs the embedded iframe URL with ?__sign=<token>.
13
+ // Incognito / Safari block the third-party .hf.space cookie, so that token is
14
+ // the only credential — carry it on same-origin API requests, else /api/*
15
+ // returns 404 and the model-ready poll hangs.
16
+ (() => {
17
+ const sign = new URLSearchParams(location.search).get("__sign");
18
+ if (!sign) return;
19
+ const orig = self.fetch.bind(self);
20
+ self.fetch = (input, init) => {
21
+ try {
22
+ const u = new URL(typeof input === "string" ? input : input.url, location.href);
23
+ if (u.origin === location.origin && !u.searchParams.has("__sign")) {
24
+ u.searchParams.set("__sign", sign);
25
+ input = u.toString();
26
+ }
27
+ } catch (e) { /* non-URL input: pass through */ }
28
+ return orig(input, init);
29
+ };
30
+ })();
31
+ </script>
32
+ </head>
33
+ <body>
34
+ <div class="wrap">
35
+ <header class="site-intro">
36
+ <h1>Multilingual <em>PII detection</em></h1>
37
+ <p class="sub">Spot <strong>40 kinds of personal data</strong> across 16 languages and redact them, in one bidirectional pass.</p>
38
+ </header>
39
+
40
+ <section class="model-strip is-loading" id="model-strip" aria-label="Model status">
41
+ <span class="model-logo-frame" aria-hidden="true">
42
+ <img id="model-logo" class="model-logo" width="56" height="59" alt="" src="lliquid.gif">
43
+ </span>
44
+ <div class="model-copy">
45
+ <div class="model-name-row">
46
+ <a href="https://huggingface.co/LiquidAI/LFM2.5-Encoder-350M-PII-Detector" target="_blank" rel="noopener">LFM2.5 Encoder</a>
47
+ <span class="model-variant">350M parameters</span>
48
+ </div>
49
+ </div>
50
+ <div class="load-block">
51
+ <div class="load-meta">
52
+ <span id="status" role="status" aria-live="polite">Connecting to server…</span>
53
+ <span id="load-percent">0%</span>
54
+ </div>
55
+ <div class="load-track" role="progressbar" aria-label="Model loading" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0"><span id="load-progress"></span></div>
56
+ </div>
57
+ </section>
58
+
59
+ <main>
60
+ <div class="pii-workspace">
61
+ <section class="workspace-card input-panel">
62
+ <div class="panel-header">
63
+ <div><h2>Text</h2></div>
64
+ </div>
65
+ <div class="editor-stage">
66
+ <textarea id="input" spellcheck="false" placeholder="Paste text containing names, emails, IDs, cards…">Update the account for david.miller@example.com: phone +1 415 555 0199, SSN 123-45-6789, card 4111 1111 1111 1111, passport C01X00T47.</textarea>
67
+ </div>
68
+ </section>
69
+
70
+ <section class="workspace-card output-panel">
71
+ <div class="panel-header">
72
+ <div><h2>Detected PII</h2></div>
73
+ <div class="view-toggle" id="view-toggle" role="tablist" aria-label="Output view">
74
+ <button type="button" data-view="highlight" class="active" role="tab">Highlight</button>
75
+ <button type="button" data-view="redacted" role="tab">Redacted</button>
76
+ </div>
77
+ </div>
78
+ <div class="output-stage">
79
+ <div id="output" class="output-body"><div class="output-empty">Detected personal data will appear here.</div></div>
80
+ </div>
81
+ <div class="controls">
82
+ <div class="legend" id="legend"></div>
83
+ </div>
84
+ </section>
85
+ </div>
86
+
87
+ <section class="examples-panel" aria-label="Examples">
88
+ <span class="examples-label">Examples</span>
89
+ <div class="example-list" id="examples">
90
+ <button type="button" class="ex active" data-t="Update the account for david.miller@example.com: phone +1 415 555 0199, SSN 123-45-6789, card 4111 1111 1111 1111, passport C01X00T47.">Contact & ID</button>
91
+ <button type="button" class="ex" data-t="Please wire EUR 5,000 to IBAN DE89 3704 0044 0532 0130 00 (SWIFT COBADEFFXXX) from account 0532013000. My server is at 192.168.1.42.">Financial</button>
92
+ <button type="button" class="ex" data-t="Deploy creds: AWS key AKIAIOSFODNN7EXAMPLE, session token eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3In0.dozjgNryP4J3jVmNHl0w, and DB password Str0ngP@ssw0rd!.">Credentials</button>
93
+ <button type="button" class="ex" data-t="Company laptop MAC 00:1A:2B:3C:4D:5E connected from IP 10.0.0.5 near GPS 37.7749, -122.4194.">Device</button>
94
+ <button type="button" class="ex" data-t="Send 0.5 BTC to bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh or ETH to 0x742d35Cc6634C0532925a3b844Bc454e4438f44e. Rotate the API key sk-proj-T3BlbkFJ4aBcDeFgHiJkLmNoP.">Crypto & keys</button>
95
+ <button type="button" class="ex" data-t="Follow username jsmith on https://social.example.com/jsmith or email j.smith@example.com for details.">Online</button>
96
+ <button type="button" class="ex" data-t="Patient Sarah Connor, date of birth 03/14/1985, record number 887766, diagnosed with Type 2 diabetes and prescribed Metformin.">Healthcare</button>
97
+ <button type="button" class="ex" data-t="Ich heiße Hans Müller und wohne in der Hauptstraße 5, 80331 München. Erreichbar unter +49 30 4505 1234 oder hans.mueller@example.de.">Deutsch</button>
98
+ <button type="button" class="ex" data-t="Je m'appelle Jean Dupont, email jean.dupont@example.fr, téléphone +33 6 12 34 56 78, IBAN FR76 3000 6000 0112 3456 7890 189.">Français</button>
99
+ <button type="button" class="ex" data-t="María García, correo maria.garcia@example.es, teléfono +34 612 345 678, tarjeta 4111 1111 1111 1111.">Español</button>
100
+ <button type="button" class="ex" data-t="Иван Петров, email ivan.petrov@example.ru, телефон +7 495 123 45 67.">Русский</button>
101
+ <button type="button" class="ex" data-t="我叫张伟,电话号码是13800138000,邮箱是 zhang.wei@example.cn,住在北京市朝阳区建国路88号。">中文</button>
102
+ <button type="button" class="ex" data-t="田中太郎と申します。メールアドレスは taro.tanaka@example.jp、電話番号は 090-1234-5678 です。">日本語</button>
103
+ <button type="button" class="ex" data-t="제 이름은 김민수입니다. 이메일은 minsu.kim@example.kr, 전화번호는 010-1234-5678입니다.">한국어</button>
104
+ <button type="button" class="ex" data-t="اسمي محمد أحمد، بريدي الإلكتروني mohammed.ahmed@example.com، هاتفي +20 10 1234 5678.">العربية</button>
105
+ </div>
106
+ </section>
107
+ </main>
108
+ </div>
109
+
110
+ <script>
111
+ window.addEventListener("error", e => {
112
+ const el = document.getElementById("status");
113
+ if (el) el.textContent = "· ERROR: " + (e.message || (e.target && (e.target.src || e.target.href)) || "resource failed");
114
+ }, true);
115
+ </script>
116
+ <script type="module">
117
+ // The detector runs on the server (CPU, f32). This script drives the UI and asks
118
+ // /api/detect for char-anchored PII spans; highlighting, the redacted view, the
119
+ // legend, and stats are done here. Nothing is downloaded to the client.
120
+
121
+ const DOMAINS = ["identity","contact","financial","credential","online","device","location","healthcare","org","special","legal","other"];
122
+ const $ = (id) => document.getElementById(id);
123
+ const escapeHtml = (v) => v.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
124
+ const cap = (s) => s.charAt(0).toUpperCase() + s.slice(1);
125
+
126
+ function setStatus(message, tone = "loading") {
127
+ $("status").textContent = message;
128
+ const strip = $("model-strip");
129
+ strip.classList.remove("is-loading", "is-preparing", "is-ready", "is-error");
130
+ strip.classList.add(`is-${tone}`);
131
+ }
132
+ function setLoadProgress(value) {
133
+ const pct = Math.max(0, Math.min(100, Number(value) || 0));
134
+ $("load-progress").style.width = `${pct}%`;
135
+ $("load-percent").textContent = `${Math.round(pct)}%`;
136
+ document.querySelector(".load-track").setAttribute("aria-valuenow", String(Math.round(pct)));
137
+ }
138
+ let READY = false, timer = null, version = 0;
139
+ let lastText = "", lastResult = null, view = "highlight";
140
+
141
+ function renderHighlight(text, spans) {
142
+ let html = "", prev = 0, i = 0;
143
+ for (const sp of spans) {
144
+ html += escapeHtml(text.slice(prev, sp.start));
145
+ const t = escapeHtml(text.slice(sp.start, sp.end));
146
+ const tip = escapeHtml(`${sp.label} · ${sp.type}`);
147
+ html += `<span class="pii is-animated" style="--pii-color:var(--d-${sp.domain});--i:${i++}" title="${tip}">${t}</span>`;
148
+ prev = sp.end;
149
+ }
150
+ return html + escapeHtml(text.slice(prev));
151
+ }
152
+ function renderRedacted(redacted) {
153
+ return escapeHtml(redacted).replace(/\[([A-Z0-9_]+)\]/g, '<span class="redacted-tag">[$1]</span>');
154
+ }
155
+ function renderLegend(spans) {
156
+ const counts = {};
157
+ for (const sp of spans) counts[sp.domain] = (counts[sp.domain] || 0) + 1;
158
+ $("legend").innerHTML = DOMAINS.filter(d => counts[d]).map(d =>
159
+ `<span class="legend-chip" style="--pii-color:var(--d-${d})"><span class="legend-swatch" aria-hidden="true"></span>${cap(d)}<span class="legend-count">${counts[d]}</span></span>`
160
+ ).join("");
161
+ }
162
+ function paint() {
163
+ const out = $("output");
164
+ if (!lastResult || !lastText.trim()) {
165
+ out.className = "output-body";
166
+ out.innerHTML = '<div class="output-empty">Detected personal data will appear here.</div>';
167
+ return;
168
+ }
169
+ out.className = view === "redacted" ? "output-body is-redacted" : "output-body";
170
+ out.innerHTML = view === "redacted" ? renderRedacted(lastResult.redacted) : renderHighlight(lastText, lastResult.spans);
171
+ }
172
+
173
+ async function detect() {
174
+ if (!READY) return;
175
+ const runVersion = ++version;
176
+ const text = $("input").value;
177
+ if (!text.trim()) { lastText = text; lastResult = null; paint(); $("legend").innerHTML = ""; return; }
178
+ document.querySelector(".output-panel").classList.add("is-detecting");
179
+ let data;
180
+ try {
181
+ const resp = await fetch("api/detect", {
182
+ method: "POST", headers: { "Content-Type": "application/json" },
183
+ body: JSON.stringify({ text }),
184
+ });
185
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
186
+ data = await resp.json();
187
+ } catch (e) {
188
+ document.querySelector(".output-panel").classList.remove("is-detecting");
189
+ return;
190
+ }
191
+ if (runVersion !== version) return;
192
+ lastText = text; lastResult = data;
193
+ paint();
194
+ renderLegend(data.spans);
195
+ document.querySelector(".output-panel").classList.remove("is-detecting");
196
+ }
197
+
198
+ function detectSoon(delay = 350) { clearTimeout(timer); timer = setTimeout(() => detect(), delay); }
199
+
200
+ $("input").addEventListener("input", () => detectSoon());
201
+ $("view-toggle").addEventListener("click", (e) => {
202
+ const b = e.target.closest("button"); if (!b) return;
203
+ view = b.dataset.view;
204
+ $("view-toggle").querySelectorAll("button").forEach(x => x.classList.toggle("active", x === b));
205
+ paint();
206
+ });
207
+ $("examples").addEventListener("click", (e) => {
208
+ const b = e.target.closest(".ex"); if (!b) return;
209
+ $("examples").querySelectorAll(".ex").forEach(x => x.classList.toggle("active", x === b));
210
+ $("input").value = b.dataset.t;
211
+ detectSoon(40);
212
+ });
213
+
214
+ async function waitForServer() {
215
+ setStatus("Connecting to server…", "loading");
216
+ setLoadProgress(12);
217
+ for (let attempt = 0; attempt < 900; attempt++) {
218
+ try {
219
+ const r = await fetch("api/ready", { cache: "no-store" });
220
+ if (r.ok && (await r.json()).ready) return true;
221
+ } catch (e) { /* server still starting */ }
222
+ setStatus("Warming up model on the server…", "preparing");
223
+ setLoadProgress(Math.min(92, 12 + attempt * 4));
224
+ await new Promise((r) => setTimeout(r, 1000));
225
+ }
226
+ return false;
227
+ }
228
+
229
+ (async () => {
230
+ const ok = await waitForServer();
231
+ if (!ok) { setStatus("Model unavailable", "error"); setLoadProgress(100); $("load-percent").textContent = "Error"; return; }
232
+ READY = true;
233
+ setLoadProgress(100);
234
+ setStatus("Model ready", "ready");
235
+ await detect();
236
+ window.__PII_READY = true;
237
+ })();
238
+ </script>
239
+ </body>
240
+ </html>
lliquid.gif ADDED
pii.py ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """pii.py — server-side CPU PII detection with LiquidAI/LFM2.5-Encoder-350M-PII-Detector.
2
+
3
+ A bidirectional LFM2.5 phase-2 encoder (354M) with a token-classification head,
4
+ loaded from the Hub via its custom modeling code (trust_remote_code), plus the
5
+ model's own inference-time **hybrid regex decode** (pii_hybrid_decode.py +
6
+ context_cued.py, fetched from the same repo) — the decode the model is meant to
7
+ use. It covers 40 PII types across 11 domains and 16 languages.
8
+
9
+ f32 on CPU (not fp16: x86 has no fast half path; not int8: token-classification
10
+ boundaries are precision-sensitive) — the same reasoning as the spellchecker /
11
+ policy-linting Spaces. One forward per request. Nothing is downloaded to the
12
+ client; the model repo is private, so an HF_TOKEN with read access is required.
13
+ """
14
+
15
+ import os
16
+ import sys
17
+ import threading
18
+
19
+ from huggingface_hub import hf_hub_download
20
+ from transformers import AutoModelForTokenClassification, AutoTokenizer
21
+
22
+ MODEL_ID = os.environ.get("PII_MODEL", "LiquidAI/LFM2.5-Encoder-350M-PII-Detector")
23
+ HF_TOKEN = os.environ.get("HF_TOKEN")
24
+
25
+
26
+ def effective_cpus():
27
+ """Cores actually available to this process (cgroup quota, not the host)."""
28
+ try: # cgroup v2
29
+ raw = open("/sys/fs/cgroup/cpu.max").read().split()
30
+ if raw and raw[0] != "max":
31
+ return max(1, round(int(raw[0]) / int(raw[1])))
32
+ except (OSError, ValueError):
33
+ pass
34
+ try: # cgroup v1
35
+ q = int(open("/sys/fs/cgroup/cpu/cpu.cfs_quota_us").read())
36
+ p = int(open("/sys/fs/cgroup/cpu/cpu.cfs_period_us").read())
37
+ if q > 0:
38
+ return max(1, q // p)
39
+ except (OSError, ValueError):
40
+ pass
41
+ try:
42
+ return max(1, len(os.sched_getaffinity(0)))
43
+ except AttributeError:
44
+ return os.cpu_count() or 1
45
+
46
+
47
+ # --- label -> {domain (color), pretty label, redaction tag} ------------------
48
+ _ACRONYM = {"ssn", "iban", "id", "ip", "url", "api", "mac", "gps", "bic", "dob",
49
+ "pin", "vat", "cvv", "swift", "tax", "jwt", "imei"}
50
+ # Types are `domain.subtype` (label_schema.json), so the color bucket is just the
51
+ # prefix. `developer.*` is the exception: login_credentials -> credential, device_id
52
+ # -> device.
53
+ _DOMAINS = {"identity", "contact", "financial", "credential", "online", "device",
54
+ "location", "healthcare", "org", "special", "legal"}
55
+
56
+
57
+ def domain_for(raw):
58
+ pre = raw.split(".")[0].lower()
59
+ if pre in _DOMAINS:
60
+ return pre
61
+ if pre == "developer":
62
+ return "device" if "device" in raw.lower() else "credential"
63
+ return "other"
64
+
65
+
66
+ def pretty(raw):
67
+ base = raw.split(".")[-1]
68
+ words = base.replace("-", " ").replace("_", " ").split()
69
+ out = [w.upper() if w.lower() in _ACRONYM else w.capitalize() for w in words]
70
+ return " ".join(out) or raw
71
+
72
+
73
+ def tag_for(raw):
74
+ base = raw.split(".")[-1]
75
+ return "_".join(base.replace("-", " ").replace("_", " ").split()).upper() or "PII"
76
+
77
+
78
+ class PiiDetector:
79
+ def __init__(self):
80
+ self.lock = threading.Lock()
81
+ self.tok = AutoTokenizer.from_pretrained(MODEL_ID, trust_remote_code=True, token=HF_TOKEN)
82
+ self.model = AutoModelForTokenClassification.from_pretrained(
83
+ MODEL_ID, trust_remote_code=True, token=HF_TOKEN).float().eval()
84
+ # the model's shipped hybrid decode (+ its context_cued dependency); load
85
+ # both from the repo snapshot and import off that dir.
86
+ hd_path = hf_hub_download(MODEL_ID, "pii_hybrid_decode.py", token=HF_TOKEN)
87
+ hf_hub_download(MODEL_ID, "context_cued.py", token=HF_TOKEN)
88
+ sys.path.insert(0, os.path.dirname(hd_path))
89
+ import pii_hybrid_decode as hd
90
+ self.hd = hd
91
+
92
+ def detect(self, text):
93
+ """Return {spans, redacted, count}. spans: char-anchored, enriched, sorted,
94
+ non-overlapping. When a model span and a regex span cover the same text, the
95
+ regex (AUTH) type wins — it's the precise format (e.g. api_key/jwt over the
96
+ model's generic login_credentials)."""
97
+ with self.lock:
98
+ raw = self.hd.predict(text, self.tok, self.model) # [{start,end,type,text}]
99
+
100
+ auth = getattr(self.hd, "_AUTH_TYPES", set())
101
+ raw.sort(key=lambda s: (s["start"], 0 if s["type"] in auth else 1, -(s["end"] - s["start"])))
102
+ spans, last_end = [], -1
103
+ for sp in raw:
104
+ if sp["start"] < last_end or sp["end"] <= sp["start"]:
105
+ continue # drop overlaps
106
+ typ = sp["type"]
107
+ spans.append({
108
+ "start": sp["start"], "end": sp["end"], "text": text[sp["start"]:sp["end"]],
109
+ "type": typ, "domain": domain_for(typ), "label": pretty(typ), "tag": tag_for(typ),
110
+ })
111
+ last_end = sp["end"]
112
+
113
+ parts, prev = [], 0
114
+ for sp in spans:
115
+ parts.append(text[prev:sp["start"]])
116
+ parts.append(f"[{sp['tag']}]")
117
+ prev = sp["end"]
118
+ parts.append(text[prev:])
119
+ return {"spans": spans, "redacted": "".join(parts), "count": len(spans)}
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ transformers==5.11.0
2
+ tokenizers==0.22.2
3
+ safetensors==0.8.0
4
+ huggingface_hub==1.19.0
5
+ flask>=3.0
6
+ gunicorn>=21.2
style.css ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ --paper:#fff;
3
+ --ink:#050505;
4
+ --purple:#6c4fe0;
5
+ --lilac:#cd82f0;
6
+ --green:#2dbd70;
7
+ --red:#e45454;
8
+ --neutral-50:#fafafa;
9
+ --neutral-100:#f4f4f3;
10
+ --neutral-200:#e7e7e5;
11
+ --neutral-300:#b9b9b5;
12
+ --neutral-500:#737373;
13
+ --neutral-700:#404040;
14
+ --border:rgba(0,0,0,.09);
15
+ --sans:"Inter",-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
16
+ --serif:"Instrument Serif",Georgia,"Times New Roman",serif;
17
+ --mono:"JetBrains Mono",ui-monospace,SFMono-Regular,Menlo,monospace;
18
+ /* PII domain colors (one bucket per legend entry) */
19
+ --d-identity:#d8d0ff; --d-contact:#c9e9ff; --d-financial:#ccefdc; --d-credential:#ffd5e5;
20
+ --d-online:#cce8e8; --d-device:#ffe4ba; --d-location:#fff1ad; --d-healthcare:#ffd9d0;
21
+ --d-org:#e6dcc8; --d-special:#f0d0ff; --d-legal:#d5dae8; --d-other:#e7e7e5;
22
+ }
23
+
24
+ * { box-sizing:border-box; }
25
+ html { background:var(--neutral-50); }
26
+ body {
27
+ min-width:320px;
28
+ min-height:100vh;
29
+ margin:0;
30
+ background:var(--neutral-50);
31
+ color:var(--ink);
32
+ font:16px/1.45 var(--sans);
33
+ -webkit-font-smoothing:antialiased;
34
+ }
35
+ button,textarea,input { font:inherit; }
36
+ a { color:inherit; }
37
+
38
+ .wrap {
39
+ display:flex;
40
+ flex-direction:column;
41
+ width:min(calc(100% - 32px),1240px);
42
+ min-height:100vh;
43
+ min-height:100dvh;
44
+ margin:0 auto;
45
+ border-right:1px solid var(--border);
46
+ border-left:1px solid var(--border);
47
+ background:var(--paper);
48
+ }
49
+
50
+ .site-intro { margin-top:auto; padding:20px 30px 16px; }
51
+ h1 { margin:0 0 7px; font:400 clamp(40px,4.3vw,51px)/.98 var(--serif); letter-spacing:.002em; }
52
+ h1 em { color:var(--purple); font-weight:400; font-style:italic; }
53
+ .sub { max-width:900px; margin:0; color:var(--neutral-500); font-size:15px; line-height:1.45; }
54
+ .sub strong { color:var(--neutral-700); font-weight:600; }
55
+
56
+ .model-strip {
57
+ display:grid;
58
+ grid-template-columns:48px 280px minmax(240px,1fr);
59
+ align-items:center;
60
+ column-gap:16px;
61
+ min-height:76px;
62
+ padding:12px 30px;
63
+ border-top:1px solid var(--border);
64
+ border-bottom:1px solid var(--border);
65
+ background:linear-gradient(90deg,rgba(108,79,224,.018),rgba(205,130,240,.035),rgba(108,79,224,.018));
66
+ transition:background .3s ease;
67
+ }
68
+ .model-logo-frame { position:relative; display:block; width:48px; height:48px; overflow:hidden; }
69
+ .model-logo { position:absolute; top:50%; left:50%; display:block; width:56px; height:auto; max-width:none; transform:translate(-50%,-50%); image-rendering:pixelated; }
70
+ .model-copy { display:flex; min-width:0; align-items:center; justify-content:center; }
71
+ .model-name-row { display:flex; min-height:25px; align-items:center; gap:10px; white-space:nowrap; }
72
+ .model-copy a { font:400 22px/1.15 var(--serif); text-decoration:none; }
73
+ .model-copy a:hover { color:var(--purple); }
74
+ .model-variant { display:inline-flex; height:18px; align-items:center; box-sizing:border-box; padding:0 7px; border:1px solid rgba(108,79,224,.14); border-radius:999px; background:rgba(108,79,224,.05); color:var(--purple); font:700 9px/1 var(--mono); letter-spacing:.035em; text-transform:uppercase; }
75
+ .load-block { min-width:0; }
76
+ .load-meta { display:flex; align-items:center; justify-content:space-between; gap:14px; margin-bottom:8px; color:var(--neutral-500); font-size:12px; font-weight:600; }
77
+ #load-percent { font:600 11px/1 var(--mono); font-variant-numeric:tabular-nums; }
78
+ .load-track { height:7px; overflow:hidden; border-radius:999px; background:rgba(108,79,224,.09); }
79
+ .load-track span { display:block; width:0; height:100%; border-radius:inherit; background:linear-gradient(90deg,var(--purple),var(--lilac)); transition:width .18s ease; }
80
+ .is-ready .load-track { background:rgba(45,189,112,.09); }
81
+ .is-ready .load-track span { background:var(--green); }
82
+ .is-ready .load-meta { color:#188e50; }
83
+ .is-error .load-track span { background:var(--red); }
84
+ .is-error .load-meta { color:var(--red); }
85
+
86
+ main { display:flex; min-height:0; flex:0 0 auto; flex-direction:column; margin-bottom:auto; padding:18px 30px 22px; }
87
+ .pii-workspace { display:grid; grid-template-columns:minmax(360px,1fr) minmax(420px,1.05fr); align-items:stretch; gap:18px; }
88
+ .workspace-card { position:relative; display:flex; min-width:0; flex-direction:column; overflow:hidden; border:1px solid var(--border); border-radius:12px; background:var(--paper); padding:20px; }
89
+ .panel-header { display:flex; align-items:flex-start; justify-content:space-between; gap:16px; margin-bottom:15px; }
90
+ .panel-header h2 { margin:0 0 5px; color:var(--neutral-500); font:600 12px/1.25 var(--mono); letter-spacing:.08em; text-transform:uppercase; }
91
+ .panel-header p { margin:0; color:var(--neutral-500); font-size:13px; line-height:1.4; }
92
+
93
+ .editor-stage { position:relative; flex:1; min-height:clamp(230px,26dvh,320px); overflow:hidden; border:1px solid var(--border); border-radius:10px; background:var(--paper); transition:border-color .2s,box-shadow .2s; }
94
+ .editor-stage:focus-within { border-color:var(--purple); box-shadow:0 0 0 3px rgba(108,79,224,.11); }
95
+ #input { width:100%; height:100%; min-height:clamp(230px,26dvh,320px); padding:16px 17px; border:0; outline:0; background:transparent; color:var(--ink); font-size:16px; line-height:1.62; resize:none; }
96
+ #input::placeholder { color:var(--neutral-300); }
97
+
98
+ .output-stage { position:relative; flex:1; min-height:clamp(230px,26dvh,320px); overflow:auto; border:1px solid var(--border); border-radius:10px; background:var(--paper); }
99
+ .output-body { padding:16px 17px; font-size:16px; line-height:1.68; white-space:pre-wrap; word-break:break-word; }
100
+ .output-body.is-redacted { font-family:var(--mono); font-size:14px; line-height:1.7; color:var(--neutral-700); }
101
+ .output-empty { padding:16px 17px; color:var(--neutral-300); }
102
+ .is-detecting .output-stage { border-color:rgba(108,79,224,.28); box-shadow:0 0 0 3px rgba(108,79,224,.06); }
103
+
104
+ /* marker-pen highlight, colored per PII domain */
105
+ .pii { position:relative; z-index:0; display:inline-block; color:inherit; isolation:isolate; cursor:default; }
106
+ .pii::before {
107
+ content:""; position:absolute; z-index:-1; top:.13em; right:-.07em; bottom:.09em; left:-.07em;
108
+ border-radius:3px 5px 2px 4px;
109
+ background:
110
+ radial-gradient(ellipse at 16% 40%,rgba(255,255,255,.38) 0 7%,transparent 8% 100%),
111
+ repeating-linear-gradient(176deg,
112
+ color-mix(in srgb,var(--pii-color) 80%,white) 0 2px,
113
+ color-mix(in srgb,var(--pii-color) 92%,transparent) 2px 5px,
114
+ color-mix(in srgb,var(--pii-color) 74%,white) 5px 8px);
115
+ box-shadow:inset 0 1px rgba(255,255,255,.34);
116
+ clip-path:polygon(0 8%,3% 2%,27% 6%,52% 1%,78% 5%,100% 0,98% 94%,73% 98%,43% 95%,17% 100%,1% 94%);
117
+ mix-blend-mode:multiply; opacity:.92;
118
+ }
119
+ .pii.is-animated::before { transform:scaleX(0) rotate(-.3deg); transform-origin:left center; animation:marker-stroke .6s cubic-bezier(.18,.72,.18,1) both; animation-delay:calc(var(--i)*45ms); }
120
+ .pii .pii-tag { display:inline-block; margin-left:.28em; padding:0 5px; border-radius:999px; background:color-mix(in srgb,var(--pii-color) 62%,white); color:var(--neutral-700); font:700 8.5px/1.7 var(--mono); letter-spacing:.03em; text-transform:uppercase; vertical-align:.09em; }
121
+ .redacted-tag { padding:0 5px; border-radius:4px; background:var(--neutral-100); border:1px solid var(--border); color:var(--purple); font-weight:600; }
122
+
123
+ .view-toggle { display:inline-flex; padding:3px; border:1px solid var(--border); border-radius:999px; background:var(--neutral-50); }
124
+ .view-toggle button { padding:5px 11px; border:0; border-radius:999px; background:transparent; color:var(--neutral-500); font:600 11px/1 var(--mono); letter-spacing:.03em; text-transform:uppercase; cursor:pointer; transition:all .15s; }
125
+ .view-toggle button.active { background:var(--ink); color:var(--paper); }
126
+
127
+ .controls { margin-top:14px; }
128
+
129
+ .legend { display:flex; flex-wrap:wrap; gap:6px; margin-top:12px; }
130
+ .legend-chip { display:inline-flex; align-items:center; gap:6px; padding:4px 9px 4px 6px; border:1px solid var(--border); border-radius:999px; background:var(--paper); color:var(--neutral-700); font:600 11px/1 var(--sans); }
131
+ .legend-swatch { width:11px; height:11px; border-radius:4px; background:var(--pii-color); box-shadow:inset 0 0 0 1px rgba(0,0,0,.05); }
132
+ .legend-count { color:var(--neutral-500); font:600 10px/1 var(--mono); }
133
+
134
+ .examples-panel { display:flex; align-items:center; gap:15px; margin-top:16px; padding:15px 17px; overflow:hidden; border:1px solid var(--border); border-radius:11px; background:var(--neutral-50); }
135
+ .examples-label { flex:none; color:var(--neutral-500); font:600 11px/1.3 var(--mono); letter-spacing:.07em; text-transform:uppercase; }
136
+ .example-list { display:flex; flex-wrap:wrap; gap:7px; min-width:0; padding-top:2px; }
137
+ .ex { display:inline-flex; align-items:center; padding:8px 12px; border:1px solid var(--border); border-radius:999px; background:var(--paper); color:var(--neutral-700); font-size:12px; line-height:1.15; cursor:pointer; transition:background .15s,border-color .15s,color .15s,transform .15s,box-shadow .15s; }
138
+ .ex:hover { border-color:rgba(108,79,224,.28); color:var(--purple); transform:translateY(-1px); box-shadow:0 5px 12px rgba(54,42,105,.08); }
139
+ .ex.active { border-color:var(--ink); background:var(--ink); color:var(--paper); box-shadow:none; }
140
+ .sr-only { position:absolute!important; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; }
141
+
142
+ @keyframes marker-stroke { 0% { opacity:0; transform:scaleX(0) rotate(-.3deg); } 14% { opacity:.82; } 76% { opacity:1; transform:scaleX(1.03) rotate(-.3deg); } 100% { opacity:.92; transform:scaleX(1) rotate(-.3deg); } }
143
+
144
+ @media (max-width:980px) { .pii-workspace { grid-template-columns:1fr; } }
145
+ @media (max-width:720px) {
146
+ .wrap { width:100%; border:0; }
147
+ .site-intro { padding:20px; }
148
+ h1 { font-size:40px; }
149
+ .sub { font-size:14px; }
150
+ .model-strip { grid-template-columns:48px minmax(0,1fr); column-gap:16px; row-gap:10px; padding:12px 20px; }
151
+ .load-block { grid-column:1/-1; }
152
+ main { padding:15px 20px 20px; }
153
+ .examples-panel { align-items:flex-start; flex-direction:column; }
154
+ }
155
+ @media (max-width:460px) {
156
+ .model-name-row { align-items:flex-start; flex-direction:column; gap:2px; }
157
+ .panel-header { flex-direction:column; }
158
+ }
159
+ @media (prefers-reduced-motion:reduce) {
160
+ *,*::before,*::after { animation-duration:.01ms!important; animation-iteration-count:1!important; transition-duration:.01ms!important; }
161
+ }