Spaces:
Running on Zero
Running on Zero
| """ | |
| PII Reveal - Document Privacy Explorer | |
| ======================================= | |
| Uploads a PDF/DOC/DOCX, runs the openai/privacy-filter model over the | |
| extracted text, and returns per-span character offsets + stats for an | |
| interactive reader view. Also supports building a black-bar redacted PDF. | |
| Inference path: `transformers.pipeline("token-classification", | |
| "openai/privacy-filter", aggregation_strategy="simple")` β the pipeline | |
| takes care of BIOES β char-level span aggregation for us. | |
| PDF redaction (build_redacted_pdf_bytes) is optimized for large files: | |
| per-page `needle in page_text` prefilter before page.search_for, skip | |
| apply_redactions on pages with no matches, and save with garbage=1 to | |
| avoid the expensive stream-recompression pass. | |
| """ | |
| # ββ stdlib βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| import functools | |
| import io | |
| import json | |
| import os | |
| import re | |
| import tempfile | |
| import time | |
| from pathlib import Path | |
| # ββ third-party ββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| import gradio as gr | |
| import spaces | |
| import torch | |
| from fastapi.responses import HTMLResponse | |
| from gradio.data_classes import FileData | |
| # ββ configuration ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| PII_MODEL_REPO = os.getenv("MODEL_ID", "openai/privacy-filter") | |
| HF_TOKEN = os.getenv("HF_TOKEN", None) | |
| CATEGORIES_META = { | |
| "private_person": {"color": "#E24B4A", "cls": "hp", "label": "Person", "mono": False}, | |
| "private_date": {"color": "#7F77DD", "cls": "hd", "label": "Date", "mono": True}, | |
| "private_address": {"color": "#1D9E75", "cls": "ha", "label": "Address", "mono": False}, | |
| "private_email": {"color": "#378ADD", "cls": "he", "label": "Email", "mono": True}, | |
| "account_number": {"color": "#BA7517", "cls": "hac", "label": "Account", "mono": True}, | |
| "private_url": {"color": "#D85A30", "cls": "hu", "label": "URL", "mono": True}, | |
| "secret": {"color": "#D4537E", "cls": "hs", "label": "Secret", "mono": True}, | |
| "private_phone": {"color": "#639922", "cls": "hph", "label": "Phone", "mono": True}, | |
| } | |
| # ===================================================================== | |
| # MODEL INFERENCE (transformers pipeline β openai/privacy-filter) | |
| # ===================================================================== | |
| def get_pii_pipeline(): | |
| """Lazy-load the privacy filter on the GPU. Cached so repeated calls | |
| inside a single ZeroGPU slot don't re-move weights.""" | |
| from transformers import pipeline | |
| return pipeline( | |
| task="token-classification", | |
| model=PII_MODEL_REPO, | |
| aggregation_strategy="simple", # merges BIOES tags into char-level spans | |
| device=0, | |
| torch_dtype=torch.bfloat16, | |
| token=HF_TOKEN, | |
| ) | |
| def predict_text(text: str) -> tuple[str, list[dict]]: | |
| """Returns (source_text, spans). `spans` is a list of | |
| {label, start, end, text} with character offsets into `text`.""" | |
| if not text.strip(): | |
| return text, [] | |
| pipe = get_pii_pipeline() | |
| results = pipe(text) | |
| spans = [] | |
| for r in results: | |
| label = r.get("entity_group") or r.get("entity") | |
| if not label or label == "O": | |
| continue | |
| s, e = int(r["start"]), int(r["end"]) | |
| if e <= s or s < 0 or e > len(text): | |
| continue | |
| spans.append({"label": label, "start": s, "end": e, "text": text[s:e]}) | |
| return text, spans | |
| # ===================================================================== | |
| # APPLICATION LAYER | |
| # ===================================================================== | |
| def _sniff_suffix(path: str) -> str: | |
| """Detect file type from magic bytes when the filename extension is | |
| missing (Gradio's server-side temp path often drops the suffix).""" | |
| try: | |
| with open(path, "rb") as f: | |
| header = f.read(8) | |
| except OSError: | |
| return "" | |
| if header.startswith(b"%PDF-"): | |
| return ".pdf" | |
| if header.startswith(b"PK\x03\x04"): # zip container β .docx | |
| return ".docx" | |
| if header.startswith(b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1"): # OLE2 β legacy .doc | |
| return ".doc" | |
| return "" | |
| def _resolve_suffix(path: str, orig_name: str) -> str: | |
| """Pick the best available suffix: orig_name β path β magic bytes.""" | |
| for candidate in (orig_name, path): | |
| s = Path(candidate or "").suffix.lower() | |
| if s: | |
| return s | |
| return _sniff_suffix(path) | |
| def extract_text(file_path: str, suffix: str | None = None) -> str: | |
| suffix = (suffix or Path(file_path).suffix).lower() | |
| if suffix == ".pdf": | |
| import fitz | |
| doc = fitz.open(file_path) | |
| pages = [page.get_text() for page in doc] | |
| doc.close() | |
| return "\n\n".join(pages) | |
| elif suffix in (".docx", ".doc"): | |
| from docx import Document | |
| doc = Document(file_path) | |
| return "\n\n".join(p.text for p in doc.paragraphs if p.text.strip()) | |
| raise ValueError(f"Unsupported file type: {suffix}") | |
| def compute_stats(text, spans): | |
| total = len(text) | |
| pii_chars = sum(s["end"] - s["start"] for s in spans) | |
| by_cat = {} | |
| for s in spans: | |
| c = s["label"] | |
| by_cat.setdefault(c, {"count": 0, "chars": 0}) | |
| by_cat[c]["count"] += 1; by_cat[c]["chars"] += s["end"] - s["start"] | |
| return { | |
| "total_chars": total, "pii_chars": pii_chars, | |
| "pii_percentage": round(pii_chars / total * 100, 1) if total else 0, | |
| "total_spans": len(spans), "categories": by_cat, "num_categories": len(by_cat), | |
| "total_lines": text.count("\n") + 1 if total else 0, | |
| } | |
| def detect_speakers(text, spans): | |
| patterns = [r"^([A-Z][a-zA-Z ]{1,30}):\s", r"^\[([^\]]{1,30})\]\s", r"^(Speaker\s*\d+):\s"] | |
| line_sp, pos, cur = [], 0, None | |
| for line in text.split("\n"): | |
| for p in patterns: | |
| m = re.match(p, line) | |
| if m: cur = m.group(1).strip(); break | |
| line_sp.append((pos, pos + len(line), cur)); pos += len(line) + 1 | |
| result = {} | |
| for span in spans: | |
| mid = (span["start"] + span["end"]) // 2 | |
| speaker = "Document" | |
| for ls, le, sp in line_sp: | |
| if ls <= mid <= le and sp: speaker = sp; break | |
| result[speaker] = result.get(speaker, 0) + 1 | |
| return {} if list(result.keys()) == ["Document"] else result | |
| def run_pii_analysis(text: str): | |
| """GPU-accelerated PII detection.""" | |
| return predict_text(text) | |
| def build_redacted_pdf_bytes(pdf_path: str, pii_texts: list[str]) -> bytes: | |
| """ | |
| Fast PDF redaction via PyMuPDF. | |
| Perf notes vs the v5 implementation that ran for "several minutes": | |
| 1. Dedupe needles once; process longest first so full spans win | |
| over their substrings. | |
| 2. Pull each page's full text string ONCE, then do a cheap | |
| Python `needle in page_text` prefilter before ever calling | |
| page.search_for (which is the expensive call). This avoids the | |
| 100-page * 200-needle = 20k wasted search calls. | |
| 3. Skip apply_redactions on pages with no matches. | |
| 4. save(garbage=1, deflate=True) β garbage=4 in v5 recompressed | |
| every stream and dominated the save time on large docs. | |
| """ | |
| import fitz | |
| ordered = sorted( | |
| {t.strip() for t in pii_texts if t and len(t.strip()) >= 2}, | |
| key=len, reverse=True, | |
| ) | |
| if not ordered: | |
| # No needles β return the original untouched | |
| return Path(pdf_path).read_bytes() | |
| doc = fitz.open(pdf_path) | |
| try: | |
| for page in doc: | |
| page_text = page.get_text() | |
| if not page_text: | |
| continue | |
| needles = [t for t in ordered if t in page_text] | |
| if not needles: | |
| continue | |
| added = False | |
| for needle in needles: | |
| for rect in page.search_for(needle): | |
| page.add_redact_annot(rect, fill=(0, 0, 0)) | |
| added = True | |
| if added: | |
| page.apply_redactions() | |
| buf = io.BytesIO() | |
| doc.save(buf, garbage=1, deflate=True) | |
| return buf.getvalue() | |
| finally: | |
| doc.close() | |
| # ββ Gradio Server ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # | |
| # We only keep one plain FastAPI route here β the homepage, which | |
| # serves the static HTML shell. The heavy lifting endpoints are | |
| # declared with @server.api, which wraps them in Gradio's queue so | |
| # they compose correctly with @spaces.GPU on ZeroGPU and with the | |
| # gradio_client / @gradio/client SDKs. | |
| server = gr.Server() | |
| async def homepage(): | |
| return FRONTEND_HTML | |
| def analyze_document_api(file: FileData) -> dict: | |
| """Extract text from an uploaded PDF/DOC/DOCX and run the OPF | |
| privacy filter over it. Returns the detected spans, stats, | |
| per-speaker counts, and the category color/label table. | |
| Called from the browser via @gradio/client: | |
| client.predict("/analyze_document", { file: handle_file(f) }) | |
| And from Python via gradio_client: | |
| client.predict("/analyze_document", file=handle_file(path)) | |
| """ | |
| path = file.get("path") or "" | |
| orig_name = file.get("orig_name") or Path(path).name | |
| suffix = _resolve_suffix(path, orig_name) | |
| if suffix not in (".pdf", ".doc", ".docx"): | |
| return {"error": f"Unsupported: {suffix or '(no extension)'}. Use PDF, DOC, or DOCX."} | |
| try: | |
| text = extract_text(path, suffix=suffix) | |
| if not text.strip(): | |
| return {"error": "No text content found."} | |
| source_text, spans = run_pii_analysis(text) | |
| stats = compute_stats(source_text, spans) | |
| speakers = detect_speakers(source_text, spans) | |
| return { | |
| "filename": orig_name, | |
| "text": source_text, | |
| "spans": spans, | |
| "stats": stats, | |
| "speakers": speakers, | |
| "categories_meta": { | |
| k: {"color": v["color"], "cls": v["cls"], | |
| "label": v["label"], "mono": v["mono"]} | |
| for k, v in CATEGORIES_META.items() | |
| }, | |
| } | |
| except Exception as e: | |
| return {"error": str(e)} | |
| def redact_pdf_api(file: FileData, spans: str, active: str) -> dict: | |
| """Build a black-bar-redacted PDF from an uploaded PDF plus the | |
| list of spans the browser wants redacted. `spans` and `active` | |
| are JSON strings because the JS client serializes complex objects | |
| more predictably as strings than as nested dicts. | |
| Returns {"pdf": FileData, "elapsed_ms": int} so the caller can | |
| download the file and also display timing.""" | |
| path = file.get("path") or "" | |
| orig_name = file.get("orig_name") or Path(path).name | |
| suffix = _resolve_suffix(path, orig_name) | |
| if suffix != ".pdf": | |
| return {"error": "PDF redaction only accepts PDF input."} | |
| try: | |
| span_list = json.loads(spans) | |
| active_set = set(json.loads(active)) | |
| except Exception as e: | |
| return {"error": f"Invalid payload: {e}"} | |
| pii_texts = [ | |
| s.get("text", "") for s in span_list | |
| if s.get("label") in active_set | |
| ] | |
| if not pii_texts: | |
| return {"error": "No active categories selected β nothing to redact."} | |
| try: | |
| t0 = time.perf_counter() | |
| pdf_bytes = build_redacted_pdf_bytes(path, pii_texts) | |
| elapsed_ms = int((time.perf_counter() - t0) * 1000) | |
| except Exception as e: | |
| return {"error": str(e)} | |
| stem = Path(orig_name).stem or "document" | |
| out_path = Path(tempfile.gettempdir()) / f"{stem}.redacted.pdf" | |
| out_path.write_bytes(pdf_bytes) | |
| return { | |
| "pdf": FileData(path=str(out_path)), | |
| "elapsed_ms": elapsed_ms, | |
| } | |
| def analyze_text_api(text: str) -> dict: | |
| """Analyze raw text for PII β convenient for gradio_client users | |
| who don't want to build a PDF just to test the model.""" | |
| source_text, spans = run_pii_analysis(text) | |
| stats = compute_stats(source_text, spans) | |
| return {"text": source_text, "spans": spans, "stats": stats} | |
| # ββ Frontend HTML (v6) βββββββββββββββββββββββββββββββββββββββββββ | |
| FRONTEND_HTML = r"""<!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width,initial-scale=1"> | |
| <title>PII Reveal β Inspector</title> | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&family=Source+Serif+4:opsz,wght@8..60,400;8..60,500;8..60,600&display=swap" rel="stylesheet"> | |
| <style> | |
| :root{ | |
| --body-background-fill: #f6f6f7; | |
| --block-background-fill: #ffffff; | |
| --block-background-fill-2: #f1f1f3; | |
| --body-text-color: #0a0a0a; | |
| --body-text-color-subdued: #3f3f46; | |
| --body-text-color-faint: #6b7280; | |
| --border-color-primary: #e4e4e7; | |
| --border-color-accent: #d4d4d8; | |
| --primary-bg: #18181b; | |
| --primary-fg: #ffffff; | |
| --h-alpha: 16%; | |
| --shadow-xs: 0 1px 1.5px rgba(10,10,10,.04); | |
| --shadow-sm: 0 1px 3px rgba(10,10,10,.06), 0 1px 2px rgba(10,10,10,.04); | |
| --shadow-md: 0 4px 14px rgba(10,10,10,.07), 0 1px 3px rgba(10,10,10,.04); | |
| --border-radius-lg: 10px; | |
| --border-radius-md: 6px; | |
| --border-radius-sm: 4px; | |
| --font-sans: 'Inter', system-ui, -apple-system, 'Segoe UI', sans-serif; | |
| --font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; | |
| --font-serif: 'Source Serif 4', 'Source Serif Pro', 'Iowan Old Style', Georgia, serif; | |
| } | |
| @media (prefers-color-scheme: dark){ | |
| :root{ | |
| --body-background-fill: #0e0e11; | |
| --block-background-fill: #18181c; | |
| --block-background-fill-2: #1f1f24; | |
| --body-text-color: #e8e8ea; | |
| --body-text-color-subdued: #a8a8ae; | |
| --body-text-color-faint: #70707a; | |
| --border-color-primary: rgba(255,255,255,0.08); | |
| --border-color-accent: rgba(255,255,255,0.18); | |
| --primary-bg: #f0f0f2; | |
| --primary-fg: #0e0e11; | |
| --h-alpha: 15%; | |
| --shadow-xs: none; | |
| --shadow-sm: none; | |
| --shadow-md: none; | |
| } | |
| } | |
| .dark, .dark :root, html.dark, body.dark{ | |
| --body-background-fill: #0e0e11; | |
| --block-background-fill: #18181c; | |
| --block-background-fill-2: #1f1f24; | |
| --body-text-color: #e8e8ea; | |
| --body-text-color-subdued: #a8a8ae; | |
| --body-text-color-faint: #70707a; | |
| --border-color-primary: rgba(255,255,255,0.08); | |
| --border-color-accent: rgba(255,255,255,0.18); | |
| --primary-bg: #f0f0f2; | |
| --primary-fg: #0e0e11; | |
| --h-alpha: 15%; | |
| --shadow-xs: none; | |
| --shadow-sm: none; | |
| --shadow-md: none; | |
| } | |
| *,*::before,*::after{box-sizing:border-box;margin:0;padding:0} | |
| html,body{height:100%} | |
| body{ | |
| font-family:var(--font-sans); | |
| background:var(--body-background-fill); | |
| color:var(--body-text-color); | |
| font-size:13.5px;line-height:1.5; | |
| -webkit-font-smoothing:antialiased; | |
| font-feature-settings:"cv11","ss01"; | |
| } | |
| button{font:inherit;color:inherit;background:transparent;border:0;cursor:pointer} | |
| .sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0} | |
| .caps{font-size:11px;font-weight:500;letter-spacing:0.06em;text-transform:uppercase;color:var(--body-text-color-subdued)} | |
| .shell{max-width:1080px;margin:0 auto;padding:40px 16px 48px} | |
| /* ============ upload view ============ */ | |
| #upload-view{min-height:100vh;display:flex;align-items:center;justify-content:center} | |
| #upload-view .shell{width:100%} | |
| .u-card{ | |
| display:grid;grid-template-columns:1.05fr 0.95fr;gap:0; | |
| background:var(--block-background-fill); | |
| border:0.5px solid var(--border-color-primary); | |
| border-radius:var(--border-radius-lg); | |
| overflow:hidden;box-shadow:var(--shadow-md); | |
| } | |
| .u-left{padding:40px 36px 34px} | |
| .u-right{padding:40px 36px 34px;background:var(--block-background-fill-2);border-left:0.5px solid var(--border-color-primary);display:flex;flex-direction:column;gap:14px} | |
| .u-brand{display:flex;align-items:center;gap:10px;margin-bottom:24px} | |
| .u-brand svg{color:var(--body-text-color)} | |
| .u-brand-name{font-size:13.5px;font-weight:500} | |
| .u-brand-name .sub{color:var(--body-text-color-faint);font-weight:400;margin-left:4px} | |
| .u-title{font-family:var(--font-serif);font-size:30px;font-weight:500;letter-spacing:-0.018em;line-height:1.15;margin-bottom:10px;color:var(--body-text-color)} | |
| .u-sub{color:var(--body-text-color-subdued);font-size:14px;margin-bottom:20px;max-width:42ch} | |
| .u-chips{display:flex;flex-wrap:wrap;gap:6px 12px;margin-bottom:24px} | |
| .u-chip{display:inline-flex;align-items:center;gap:6px;font-size:12px;color:var(--body-text-color-subdued);font-weight:500} | |
| .u-chip-dot{width:7px;height:7px;border-radius:2px} | |
| .u-drop{ | |
| border:1px solid var(--border-color-primary); | |
| background:color-mix(in srgb, var(--body-text-color) 2.5%, transparent); | |
| border-radius:var(--border-radius-md); | |
| padding:30px 20px;cursor:pointer;text-align:center; | |
| transition:background .15s,border-color .15s;position:relative; | |
| } | |
| .u-drop:hover,.u-drop.dragover{background:color-mix(in srgb, var(--body-text-color) 5%, transparent);border-color:var(--border-color-accent)} | |
| .u-drop-icon{margin:0 auto 8px;color:var(--body-text-color-subdued)} | |
| .u-drop-title{font-size:13.5px;font-weight:500;margin-bottom:3px;color:var(--body-text-color)} | |
| .u-drop-sub{font-family:var(--font-mono);font-size:11px;color:var(--body-text-color-faint)} | |
| .u-drop input{position:absolute;inset:0;opacity:0;cursor:pointer} | |
| .u-meta{display:flex;flex-wrap:wrap;align-items:center;margin-top:22px;font-family:var(--font-mono);font-size:11px;color:var(--body-text-color-faint)} | |
| .u-meta > span{padding:0 12px;border-right:1px solid var(--border-color-primary);line-height:1} | |
| .u-meta > span:first-child{padding-left:0} | |
| .u-meta > span:last-child{border-right:0;padding-right:0} | |
| .prev-h{margin-bottom:8px} | |
| .prev-row{display:grid;grid-template-columns:1fr 16px 1fr;gap:10px;align-items:stretch} | |
| .prev-arrow{align-self:center;color:var(--body-text-color-faint);font-family:var(--font-mono);font-size:12px;text-align:center} | |
| .prev-card{background:var(--block-background-fill);border:0.5px solid var(--border-color-primary);border-radius:var(--border-radius-md);padding:14px 14px 12px;font-family:var(--font-serif);font-size:12.5px;line-height:1.65;color:var(--body-text-color);min-height:148px;box-shadow:var(--shadow-xs)} | |
| .prev-label{font-family:var(--font-sans);font-size:10px;font-weight:500;letter-spacing:0.08em;text-transform:uppercase;color:var(--body-text-color-faint);display:block;margin-bottom:8px} | |
| .prev-card p{margin:0 0 6px} | |
| .prev-card p:last-child{margin-bottom:0} | |
| .prev-bar{display:inline-block;vertical-align:middle;height:0.85em;border-radius:2px;background:var(--body-text-color);opacity:.88;margin:0 1px} | |
| .u-stat{margin-top:auto;padding-top:14px;border-top:0.5px solid var(--border-color-primary);display:flex;align-items:baseline;gap:8px;color:var(--body-text-color-subdued);font-size:12px} | |
| .u-stat b{font-family:var(--font-serif);font-weight:500;font-size:18px;color:var(--body-text-color);letter-spacing:-0.01em} | |
| /* ============ results view ============ */ | |
| #results-view{display:none;min-height:100vh} | |
| .pr-app{ | |
| font-family:var(--font-sans); | |
| border:0.5px solid var(--border-color-primary); | |
| border-radius:var(--border-radius-lg); | |
| overflow:hidden;background:var(--block-background-fill); | |
| color:var(--body-text-color);box-shadow:var(--shadow-md); | |
| } | |
| .pr-top{display:flex;align-items:center;gap:10px;flex-wrap:wrap;padding:11px 14px;border-bottom:0.5px solid var(--border-color-primary)} | |
| .pr-logo{display:flex;align-items:center;gap:8px} | |
| .pr-name{font-size:13.5px;font-weight:500} | |
| .pr-name-sub{color:var(--body-text-color-faint);font-weight:400;margin-left:4px} | |
| .pr-file-chip{font-family:var(--font-mono);font-size:11.5px;color:var(--body-text-color-subdued);padding:4px 8px;background:var(--block-background-fill-2);border:0.5px solid var(--border-color-primary);border-radius:5px;margin-left:4px;max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} | |
| .pr-grow{flex:1} | |
| .pr-status{font-size:11.5px;color:var(--body-text-color-subdued);display:flex;align-items:center;gap:6px} | |
| .pr-status-dot{width:6px;height:6px;border-radius:50%;background:#1D9E75;box-shadow:0 0 0 3px color-mix(in srgb, #1D9E75 18%, transparent)} | |
| .pr-top-actions{display:flex;align-items:center;gap:6px;flex-wrap:wrap} | |
| .pr-btn{ | |
| font-size:12px;padding:6px 10px; | |
| border:0.5px solid var(--border-color-accent); | |
| border-radius:5px;background:var(--block-background-fill); | |
| color:var(--body-text-color);cursor:pointer; | |
| font-family:inherit;font-weight:500; | |
| display:inline-flex;align-items:center;gap:6px; | |
| transition:background .12s,border-color .12s,color .12s; | |
| } | |
| .pr-btn:hover:not(:disabled){background:color-mix(in srgb, var(--body-text-color) 4%, var(--block-background-fill));border-color:var(--body-text-color-subdued)} | |
| .pr-btn:disabled{opacity:.5;cursor:not-allowed} | |
| .pr-btn-ghost{border-color:var(--border-color-primary);color:var(--body-text-color-subdued);background:transparent;font-weight:400} | |
| .pr-btn-ghost:hover:not(:disabled){color:var(--body-text-color);border-color:var(--border-color-accent);background:color-mix(in srgb, var(--body-text-color) 3%, transparent)} | |
| .pr-btn-prim{background:var(--primary-bg);color:var(--primary-fg);border-color:var(--primary-bg);font-weight:500} | |
| .pr-btn-prim:hover:not(:disabled){background:color-mix(in srgb, var(--primary-bg) 88%, var(--body-text-color));border-color:var(--primary-bg)} | |
| .pr-btn-arr{font-family:var(--font-mono);font-size:11px;opacity:0.6} | |
| .pr-stats{padding:18px 18px 16px;border-bottom:0.5px solid var(--border-color-primary)} | |
| .pr-stats-row{display:flex;align-items:flex-end;gap:34px;margin-bottom:14px;flex-wrap:wrap} | |
| .pr-hero{font-size:34px;font-weight:600;line-height:1;letter-spacing:-0.028em;font-variant-numeric:tabular-nums;color:var(--body-text-color)} | |
| .pr-hero-pct{font-size:18px;opacity:0.5;margin-left:1px;font-weight:400} | |
| .pr-num{font-size:21px;font-weight:600;line-height:1;letter-spacing:-0.015em;font-variant-numeric:tabular-nums} | |
| .pr-lab{margin-top:10px} | |
| .pr-bar{display:flex;height:4px;gap:2px;margin-bottom:12px;border-radius:2px;overflow:hidden} | |
| .pr-bar > span{display:block;height:100%;border-radius:1px;min-width:4px;transition:opacity .15s} | |
| .pr-bar > span:hover{opacity:.82} | |
| .pr-legend{display:flex;flex-wrap:wrap;gap:8px 14px;font-size:12px} | |
| .pr-leg{display:flex;align-items:center;gap:6px;color:var(--body-text-color-subdued);cursor:pointer;user-select:none;font-weight:500} | |
| .pr-leg-sw{width:8px;height:8px;border-radius:2px} | |
| .pr-leg-ct{font-family:var(--font-mono);font-size:11px;color:var(--body-text-color-faint);margin-left:1px;font-weight:500} | |
| .pr-leg.off{opacity:.4} | |
| .pr-leg.off .pr-leg-sw{opacity:.3} | |
| .pr-body{display:grid;grid-template-columns:minmax(0,1fr) 220px} | |
| .pr-doc-pane{padding:20px 24px 28px;border-right:0.5px solid var(--border-color-primary);min-width:0;max-height:calc(100vh - 260px);overflow-y:auto} | |
| .pr-doc-meta{font-family:var(--font-mono);font-size:11px;color:var(--body-text-color-faint);margin-bottom:16px;display:flex;gap:10px;flex-wrap:wrap} | |
| .pr-doc-meta span + span::before{content:'Β·';margin-right:10px;color:var(--border-color-accent)} | |
| .pr-text{font-family:var(--font-serif);font-size:15px;line-height:1.9;color:var(--body-text-color);white-space:pre-wrap;word-wrap:break-word;font-feature-settings:"liga","calt"} | |
| .h{padding:1px 1px;border-bottom:1.5px solid;transition:background .15s,opacity .15s;cursor:pointer} | |
| .h:hover{filter:brightness(0.96) saturate(1.12)} | |
| .h.off{background:transparent !important;border-color:transparent !important;color:inherit;opacity:.9} | |
| .hp {background:color-mix(in srgb, #E24B4A var(--h-alpha), transparent); border-color:#E24B4A} | |
| .hd {background:color-mix(in srgb, #7F77DD var(--h-alpha), transparent); border-color:#7F77DD} | |
| .ha {background:color-mix(in srgb, #1D9E75 var(--h-alpha), transparent); border-color:#1D9E75} | |
| .he {background:color-mix(in srgb, #378ADD var(--h-alpha), transparent); border-color:#378ADD} | |
| .hac {background:color-mix(in srgb, #BA7517 var(--h-alpha), transparent); border-color:#BA7517} | |
| .hu {background:color-mix(in srgb, #D85A30 var(--h-alpha), transparent); border-color:#D85A30} | |
| .hs {background:color-mix(in srgb, #D4537E var(--h-alpha), transparent); border-color:#D4537E} | |
| .hph {background:color-mix(in srgb, #639922 var(--h-alpha), transparent); border-color:#639922} | |
| .m{font-family:var(--font-mono);font-size:13px} | |
| .pr-side{background:var(--block-background-fill-2);padding:16px 14px;display:flex;flex-direction:column;gap:20px;min-width:0} | |
| .pr-side-head{display:flex;align-items:baseline;justify-content:space-between;gap:8px;margin-bottom:8px} | |
| .pr-side-link{font-size:11px;color:var(--body-text-color-subdued);cursor:pointer;background:transparent;border:0;padding:0;font-family:inherit;font-weight:500} | |
| .pr-side-link:hover{color:var(--body-text-color);text-decoration:underline} | |
| .pr-cat{position:relative;display:grid;grid-template-columns:9px 1fr auto;column-gap:8px;row-gap:4px;align-items:center;padding:8px 10px 7px;border-radius:var(--border-radius-sm);background:color-mix(in srgb, var(--body-text-color) 3%, transparent);border:0.5px solid transparent;cursor:pointer;user-select:none;transition:background .12s,border-color .12s,opacity .15s;margin-bottom:4px;overflow:hidden} | |
| .pr-cat:hover{border-color:var(--border-color-accent)} | |
| .pr-cat-sw{width:9px;height:9px;border-radius:2px;flex-shrink:0;grid-row:1} | |
| .pr-cat-nm{grid-row:1;color:var(--body-text-color);font-size:12.5px;font-weight:500} | |
| .pr-cat-ct{grid-row:1;font-family:var(--font-mono);font-size:11px;color:var(--body-text-color-faint);text-align:right;font-weight:500} | |
| .pr-cat-mini{grid-column:2/4;grid-row:2;height:1.5px;width:100%;background:color-mix(in srgb, var(--body-text-color) 6%, transparent);border-radius:1px;overflow:hidden} | |
| .pr-cat-mini > span{display:block;height:100%;border-radius:1px;transition:width .2s,background .15s} | |
| .pr-cat.on{background:color-mix(in srgb, var(--cat) 9%, transparent);box-shadow:inset 3px 0 0 0 var(--cat);padding-left:13px} | |
| .pr-cat.on .pr-cat-nm{color:var(--body-text-color)} | |
| .pr-cat.off{opacity:.42;filter:saturate(.35)} | |
| .pr-cat.off .pr-cat-nm{text-decoration:line-through} | |
| .pr-cat.off .pr-cat-mini > span{background:var(--body-text-color-faint) !important} | |
| .pr-speakers .pr-cat{cursor:default;background:transparent;border-color:transparent;padding:4px 2px} | |
| .pr-speakers .pr-cat:hover{background:transparent;border-color:transparent} | |
| .pr-speakers .pr-cat-sw{background:var(--body-text-color-faint);opacity:.55} | |
| .pr-speakers .pr-cat-mini{display:none} | |
| .empty-rail{color:var(--body-text-color-faint);font-size:12px;font-style:italic} | |
| #loading{position:fixed;inset:0;background:color-mix(in srgb, var(--body-background-fill) 88%, transparent);backdrop-filter:blur(8px);display:none;flex-direction:column;align-items:center;justify-content:center;gap:10px;z-index:9999} | |
| .l-ring{width:26px;height:26px;border:1.5px solid var(--border-color-accent);border-top-color:var(--body-text-color);border-radius:50%;animation:sp .7s linear infinite} | |
| @keyframes sp{to{transform:rotate(360deg)}} | |
| .l-label{font-family:var(--font-mono);font-size:11.5px;color:var(--body-text-color-subdued)} | |
| .l-timer{font-family:var(--font-mono);font-size:11px;color:var(--body-text-color-faint);font-variant-numeric:tabular-nums} | |
| .error-banner{margin:14px 18px 0;padding:10px 14px;background:color-mix(in srgb, #E24B4A 10%, transparent);border:0.5px solid color-mix(in srgb, #E24B4A 45%, transparent);border-radius:var(--border-radius-md);color:#C43A39;font-size:12.5px;display:none;font-weight:500} | |
| .tip{position:fixed;z-index:9998;font-family:var(--font-mono);font-size:11px;color:var(--primary-fg);background:var(--primary-bg);padding:4px 8px;border-radius:4px;pointer-events:none;white-space:nowrap;max-width:420px;overflow:hidden;text-overflow:ellipsis} | |
| @media(max-width:880px){ | |
| .u-card{grid-template-columns:1fr} | |
| .u-right{border-left:0;border-top:0.5px solid var(--border-color-primary)} | |
| .pr-body{grid-template-columns:1fr} | |
| .pr-doc-pane{border-right:none;border-bottom:0.5px solid var(--border-color-primary);max-height:none} | |
| } | |
| @media(max-width:640px){.shell{padding:24px 12px}} | |
| </style> | |
| </head> | |
| <body> | |
| <!-- ============ upload view ============ --> | |
| <div id="upload-view"> | |
| <div class="shell"> | |
| <div class="u-card"> | |
| <div class="u-left"> | |
| <div class="u-brand"> | |
| <svg width="20" height="20" viewBox="0 0 20 20" fill="none"> | |
| <rect x="0" y="0" width="20" height="20" rx="5" fill="currentColor"/> | |
| <circle cx="8.5" cy="8.5" r="3.2" stroke="var(--block-background-fill)" stroke-width="1.4" fill="none"/> | |
| <line x1="11.2" y1="11.2" x2="14.2" y2="14.2" stroke="var(--block-background-fill)" stroke-width="1.4" stroke-linecap="round"/> | |
| </svg> | |
| <span class="u-brand-name">PII Reveal<span class="sub">/ inspector</span></span> | |
| </div> | |
| <h1 class="u-title">See what your documents are leaking.</h1> | |
| <p class="u-sub">Find every PII span in a PDF, DOC or DOCX β names, accounts, secrets and five other entity types β then export a fully redacted copy.</p> | |
| <div class="u-chips"> | |
| <span class="u-chip"><span class="u-chip-dot" style="background:#E24B4A"></span>Person</span> | |
| <span class="u-chip"><span class="u-chip-dot" style="background:#378ADD"></span>Email</span> | |
| <span class="u-chip"><span class="u-chip-dot" style="background:#7F77DD"></span>Date</span> | |
| <span class="u-chip"><span class="u-chip-dot" style="background:#1D9E75"></span>Address</span> | |
| <span class="u-chip"><span class="u-chip-dot" style="background:#BA7517"></span>Account</span> | |
| <span class="u-chip"><span class="u-chip-dot" style="background:#D85A30"></span>URL</span> | |
| <span class="u-chip"><span class="u-chip-dot" style="background:#639922"></span>Phone</span> | |
| <span class="u-chip"><span class="u-chip-dot" style="background:#D4537E"></span>Secret</span> | |
| </div> | |
| <div class="u-drop" id="dropzone"> | |
| <div class="u-drop-icon"> | |
| <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"> | |
| <path d="M12 3v13"/><path d="m6 9 6-6 6 6"/><path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2"/> | |
| </svg> | |
| </div> | |
| <div class="u-drop-title">Drop a document, or click to browse</div> | |
| <div class="u-drop-sub">pdf · doc · docx · up to 128k tokens</div> | |
| <input type="file" id="file-input" accept=".pdf,.doc,.docx"> | |
| </div> | |
| <div class="u-meta"> | |
| <span>openai privacy filter</span> | |
| <span>128k ctx</span> | |
| <span>bfloat16</span> | |
| <span>apache 2.0</span> | |
| </div> | |
| </div> | |
| <div class="u-right" aria-hidden="true"> | |
| <div class="prev-h caps">Before → after</div> | |
| <div class="prev-row"> | |
| <div class="prev-card"> | |
| <span class="prev-label">detected</span> | |
| <p>Reporter: <span class="h hp">Dr. Margaret Holloway-Chen</span> called at <span class="h hd m">03:42 GMT</span>.</p> | |
| <p>Email: <span class="h he m">margaret.h@protomail.co.uk</span>.</p> | |
| <p>Token: <span class="h hs m">sk_live_T3sT4zN9pQ2v</span>.</p> | |
| </div> | |
| <div class="prev-arrow">→</div> | |
| <div class="prev-card"> | |
| <span class="prev-label">redacted</span> | |
| <p>Reporter: <span class="prev-bar" style="width:11em"></span> called at <span class="prev-bar" style="width:3.5em"></span>.</p> | |
| <p>Email: <span class="prev-bar" style="width:9em"></span>.</p> | |
| <p>Token: <span class="prev-bar" style="width:7em"></span>.</p> | |
| </div> | |
| </div> | |
| <div class="u-stat"> | |
| <b>PDF-ready</b> | |
| <span>export a redacted PDF or .txt with one click</span> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- ============ results view ============ --> | |
| <div id="results-view"> | |
| <div class="shell"> | |
| <div class="pr-app" aria-label="PII Reveal inspector"> | |
| <div class="pr-top"> | |
| <div class="pr-logo"> | |
| <svg width="20" height="20" viewBox="0 0 20 20" fill="none" style="color: var(--body-text-color);"> | |
| <rect x="0" y="0" width="20" height="20" rx="5" fill="currentColor"/> | |
| <circle cx="8.5" cy="8.5" r="3.2" stroke="var(--block-background-fill)" stroke-width="1.4" fill="none"/> | |
| <line x1="11.2" y1="11.2" x2="14.2" y2="14.2" stroke="var(--block-background-fill)" stroke-width="1.4" stroke-linecap="round"/> | |
| </svg> | |
| <span class="pr-name">PII Reveal<span class="pr-name-sub">/ inspector</span></span> | |
| </div> | |
| <span class="pr-file-chip" id="file-chip"></span> | |
| <span class="pr-status" id="scan-status"><span class="pr-status-dot"></span>Scan complete</span> | |
| <div class="pr-grow"></div> | |
| <div class="pr-top-actions"> | |
| <button class="pr-btn pr-btn-ghost" id="act-copy" title="Copy masked text to clipboard"><span>Copy masked</span></button> | |
| <button class="pr-btn pr-btn-ghost" id="act-report" title="Download JSON report"><span>Report</span></button> | |
| <button class="pr-btn" id="act-txt" title="Download sanitized .txt"><span>.txt</span></button> | |
| <button class="pr-btn pr-btn-prim" id="act-pdf" title="Download redacted PDF"><span>Redact PDF</span><span class="pr-btn-arr">→</span></button> | |
| <button class="pr-btn pr-btn-ghost" id="btn-new"><span>New file</span></button> | |
| </div> | |
| </div> | |
| <div class="error-banner" id="error-banner"></div> | |
| <div class="pr-stats"> | |
| <div class="pr-stats-row"> | |
| <div> | |
| <div class="pr-hero"><span id="hero-val">0</span><span class="pr-hero-pct">%</span></div> | |
| <div class="caps pr-lab">PII content</div> | |
| </div> | |
| <div> | |
| <div class="pr-num" id="num-spans">0</div> | |
| <div class="caps pr-lab">Spans detected</div> | |
| </div> | |
| <div> | |
| <div class="pr-num" id="num-cats">0 / 8</div> | |
| <div class="caps pr-lab">Categories present</div> | |
| </div> | |
| <div> | |
| <div class="pr-num" id="num-speakers">0</div> | |
| <div class="caps pr-lab">Speakers identified</div> | |
| </div> | |
| </div> | |
| <div class="pr-bar" id="dist-bar"></div> | |
| <div class="pr-legend" id="legend"></div> | |
| </div> | |
| <div class="pr-body"> | |
| <div class="pr-doc-pane"> | |
| <div class="pr-doc-meta" id="doc-meta"></div> | |
| <div class="pr-text" id="doc-text"></div> | |
| </div> | |
| <aside class="pr-side"> | |
| <div> | |
| <div class="pr-side-head"> | |
| <span class="caps">Filter categories</span> | |
| <button class="pr-side-link" id="cat-toggle-all">Clear all</button> | |
| </div> | |
| <div id="cat-list"></div> | |
| </div> | |
| <div id="speakers-block" style="display:none"> | |
| <div class="pr-side-head"><span class="caps">Speakers</span></div> | |
| <div class="pr-speakers" id="speakers-list"></div> | |
| </div> | |
| </aside> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="loading"> | |
| <div class="l-ring"></div> | |
| <div class="l-label" id="loading-label">scanning document…</div> | |
| <div class="l-timer" id="loading-timer"></div> | |
| </div> | |
| <div class="tip" id="tip" style="display:none"></div> | |
| <script type="module"> | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // Gradio JS client β /api/analyze and /api/redact-pdf were plain | |
| // FastAPI routes in the old version, which meant requests bypassed | |
| // Gradio's queue entirely. Now the backend exposes @server.api | |
| // routes and we call them through the Client, which gives us queue | |
| // serialization, progress events, and correct ZeroGPU allocation | |
| // via @spaces.GPU. | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| import { Client, handle_file } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js"; | |
| const clientPromise = Client.connect(window.location.origin); | |
| const S = { | |
| text:'', spans:[], stats:{}, speakers:{}, catMeta:{}, filename:'', file:null, | |
| activeCats:new Set(), scanMs:0, sortedSpans:[], | |
| }; | |
| const DEFAULT_META = { | |
| private_person: {color:'#E24B4A', cls:'hp', label:'Person', mono:false}, | |
| private_date: {color:'#7F77DD', cls:'hd', label:'Date', mono:true}, | |
| private_address: {color:'#1D9E75', cls:'ha', label:'Address', mono:false}, | |
| private_email: {color:'#378ADD', cls:'he', label:'Email', mono:true}, | |
| account_number: {color:'#BA7517', cls:'hac', label:'Account', mono:true}, | |
| private_url: {color:'#D85A30', cls:'hu', label:'URL', mono:true}, | |
| secret: {color:'#D4537E', cls:'hs', label:'Secret', mono:true}, | |
| private_phone: {color:'#639922', cls:'hph', label:'Phone', mono:true}, | |
| }; | |
| const ORDER = ['private_person','private_address','private_email','private_phone', | |
| 'private_url','private_date','account_number','secret']; | |
| const metaFor = c => ({...(DEFAULT_META[c]||{color:'#999',cls:'',label:c,mono:false}), ...(S.catMeta[c]||{})}); | |
| const isPdf = () => (S.filename||'').toLowerCase().endsWith('.pdf'); | |
| /* ===== loading overlay with live timer ===== */ | |
| let _loadingTimer = null; | |
| function showLoading(label){ | |
| document.getElementById('loading-label').textContent = label; | |
| document.getElementById('loading-timer').textContent = '0.0s'; | |
| document.getElementById('loading').style.display = 'flex'; | |
| const t0 = performance.now(); | |
| clearInterval(_loadingTimer); | |
| _loadingTimer = setInterval(() => { | |
| const s = (performance.now() - t0) / 1000; | |
| document.getElementById('loading-timer').textContent = s.toFixed(1) + 's'; | |
| }, 100); | |
| } | |
| function hideLoading(){ | |
| clearInterval(_loadingTimer); _loadingTimer = null; | |
| document.getElementById('loading').style.display = 'none'; | |
| } | |
| /* ===== upload flow ===== */ | |
| const dz = document.getElementById('dropzone'); | |
| const fi = document.getElementById('file-input'); | |
| ['dragenter','dragover'].forEach(e => dz.addEventListener(e, ev => { ev.preventDefault(); dz.classList.add('dragover'); })); | |
| ['dragleave','drop'].forEach(e => dz.addEventListener(e, ev => { ev.preventDefault(); dz.classList.remove('dragover'); })); | |
| dz.addEventListener('drop', ev => { if (ev.dataTransfer.files[0]) uploadFile(ev.dataTransfer.files[0]); }); | |
| fi.addEventListener('change', ev => { if (ev.target.files[0]) uploadFile(ev.target.files[0]); }); | |
| async function uploadFile(file){ | |
| const ext = file.name.split('.').pop().toLowerCase(); | |
| if (!['pdf','doc','docx'].includes(ext)) { showError('Unsupported file type.'); return; } | |
| S.file = file; | |
| showLoading('scanning documentβ¦'); | |
| document.getElementById('upload-view').style.display='none'; | |
| const t0 = performance.now(); | |
| try{ | |
| const client = await clientPromise; | |
| const result = await client.predict("/analyze_document", { | |
| file: handle_file(file), | |
| }); | |
| const d = result.data[0] || {}; | |
| if (d.error) { showError(d.error); return; } | |
| S.scanMs = performance.now() - t0; | |
| S.text = d.text; S.spans = d.spans; S.stats = d.stats; | |
| S.speakers = d.speakers||{}; S.catMeta = d.categories_meta||{}; | |
| S.filename = d.filename || file.name; | |
| S.activeCats = new Set(Object.keys(d.stats.categories)); | |
| S.sortedSpans = [...S.spans].sort((a,b) => a.start - b.start); | |
| renderResults(); | |
| } catch(e){ showError('Analysis failed: '+(e && e.message ? e.message : e)); } | |
| finally { hideLoading(); } | |
| } | |
| function showError(m){ | |
| hideLoading(); | |
| document.getElementById('upload-view').style.display='flex'; | |
| document.getElementById('results-view').style.display='none'; | |
| alert(m); | |
| } | |
| function resetView(){ | |
| document.getElementById('results-view').style.display='none'; | |
| document.getElementById('upload-view').style.display='flex'; | |
| fi.value = ''; S.file = null; | |
| } | |
| document.getElementById('btn-new').addEventListener('click', resetView); | |
| /* ===== render ===== */ | |
| function renderResults(){ | |
| document.getElementById('results-view').style.display='block'; | |
| document.getElementById('file-chip').textContent = S.filename; | |
| document.getElementById('scan-status').innerHTML = | |
| `<span class="pr-status-dot"></span>Scan complete · ${(S.scanMs/1000).toFixed(1)}s`; | |
| renderStats(); renderBar(); renderLegend(); renderDocMeta(); renderDoc(); renderCats(); renderSpeakers(); | |
| updateToggleAllLabel(); updatePrimaryAction(); | |
| } | |
| function updatePrimaryAction(){ | |
| const pdfBtn = document.getElementById('act-pdf'); | |
| const txtBtn = document.getElementById('act-txt'); | |
| if (isPdf()) { | |
| pdfBtn.style.display = ''; | |
| pdfBtn.classList.add('pr-btn-prim'); | |
| txtBtn.classList.remove('pr-btn-prim'); | |
| } else { | |
| pdfBtn.style.display = 'none'; | |
| pdfBtn.classList.remove('pr-btn-prim'); | |
| txtBtn.classList.add('pr-btn-prim'); | |
| } | |
| } | |
| function renderStats(){ | |
| const s = S.stats; | |
| document.getElementById('hero-val').textContent = (s.pii_percentage ?? 0).toFixed(1); | |
| document.getElementById('num-spans').textContent = s.total_spans; | |
| document.getElementById('num-cats').textContent = `${s.num_categories} / 8`; | |
| const n = Object.keys(S.speakers).length; | |
| document.getElementById('num-speakers').textContent = n || 'β'; | |
| } | |
| function renderBar(){ | |
| const bar = document.getElementById('dist-bar'); | |
| bar.innerHTML = ''; | |
| const cats = S.stats.categories; | |
| const total = Object.values(cats).reduce((a,b) => a + b.chars, 0) || 1; | |
| const ordered = ORDER.filter(c => cats[c]); | |
| if (!ordered.length) { | |
| const span = document.createElement('span'); | |
| span.style.cssText = 'flex:1;background:var(--border-color-primary);opacity:.4'; | |
| bar.appendChild(span); return; | |
| } | |
| for (const c of ordered) { | |
| const m = metaFor(c); | |
| const span = document.createElement('span'); | |
| span.style.background = m.color; | |
| span.style.flex = cats[c].chars / total; | |
| span.dataset.cat = c; | |
| span.addEventListener('mouseenter', ev => showTip(ev, `${m.label} Β· ${cats[c].count}`)); | |
| span.addEventListener('mousemove', moveTip); | |
| span.addEventListener('mouseleave', hideTip); | |
| if (!S.activeCats.has(c)) span.style.opacity = '.25'; | |
| bar.appendChild(span); | |
| } | |
| } | |
| function renderLegend(){ | |
| const leg = document.getElementById('legend'); | |
| leg.innerHTML = ''; | |
| const cats = S.stats.categories; | |
| const ordered = ORDER.filter(c => cats[c]); | |
| for (const c of ordered) { | |
| const m = metaFor(c); | |
| const el = document.createElement('span'); | |
| el.className = 'pr-leg' + (S.activeCats.has(c) ? '' : ' off'); | |
| el.dataset.cat = c; | |
| el.innerHTML = `<span class="pr-leg-sw" style="background:${m.color}"></span>${m.label}<span class="pr-leg-ct">${cats[c].count}</span>`; | |
| el.addEventListener('click', () => toggleCat(c)); | |
| leg.appendChild(el); | |
| } | |
| } | |
| function renderDocMeta(){ | |
| const s = S.stats; | |
| const parts = [ | |
| `${s.total_chars.toLocaleString()} characters`, | |
| `${s.total_lines.toLocaleString()} lines`, | |
| `scanned in ${(S.scanMs/1000).toFixed(1)}s`, | |
| ]; | |
| document.getElementById('doc-meta').innerHTML = parts.map(p => `<span>${p}</span>`).join(''); | |
| } | |
| function esc(s){ const d=document.createElement('div'); d.textContent=s; return d.innerHTML; } | |
| function renderDoc(){ | |
| const { text, sortedSpans, activeCats } = S; | |
| const el = document.getElementById('doc-text'); | |
| let html = '', pos = 0; | |
| for (const sp of sortedSpans) { | |
| if (sp.start < pos) continue; | |
| if (sp.start > pos) html += esc(text.substring(pos, sp.start)); | |
| const m = metaFor(sp.label); | |
| const cls = ['h', m.cls]; | |
| if (m.mono) cls.push('m'); | |
| if (!activeCats.has(sp.label)) cls.push('off'); | |
| html += `<span class="${cls.join(' ')}" data-cat="${sp.label}">${esc(text.substring(sp.start, sp.end))}</span>`; | |
| pos = sp.end; | |
| } | |
| if (pos < text.length) html += esc(text.substring(pos)); | |
| el.innerHTML = html; | |
| el.querySelectorAll('.h').forEach(span => { | |
| const cat = span.dataset.cat, m = metaFor(cat); | |
| span.addEventListener('mouseenter', ev => showTip(ev, `${m.label}: ${span.textContent.trim()}`)); | |
| span.addEventListener('mousemove', moveTip); | |
| span.addEventListener('mouseleave', hideTip); | |
| }); | |
| } | |
| function renderCats(){ | |
| const box = document.getElementById('cat-list'); | |
| box.innerHTML = ''; | |
| const cats = S.stats.categories; | |
| const ordered = ORDER.filter(c => cats[c]); | |
| if (!ordered.length) { box.innerHTML = '<div class="empty-rail">No entities detected.</div>'; return; } | |
| const totalSpans = S.stats.total_spans || 1; | |
| for (const c of ordered) { | |
| const m = metaFor(c); | |
| const count = cats[c].count; | |
| const share = (count / totalSpans) * 100; | |
| const active = S.activeCats.has(c); | |
| const el = document.createElement('div'); | |
| el.className = 'pr-cat' + (active ? ' on' : ' off'); | |
| el.dataset.cat = c; | |
| el.style.setProperty('--cat', m.color); | |
| el.innerHTML = ` | |
| <span class="pr-cat-sw" style="background:${m.color}"></span> | |
| <span class="pr-cat-nm">${m.label}</span> | |
| <span class="pr-cat-ct">${count}</span> | |
| <span class="pr-cat-mini"><span style="width:${share.toFixed(1)}%;background:${m.color}"></span></span>`; | |
| el.addEventListener('click', () => toggleCat(c)); | |
| box.appendChild(el); | |
| } | |
| } | |
| function renderSpeakers(){ | |
| const names = Object.keys(S.speakers); | |
| const block = document.getElementById('speakers-block'); | |
| const box = document.getElementById('speakers-list'); | |
| if (!names.length) { block.style.display = 'none'; return; } | |
| block.style.display = 'block'; | |
| box.innerHTML = ''; | |
| for (const n of names) { | |
| const el = document.createElement('div'); | |
| el.className = 'pr-cat'; | |
| el.innerHTML = `<span class="pr-cat-sw"></span><span class="pr-cat-nm">${esc(n)}</span><span class="pr-cat-ct">${S.speakers[n]}</span>`; | |
| box.appendChild(el); | |
| } | |
| } | |
| function toggleCat(c){ | |
| if (S.activeCats.has(c)) S.activeCats.delete(c); | |
| else S.activeCats.add(c); | |
| const on = S.activeCats.has(c); | |
| document.querySelectorAll(`.pr-cat[data-cat="${c}"]`).forEach(el => { el.classList.toggle('on', on); el.classList.toggle('off', !on); }); | |
| document.querySelectorAll(`.pr-leg[data-cat="${c}"]`).forEach(el => el.classList.toggle('off', !on)); | |
| document.querySelectorAll(`.h[data-cat="${c}"]`).forEach(el => el.classList.toggle('off', !on)); | |
| document.querySelectorAll(`.pr-bar span[data-cat="${c}"]`).forEach(el => el.style.opacity = on ? '1' : '.25'); | |
| updateToggleAllLabel(); | |
| } | |
| function updateToggleAllLabel(){ | |
| const btn = document.getElementById('cat-toggle-all'); | |
| if (!btn) return; | |
| const all = Object.keys(S.stats.categories||{}); | |
| const allOn = all.length > 0 && all.every(c => S.activeCats.has(c)); | |
| btn.textContent = allOn ? 'Clear all' : 'Select all'; | |
| } | |
| document.getElementById('cat-toggle-all').addEventListener('click', () => { | |
| const all = Object.keys(S.stats.categories||{}); | |
| const allOn = all.every(c => S.activeCats.has(c)); | |
| all.forEach(c => { | |
| const want = !allOn; | |
| if (want !== S.activeCats.has(c)) toggleCat(c); | |
| }); | |
| }); | |
| function showTip(ev, text){ const t = document.getElementById('tip'); t.textContent = text; t.style.display = 'block'; moveTip(ev); } | |
| function moveTip(ev){ const t = document.getElementById('tip'); t.style.left = (ev.clientX + 12) + 'px'; t.style.top = (ev.clientY - 26) + 'px'; } | |
| function hideTip(){ document.getElementById('tip').style.display = 'none'; } | |
| /* ===== actions ===== */ | |
| function sanitizedText(){ | |
| const parts = []; let pos = 0; | |
| for (const sp of S.sortedSpans) { | |
| if (sp.start < pos) continue; | |
| if (sp.start > pos) parts.push(S.text.substring(pos, sp.start)); | |
| const m = metaFor(sp.label); | |
| parts.push(S.activeCats.has(sp.label) ? `[${m.label.toUpperCase()}]` : S.text.substring(sp.start, sp.end)); | |
| pos = sp.end; | |
| } | |
| if (pos < S.text.length) parts.push(S.text.substring(pos)); | |
| return parts.join(''); | |
| } | |
| function download(name, content, type){ | |
| const blob = content instanceof Blob ? content : new Blob([content], { type: type || 'text/plain' }); | |
| const a = document.createElement('a'); | |
| a.href = URL.createObjectURL(blob); a.download = name; | |
| document.body.appendChild(a); a.click(); a.remove(); | |
| setTimeout(() => URL.revokeObjectURL(a.href), 1000); | |
| } | |
| function baseName(){ | |
| const f = S.filename || 'document'; | |
| const i = f.lastIndexOf('.'); | |
| return i > 0 ? f.slice(0, i) : f; | |
| } | |
| document.getElementById('act-txt').addEventListener('click', () => { | |
| download(baseName() + '.redacted.txt', sanitizedText(), 'text/plain'); | |
| flash('act-txt', 'Exported'); | |
| }); | |
| document.getElementById('act-copy').addEventListener('click', async () => { | |
| try { await navigator.clipboard.writeText(sanitizedText()); flash('act-copy', 'Copied'); } | |
| catch { flash('act-copy', 'Copy failed'); } | |
| }); | |
| document.getElementById('act-report').addEventListener('click', () => { | |
| const report = { | |
| filename: S.filename, scanned_in_ms: Math.round(S.scanMs), | |
| stats: S.stats, speakers: S.speakers, | |
| active_categories: [...S.activeCats], spans: S.spans, | |
| }; | |
| download(baseName() + '.report.json', JSON.stringify(report, null, 2), 'application/json'); | |
| flash('act-report', 'Downloaded'); | |
| }); | |
| document.getElementById('act-pdf').addEventListener('click', async () => { | |
| if (!isPdf()) return; | |
| if (!S.file) { alert('Original PDF reference lost β upload again to export a redacted PDF.'); return; } | |
| if (!S.activeCats.size) { alert('No categories selected β enable at least one category in the sidebar before redacting.'); return; } | |
| const btn = document.getElementById('act-pdf'); | |
| btn.disabled = true; | |
| showLoading('redacting PDFβ¦'); | |
| try { | |
| const client = await clientPromise; | |
| const result = await client.predict("/redact_pdf", { | |
| file: handle_file(S.file), | |
| spans: JSON.stringify(S.spans), | |
| active: JSON.stringify([...S.activeCats]), | |
| }); | |
| const d = result.data[0] || {}; | |
| if (d.error) throw new Error(d.error); | |
| if (!d.pdf || !d.pdf.url) throw new Error('No PDF returned.'); | |
| const blob = await (await fetch(d.pdf.url)).blob(); | |
| download(baseName() + '.redacted.pdf', blob, 'application/pdf'); | |
| if (typeof d.elapsed_ms === 'number') flash('act-pdf', `Downloaded (${(d.elapsed_ms/1000).toFixed(1)}s)`); | |
| else flash('act-pdf', 'Downloaded'); | |
| } catch (e) { | |
| alert(e.message || 'Redaction failed'); | |
| } finally { | |
| hideLoading(); | |
| btn.disabled = false; | |
| } | |
| }); | |
| const _flashTimers = {}; | |
| function flash(id, msg){ | |
| const btn = document.getElementById(id); | |
| const span = btn.querySelector('span'); | |
| const prev = span ? span.textContent : btn.textContent; | |
| if (span) span.textContent = msg; else btn.textContent = msg; | |
| clearTimeout(_flashTimers[id]); | |
| _flashTimers[id] = setTimeout(() => { if (span) span.textContent = prev; else btn.textContent = prev; }, 1800); | |
| } | |
| </script> | |
| </body> | |
| </html>""" | |
| # ββ launch βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| if __name__ == "__main__": | |
| server.launch(server_name="0.0.0.0", server_port=7860) | |