Spaces:
Paused
Paused
Initial public commit
Browse filesCo-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>
- .dockerignore +6 -0
- .gitattributes +35 -0
- Dockerfile +30 -0
- README.md +28 -0
- app.py +67 -0
- index.html +240 -0
- lliquid.gif +0 -0
- pii.py +119 -0
- requirements.txt +6 -0
- 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, "&").replace(/</g, "<").replace(/>/g, ">");
|
| 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 |
+
}
|