ysharma HF Staff commited on
Commit
37d674e
Β·
verified Β·
1 Parent(s): 6b0722d

Upload app_v4.py

Browse files
Files changed (1) hide show
  1. app_v4.py +1643 -0
app_v4.py ADDED
@@ -0,0 +1,1643 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ PII Reveal - Document Privacy Explorer (v4 β€” "Inspector, revised")
3
+ ===================================================================
4
+ Addresses ui-critique-3.txt. Six changes vs v3:
5
+ 1. Small-caps, secondary-color captions under every number and section heading
6
+ 2. Whole app centered inside a 1080px container with breathing room
7
+ 3. Sidebar category rows are real pills: tinted pad, left-border accent when
8
+ active, strike-through + dim when off, and a 1.5px count mini-bar below
9
+ each label that turns the rail into a tiny distribution chart. Plus a
10
+ "Select all / Clear all" toggle row.
11
+ 4. Actions moved out of the sidebar and into the top bar β€” "Redact & export"
12
+ is the primary, plus Copy / Report / new-file as ghost buttons
13
+ 5. Theme via Gradio-compatible CSS variables with prefers-color-scheme +
14
+ .dark fallbacks, and category fills rebuilt on color-mix() so the hues
15
+ work on light and dark backgrounds
16
+ 6. Landing page split into two columns: receptive dropzone tint + category
17
+ chips on the left, a static before->after preview on the right; headline
18
+ sharpened, bottom metadata uses vertical rules
19
+
20
+ Backend (model, server, endpoints) is identical to app.py.
21
+ """
22
+
23
+ # ── stdlib ───────────────────────────────────────────────────────
24
+ import dataclasses
25
+ import functools
26
+ import json
27
+ import math
28
+ import os
29
+ import re
30
+ import tempfile
31
+ from bisect import bisect_left, bisect_right
32
+ from collections.abc import Sequence
33
+ from dataclasses import dataclass
34
+ from pathlib import Path
35
+ from typing import Final
36
+
37
+ # ── third-party ──────────────────────────────────────────────────
38
+ import gradio as gr
39
+ import spaces
40
+ import tiktoken
41
+ import torch
42
+ import torch.nn.functional as F
43
+ from fastapi import UploadFile, File
44
+ from fastapi.responses import HTMLResponse, JSONResponse
45
+ from huggingface_hub import snapshot_download
46
+ from safetensors import safe_open
47
+
48
+ # ── configuration ────────────────────────────────────────────────
49
+ MODEL_REPO = os.getenv("MODEL_ID", "charles-first-org/second-model")
50
+ HF_TOKEN = os.getenv("HF_TOKEN", None)
51
+ MODEL_DIR = Path(snapshot_download(MODEL_REPO, token=HF_TOKEN))
52
+
53
+ CATEGORIES_META = {
54
+ "private_person": {"color": "#E24B4A", "cls": "hp", "label": "Person", "mono": False},
55
+ "private_date": {"color": "#7F77DD", "cls": "hd", "label": "Date", "mono": True},
56
+ "private_address": {"color": "#1D9E75", "cls": "ha", "label": "Address", "mono": False},
57
+ "private_email": {"color": "#378ADD", "cls": "he", "label": "Email", "mono": True},
58
+ "account_number": {"color": "#BA7517", "cls": "hac", "label": "Account", "mono": True},
59
+ "private_url": {"color": "#D85A30", "cls": "hu", "label": "URL", "mono": True},
60
+ "secret": {"color": "#D4537E", "cls": "hs", "label": "Secret", "mono": True},
61
+ "private_phone": {"color": "#639922", "cls": "hph", "label": "Phone", "mono": True},
62
+ }
63
+
64
+ # =====================================================================
65
+ # MODEL ARCHITECTURE + INFERENCE (from reference implementation)
66
+ # =====================================================================
67
+
68
+ PRIVACY_FILTER_MODEL_TYPE: Final[str] = "privacy_filter"
69
+ REQUIRED_MODEL_CONFIG_KEYS: Final[tuple[str, ...]] = (
70
+ "model_type", "encoding", "num_hidden_layers", "num_experts",
71
+ "experts_per_token", "vocab_size", "num_labels", "hidden_size",
72
+ "intermediate_size", "head_dim", "num_attention_heads",
73
+ "num_key_value_heads", "sliding_window", "bidirectional_context",
74
+ "bidirectional_left_context", "bidirectional_right_context",
75
+ "default_n_ctx", "initial_context_length", "rope_theta",
76
+ "rope_scaling_factor", "rope_ntk_alpha", "rope_ntk_beta", "param_dtype",
77
+ )
78
+ BACKGROUND_CLASS_LABEL: Final[str] = "O"
79
+ BOUNDARY_PREFIXES: Final[tuple[str, ...]] = ("B", "I", "E", "S")
80
+ SPAN_CLASS_NAMES: Final[tuple[str, ...]] = (
81
+ BACKGROUND_CLASS_LABEL,
82
+ "account_number", "private_address", "private_date", "private_email",
83
+ "private_person", "private_phone", "private_url", "secret",
84
+ )
85
+ NER_CLASS_NAMES: Final[tuple[str, ...]] = (BACKGROUND_CLASS_LABEL,) + tuple(
86
+ f"{prefix}-{base}"
87
+ for base in SPAN_CLASS_NAMES if base != BACKGROUND_CLASS_LABEL
88
+ for prefix in BOUNDARY_PREFIXES
89
+ )
90
+ VITERBI_TRANSITION_BIAS_KEYS: Final[tuple[str, ...]] = (
91
+ "transition_bias_background_stay", "transition_bias_background_to_start",
92
+ "transition_bias_inside_to_continue", "transition_bias_inside_to_end",
93
+ "transition_bias_end_to_background", "transition_bias_end_to_start",
94
+ )
95
+ DEFAULT_VITERBI_CALIBRATION_PRESET: Final[str] = "default"
96
+
97
+
98
+ def validate_model_config_contract(cfg: dict, *, context: str) -> None:
99
+ missing = [k for k in REQUIRED_MODEL_CONFIG_KEYS if k not in cfg]
100
+ if missing:
101
+ raise ValueError(f"{context} missing keys: {', '.join(missing)}")
102
+ if cfg.get("model_type") != PRIVACY_FILTER_MODEL_TYPE:
103
+ raise ValueError(f"{context} model_type must be {PRIVACY_FILTER_MODEL_TYPE!r}")
104
+ if cfg.get("bidirectional_context") is not True:
105
+ raise ValueError(f"{context} must use bidirectional_context=true")
106
+ lc, rc = cfg.get("bidirectional_left_context"), cfg.get("bidirectional_right_context")
107
+ if not isinstance(lc, int) or not isinstance(rc, int) or lc != rc or lc < 0:
108
+ raise ValueError(f"{context} bidirectional context must be equal non-negative ints")
109
+ sw = cfg.get("sliding_window")
110
+ if sw != 2 * lc + 1:
111
+ raise ValueError(f"{context} sliding_window must equal 2*context+1")
112
+ if cfg["num_labels"] != 33:
113
+ raise ValueError(f"{context} num_labels must be 33")
114
+ if cfg["param_dtype"] != "bfloat16":
115
+ raise ValueError(f"{context} param_dtype must be bfloat16")
116
+
117
+
118
+ # ── model helpers ────────────────────────────────────────────────
119
+
120
+ def expert_linear(x: torch.Tensor, weight: torch.Tensor, bias: torch.Tensor | None) -> torch.Tensor:
121
+ n, e, k = x.shape
122
+ _, _, _, o = weight.shape
123
+ out = torch.bmm(x.reshape(n * e, 1, k), weight.reshape(n * e, k, o)).reshape(n, e, o)
124
+ return out + bias if bias is not None else out
125
+
126
+
127
+ @dataclass
128
+ class ModelConfig:
129
+ num_hidden_layers: int; num_experts: int; experts_per_token: int
130
+ vocab_size: int; num_labels: int; hidden_size: int; intermediate_size: int
131
+ head_dim: int; num_attention_heads: int; num_key_value_heads: int
132
+ bidirectional_context_size: int; initial_context_length: int
133
+ rope_theta: float; rope_scaling_factor: float; rope_ntk_alpha: float; rope_ntk_beta: float
134
+
135
+ @classmethod
136
+ def from_checkpoint_config(cls, cfg: dict, *, context: str) -> "ModelConfig":
137
+ cfg = dict(cfg)
138
+ cfg["bidirectional_context_size"] = cfg["bidirectional_left_context"]
139
+ fields = {f.name for f in dataclasses.fields(cls)}
140
+ return cls(**{k: v for k, v in cfg.items() if k in fields})
141
+
142
+
143
+ class RMSNorm(torch.nn.Module):
144
+ def __init__(self, n: int, eps: float = 1e-5, device=None):
145
+ super().__init__()
146
+ self.eps = eps
147
+ self.scale = torch.nn.Parameter(torch.ones(n, device=device, dtype=torch.float32))
148
+
149
+ def forward(self, x):
150
+ t = x.float()
151
+ return (t * torch.rsqrt(t.pow(2).mean(-1, keepdim=True) + self.eps) * self.scale).to(x.dtype)
152
+
153
+
154
+ def apply_rope(x, cos, sin):
155
+ cos = cos.unsqueeze(-2).to(x.dtype); sin = sin.unsqueeze(-2).to(x.dtype)
156
+ x1, x2 = x[..., ::2], x[..., 1::2]
157
+ return torch.stack((x1 * cos - x2 * sin, x2 * cos + x1 * sin), dim=-1).reshape(x.shape)
158
+
159
+
160
+ class RotaryEmbedding(torch.nn.Module):
161
+ def __init__(self, head_dim, base, dtype, *, initial_context_length=4096,
162
+ scaling_factor=1.0, ntk_alpha=1.0, ntk_beta=32.0, device=None):
163
+ super().__init__()
164
+ self.head_dim, self.base, self.dtype = head_dim, base, dtype
165
+ self.initial_context_length = initial_context_length
166
+ self.scaling_factor, self.ntk_alpha, self.ntk_beta = scaling_factor, ntk_alpha, ntk_beta
167
+ self.device = device
168
+ mp = max(int(initial_context_length * scaling_factor), initial_context_length)
169
+ self.max_position_embeddings = mp
170
+ cos, sin = self._compute(mp, device=torch.device("cpu"))
171
+ target = device or torch.device("cpu")
172
+ self.register_buffer("cos_cache", cos.to(target), persistent=False)
173
+ self.register_buffer("sin_cache", sin.to(target), persistent=False)
174
+
175
+ def _inv_freq(self, device=None):
176
+ device = device or self.device
177
+ freq = self.base ** (torch.arange(0, self.head_dim, 2, dtype=torch.float, device=device) / self.head_dim)
178
+ if self.scaling_factor > 1.0:
179
+ d_half = self.head_dim / 2
180
+ low = d_half * math.log(self.initial_context_length / (self.ntk_beta * 2 * math.pi)) / math.log(self.base)
181
+ high = d_half * math.log(self.initial_context_length / (self.ntk_alpha * 2 * math.pi)) / math.log(self.base)
182
+ interp = 1.0 / (self.scaling_factor * freq)
183
+ extrap = 1.0 / freq
184
+ ramp = (torch.arange(d_half, dtype=torch.float32, device=device) - low) / (high - low)
185
+ mask = 1 - ramp.clamp(0, 1)
186
+ return interp * (1 - mask) + extrap * mask
187
+ return 1.0 / freq
188
+
189
+ def _compute(self, n, device=None):
190
+ inv_freq = self._inv_freq(device)
191
+ t = torch.arange(n, dtype=torch.float32, device=device or self.device)
192
+ freqs = torch.einsum("i,j->ij", t, inv_freq)
193
+ c = 0.1 * math.log(self.scaling_factor) + 1.0 if self.scaling_factor > 1.0 else 1.0
194
+ return (freqs.cos() * c).to(self.dtype), (freqs.sin() * c).to(self.dtype)
195
+
196
+ def forward(self, q, k):
197
+ n = q.shape[0]
198
+ if n > self.cos_cache.shape[0]:
199
+ cos, sin = self._compute(n, torch.device("cpu"))
200
+ self.cos_cache, self.sin_cache = cos.to(q.device), sin.to(q.device)
201
+ cc = self.cos_cache.to(q.device) if self.cos_cache.device != q.device else self.cos_cache
202
+ sc = self.sin_cache.to(q.device) if self.sin_cache.device != q.device else self.sin_cache
203
+ cos, sin = cc[:n], sc[:n]
204
+ q = apply_rope(q.view(n, -1, self.head_dim), cos, sin).reshape(q.shape)
205
+ k = apply_rope(k.view(n, -1, self.head_dim), cos, sin).reshape(k.shape)
206
+ return q, k
207
+
208
+
209
+ def sdpa(Q, K, V, S, sm_scale, ctx):
210
+ n, nh, qm, hd = Q.shape
211
+ w = 2 * ctx + 1
212
+ Kp = F.pad(K, (0, 0, 0, 0, ctx, ctx)); Vp = F.pad(V, (0, 0, 0, 0, ctx, ctx))
213
+ Kw = Kp.unfold(0, w, 1).permute(0, 3, 1, 2); Vw = Vp.unfold(0, w, 1).permute(0, 3, 1, 2)
214
+ idx = torch.arange(w, device=Q.device) - ctx
215
+ pos = torch.arange(n, device=Q.device)[:, None] + idx[None, :]
216
+ valid = (pos >= 0) & (pos < n)
217
+ scores = torch.einsum("nhqd,nwhd->nhqw", Q, Kw).float() * sm_scale
218
+ scores = scores.masked_fill(~valid[:, None, None, :], -float("inf"))
219
+ sink = (S * math.log(2.0)).reshape(nh, qm)[None, :, :, None].expand(n, -1, -1, 1)
220
+ scores = torch.cat([scores, sink], dim=-1)
221
+ wt = torch.softmax(scores, dim=-1)[..., :-1].to(V.dtype)
222
+ return torch.einsum("nhqw,nwhd->nhqd", wt, Vw).reshape(n, -1)
223
+
224
+
225
+ class AttentionBlock(torch.nn.Module):
226
+ def __init__(self, cfg: ModelConfig, device=None):
227
+ super().__init__()
228
+ dt = torch.bfloat16
229
+ self.head_dim, self.nah, self.nkv = cfg.head_dim, cfg.num_attention_heads, cfg.num_key_value_heads
230
+ self.ctx = int(cfg.bidirectional_context_size)
231
+ self.sinks = torch.nn.Parameter(torch.empty(cfg.num_attention_heads, device=device, dtype=torch.float32))
232
+ self.norm = RMSNorm(cfg.hidden_size, device=device)
233
+ qkv_d = cfg.head_dim * (cfg.num_attention_heads + 2 * cfg.num_key_value_heads)
234
+ self.qkv = torch.nn.Linear(cfg.hidden_size, qkv_d, device=device, dtype=dt)
235
+ self.out = torch.nn.Linear(cfg.head_dim * cfg.num_attention_heads, cfg.hidden_size, device=device, dtype=dt)
236
+ self.qk_scale = 1 / math.sqrt(math.sqrt(cfg.head_dim))
237
+ self.rope = RotaryEmbedding(cfg.head_dim, int(cfg.rope_theta), torch.float32,
238
+ initial_context_length=cfg.initial_context_length,
239
+ scaling_factor=cfg.rope_scaling_factor,
240
+ ntk_alpha=cfg.rope_ntk_alpha, ntk_beta=cfg.rope_ntk_beta, device=device)
241
+
242
+ def forward(self, x):
243
+ t = self.norm(x).to(self.qkv.weight.dtype)
244
+ qkv = F.linear(t, self.qkv.weight, self.qkv.bias)
245
+ hd, nah, nkv = self.head_dim, self.nah, self.nkv
246
+ q = qkv[:, :nah * hd].contiguous()
247
+ k = qkv[:, nah * hd:(nah + nkv) * hd].contiguous()
248
+ v = qkv[:, (nah + nkv) * hd:(nah + 2 * nkv) * hd].contiguous()
249
+ q, k = self.rope(q, k)
250
+ q, k = q * self.qk_scale, k * self.qk_scale
251
+ n = q.shape[0]
252
+ q = q.view(n, nkv, nah // nkv, hd); k = k.view(n, nkv, hd); v = v.view(n, nkv, hd)
253
+ ao = sdpa(q, k, v, self.sinks, 1.0, self.ctx).to(self.out.weight.dtype)
254
+ return x + F.linear(ao, self.out.weight, self.out.bias).to(x.dtype)
255
+
256
+
257
+ def swiglu(x, alpha=1.702, limit=7.0):
258
+ g, l = x.chunk(2, dim=-1)
259
+ g, l = g.clamp(max=limit), l.clamp(-limit, limit)
260
+ return g * torch.sigmoid(alpha * g) * (l + 1)
261
+
262
+
263
+ class MLPBlock(torch.nn.Module):
264
+ def __init__(self, cfg: ModelConfig, device=None):
265
+ super().__init__()
266
+ dt = torch.bfloat16
267
+ self.ne, self.ept = cfg.num_experts, cfg.experts_per_token
268
+ self.norm = RMSNorm(cfg.hidden_size, device=device)
269
+ self.gate = torch.nn.Linear(cfg.hidden_size, cfg.num_experts, device=device, dtype=dt)
270
+ self.mlp1_weight = torch.nn.Parameter(torch.empty(cfg.num_experts, cfg.hidden_size, cfg.intermediate_size * 2, device=device, dtype=dt))
271
+ self.mlp1_bias = torch.nn.Parameter(torch.empty(cfg.num_experts, cfg.intermediate_size * 2, device=device, dtype=dt))
272
+ self.mlp2_weight = torch.nn.Parameter(torch.empty(cfg.num_experts, cfg.intermediate_size, cfg.hidden_size, device=device, dtype=dt))
273
+ self.mlp2_bias = torch.nn.Parameter(torch.empty(cfg.num_experts, cfg.hidden_size, device=device, dtype=dt))
274
+
275
+ def forward(self, x):
276
+ t = self.norm(x)
277
+ gs = F.linear(t.float(), self.gate.weight.float(), self.gate.bias.float())
278
+ top = torch.topk(gs, k=self.ept, dim=-1, sorted=True)
279
+ ew = torch.softmax(top.values, dim=-1) / self.ept
280
+ ei = top.indices
281
+ ept = self.ept
282
+
283
+ def _chunk(tc, eic, ewc):
284
+ o = expert_linear(tc.float().unsqueeze(1).expand(-1, eic.shape[1], -1),
285
+ self.mlp1_weight[eic].float(), self.mlp1_bias[eic].float())
286
+ o = swiglu(o)
287
+ o = expert_linear(o.float(), self.mlp2_weight[eic].float(), self.mlp2_bias[eic].float())
288
+ return (torch.einsum("bec,be->bc", o.to(ewc.dtype), ewc) * ept).to(x.dtype)
289
+
290
+ cs = 32
291
+ if t.shape[0] > cs:
292
+ parts = [_chunk(t[s:s+cs], ei[s:s+cs], ew[s:s+cs]) for s in range(0, t.shape[0], cs)]
293
+ return x + torch.cat(parts, 0)
294
+ return x + _chunk(t, ei, ew)
295
+
296
+
297
+ class TransformerBlock(torch.nn.Module):
298
+ def __init__(self, cfg, device=None):
299
+ super().__init__()
300
+ self.attn = AttentionBlock(cfg, device=device)
301
+ self.mlp = MLPBlock(cfg, device=device)
302
+ def forward(self, x):
303
+ return self.mlp(self.attn(x))
304
+
305
+
306
+ class Checkpoint:
307
+ @staticmethod
308
+ def build_param_name_map(n):
309
+ return ({f"block.{i}.mlp.mlp1_bias": f"block.{i}.mlp.swiglu.bias" for i in range(n)}
310
+ | {f"block.{i}.mlp.mlp1_weight": f"block.{i}.mlp.swiglu.weight" for i in range(n)}
311
+ | {f"block.{i}.mlp.mlp2_bias": f"block.{i}.mlp.out.bias" for i in range(n)}
312
+ | {f"block.{i}.mlp.mlp2_weight": f"block.{i}.mlp.out.weight" for i in range(n)})
313
+
314
+ def __init__(self, path, device, num_hidden_layers):
315
+ self.pnm = self.build_param_name_map(num_hidden_layers)
316
+ self.ds = device.type if device.index is None else f"{device.type}:{device.index}"
317
+ files = [os.path.join(path, f) for f in os.listdir(path) if f.endswith(".safetensors")]
318
+ self.map = {}
319
+ for sf in files:
320
+ with safe_open(sf, framework="pt", device=self.ds) as h:
321
+ for k in h.keys():
322
+ self.map[k] = sf
323
+
324
+ def get(self, name):
325
+ mapped = self.pnm.get(name, name)
326
+ with safe_open(self.map[mapped], framework="pt", device=self.ds) as h:
327
+ return h.get_tensor(mapped)
328
+
329
+
330
+ class Transformer(torch.nn.Module):
331
+ def __init__(self, cfg, device):
332
+ super().__init__()
333
+ dt = torch.bfloat16
334
+ self.embedding = torch.nn.Embedding(cfg.vocab_size, cfg.hidden_size, device=device, dtype=dt)
335
+ self.block = torch.nn.ModuleList([TransformerBlock(cfg, device=device) for _ in range(cfg.num_hidden_layers)])
336
+ self.norm = RMSNorm(cfg.hidden_size, device=device)
337
+ self.unembedding = torch.nn.Linear(cfg.hidden_size, cfg.num_labels, bias=False, device=device, dtype=dt)
338
+
339
+ def forward(self, token_ids):
340
+ x = self.embedding(token_ids)
341
+ for blk in self.block:
342
+ x = blk(x)
343
+ return F.linear(self.norm(x), self.unembedding.weight, None)
344
+
345
+ @classmethod
346
+ def from_checkpoint(cls, checkpoint_dir, *, device):
347
+ torch.backends.cuda.matmul.allow_tf32 = False
348
+ torch.backends.cudnn.allow_tf32 = False
349
+ torch.set_float32_matmul_precision("highest")
350
+ cp = json.loads((Path(checkpoint_dir) / "config.json").read_text())
351
+ validate_model_config_contract(cp, context=str(checkpoint_dir))
352
+ cfg = ModelConfig.from_checkpoint_config(cp, context=str(checkpoint_dir))
353
+ ckpt = Checkpoint(checkpoint_dir, device, cfg.num_hidden_layers)
354
+ m = cls(cfg, device); m.eval()
355
+ for name, param in m.named_parameters():
356
+ loaded = ckpt.get(name)
357
+ if param.shape != loaded.shape:
358
+ raise ValueError(f"Shape mismatch {name}: {param.shape} vs {loaded.shape}")
359
+ param.data.copy_(loaded)
360
+ return m
361
+
362
+
363
+ # ── label info + span decoding ───────────────────────────────────
364
+
365
+ @dataclass(frozen=True)
366
+ class LabelInfo:
367
+ boundary_label_lookup: dict[str, dict[str, int]]
368
+ token_to_span_label: dict[int, int]
369
+ token_boundary_tags: dict[int, str | None]
370
+ span_class_names: tuple[str, ...]
371
+ span_label_lookup: dict[str, int]
372
+ background_token_label: int
373
+ background_span_label: int
374
+
375
+
376
+ def labels_to_spans(labels_by_index, label_info):
377
+ spans, cur_label, start_idx, prev_idx = [], None, None, None
378
+ bg = label_info.background_span_label
379
+ for ti in sorted(labels_by_index):
380
+ lid = labels_by_index[ti]
381
+ sl = label_info.token_to_span_label.get(lid)
382
+ bt = label_info.token_boundary_tags.get(lid)
383
+ if prev_idx is not None and ti != prev_idx + 1:
384
+ if cur_label is not None and start_idx is not None:
385
+ spans.append((cur_label, start_idx, prev_idx + 1))
386
+ cur_label = start_idx = None
387
+ if sl is None:
388
+ prev_idx = ti; continue
389
+ if sl == bg:
390
+ if cur_label is not None and start_idx is not None:
391
+ spans.append((cur_label, start_idx, ti))
392
+ cur_label = start_idx = None; prev_idx = ti; continue
393
+ if bt == "S":
394
+ if cur_label is not None and start_idx is not None and prev_idx is not None:
395
+ spans.append((cur_label, start_idx, prev_idx + 1))
396
+ spans.append((sl, ti, ti + 1)); cur_label = start_idx = None
397
+ elif bt == "B":
398
+ if cur_label is not None and start_idx is not None and prev_idx is not None:
399
+ spans.append((cur_label, start_idx, prev_idx + 1))
400
+ cur_label, start_idx = sl, ti
401
+ elif bt == "I":
402
+ if cur_label is None or cur_label != sl:
403
+ if cur_label is not None and start_idx is not None and prev_idx is not None:
404
+ spans.append((cur_label, start_idx, prev_idx + 1))
405
+ cur_label, start_idx = sl, ti
406
+ elif bt == "E":
407
+ if cur_label is None or cur_label != sl or start_idx is None:
408
+ if cur_label is not None and start_idx is not None and prev_idx is not None:
409
+ spans.append((cur_label, start_idx, prev_idx + 1))
410
+ spans.append((sl, ti, ti + 1)); cur_label = start_idx = None
411
+ else:
412
+ spans.append((cur_label, start_idx, ti + 1)); cur_label = start_idx = None
413
+ else:
414
+ if cur_label is not None and start_idx is not None and prev_idx is not None:
415
+ spans.append((cur_label, start_idx, prev_idx + 1))
416
+ cur_label = start_idx = None
417
+ prev_idx = ti
418
+ if cur_label is not None and start_idx is not None and prev_idx is not None:
419
+ spans.append((cur_label, start_idx, prev_idx + 1))
420
+ return spans
421
+
422
+
423
+ def token_spans_to_char_spans(spans, cs, ce):
424
+ out = []
425
+ for li, ts, te in spans:
426
+ if not (0 <= ts < te <= len(cs)):
427
+ continue
428
+ s, e = cs[ts], ce[te - 1]
429
+ if e > s:
430
+ out.append((li, s, e))
431
+ return out
432
+
433
+
434
+ def trim_char_spans_whitespace(spans, text):
435
+ out = []
436
+ for li, s, e in spans:
437
+ if not (0 <= s < e <= len(text)):
438
+ continue
439
+ while s < e and text[s].isspace(): s += 1
440
+ while e > s and text[e - 1].isspace(): e -= 1
441
+ if e > s:
442
+ out.append((li, s, e))
443
+ return out
444
+
445
+
446
+ # ── viterbi decoder ──────────────────────────────────────────────
447
+
448
+ @functools.lru_cache(maxsize=1)
449
+ def get_viterbi_transition_biases():
450
+ cp = MODEL_DIR / "viterbi_calibration.json"
451
+ default = {k: 0.0 for k in VITERBI_TRANSITION_BIAS_KEYS}
452
+ if not cp.is_file():
453
+ return default
454
+ payload = json.loads(cp.read_text())
455
+ raw = payload
456
+ ops = payload.get("operating_points")
457
+ if isinstance(ops, dict):
458
+ preset = ops.get(DEFAULT_VITERBI_CALIBRATION_PRESET)
459
+ if isinstance(preset, dict):
460
+ raw = preset.get("biases", raw)
461
+ if not isinstance(raw, dict):
462
+ return default
463
+ return {k: float(raw.get(k, 0.0)) for k in VITERBI_TRANSITION_BIAS_KEYS}
464
+
465
+
466
+ class Decoder:
467
+ def __init__(self, label_info):
468
+ nc = len(label_info.token_to_span_label)
469
+ self._start = torch.full((nc,), -1e9, dtype=torch.float32)
470
+ self._end = torch.full((nc,), -1e9, dtype=torch.float32)
471
+ self._trans = torch.full((nc, nc), -1e9, dtype=torch.float32)
472
+ biases = get_viterbi_transition_biases()
473
+ bg_tok, bg_sp = label_info.background_token_label, label_info.background_span_label
474
+ ttsl, tbt = label_info.token_to_span_label, label_info.token_boundary_tags
475
+ for i in range(nc):
476
+ tag, sl = tbt.get(i), ttsl.get(i)
477
+ if tag in {"B", "S"} or i == bg_tok: self._start[i] = 0.0
478
+ if tag in {"E", "S"} or i == bg_tok: self._end[i] = 0.0
479
+ for j in range(nc):
480
+ nt, ns = tbt.get(j), ttsl.get(j)
481
+ if self._valid(tag, sl, nt, ns, bg_tok, bg_sp, j):
482
+ self._trans[i, j] = self._bias(tag, sl, nt, ns, bg_sp, biases)
483
+
484
+ @staticmethod
485
+ def _valid(pt, ps, nt, ns, bti, bsi, ni):
486
+ nb = ns == bsi or ni == bti
487
+ if (ns is None or nt is None) and not nb: return False
488
+ if pt is None or ps is None: return nb or nt in {"B", "S"}
489
+ if ps == bsi or pt in {"E", "S"}: return nb or nt in {"B", "S"}
490
+ if pt in {"B", "I"}: return ps == ns and nt in {"I", "E"}
491
+ return False
492
+
493
+ @staticmethod
494
+ def _bias(pt, ps, nt, ns, bsi, b):
495
+ nb, pb = ns == bsi, ps == bsi
496
+ if pb: return b["transition_bias_background_stay"] if nb else b["transition_bias_background_to_start"]
497
+ if pt in {"B", "I"}: return b["transition_bias_inside_to_continue"] if nt == "I" else b["transition_bias_inside_to_end"]
498
+ return b["transition_bias_end_to_background"] if nb else b["transition_bias_end_to_start"]
499
+
500
+ def decode(self, lp):
501
+ sl, nc = lp.shape
502
+ if sl == 0: return []
503
+ st = self._start.to(lp.device, lp.dtype)
504
+ en = self._end.to(lp.device, lp.dtype)
505
+ tr = self._trans.to(lp.device, lp.dtype)
506
+ scores = lp[0] + st
507
+ bp = torch.empty((sl - 1, nc), device=lp.device, dtype=torch.int64)
508
+ for i in range(1, sl):
509
+ t = scores.unsqueeze(1) + tr
510
+ bs, bi = t.max(dim=0)
511
+ scores = bs + lp[i]; bp[i - 1] = bi
512
+ if not torch.isfinite(scores).any(): return lp.argmax(dim=1).tolist()
513
+ scores += en
514
+ path = torch.empty(sl, device=lp.device, dtype=torch.int64)
515
+ path[-1] = scores.argmax()
516
+ for i in range(sl - 2, -1, -1): path[i] = bp[i, path[i + 1]]
517
+ return path.tolist()
518
+
519
+
520
+ # ── runtime singleton ────────────────────────────────────────────
521
+
522
+ @dataclass(frozen=True)
523
+ class InferenceRuntime:
524
+ model: Transformer; encoding: tiktoken.Encoding; label_info: LabelInfo
525
+ device: torch.device; n_ctx: int
526
+
527
+
528
+ @functools.lru_cache(maxsize=1)
529
+ def get_runtime():
530
+ cp = MODEL_DIR
531
+ cfg = json.loads((cp / "config.json").read_text())
532
+ validate_model_config_contract(cfg, context=str(cp))
533
+ device = torch.device("cuda")
534
+ encoding = tiktoken.get_encoding(str(cfg["encoding"]).strip())
535
+ scn = [BACKGROUND_CLASS_LABEL]; sll = {BACKGROUND_CLASS_LABEL: 0}
536
+ bll, ttsl, tbt = {}, {}, {}
537
+ bg_idx = None
538
+ for idx, name in enumerate(NER_CLASS_NAMES):
539
+ if name == BACKGROUND_CLASS_LABEL:
540
+ bg_idx = idx; ttsl[idx] = 0; tbt[idx] = None; continue
541
+ bnd, base = name.split("-", 1)
542
+ si = sll.get(base)
543
+ if si is None:
544
+ si = len(scn); scn.append(base); sll[base] = si
545
+ ttsl[idx] = si; tbt[idx] = bnd
546
+ bll.setdefault(base, {})[bnd] = idx
547
+ li = LabelInfo(bll, ttsl, tbt, tuple(scn), sll, bg_idx, 0)
548
+ m = Transformer.from_checkpoint(str(cp), device=device)
549
+ return InferenceRuntime(m, encoding, li, device, int(cfg["default_n_ctx"]))
550
+
551
+
552
+ @torch.inference_mode()
553
+ def predict_text(runtime, text, decoder):
554
+ tids = tuple(int(t) for t in runtime.encoding.encode(text, allowed_special="all"))
555
+ if not tids: return text, []
556
+ scores = []
557
+ for s in range(0, len(tids), runtime.n_ctx):
558
+ e = min(s + runtime.n_ctx, len(tids))
559
+ wt = torch.tensor(tids[s:e], device=runtime.device, dtype=torch.int32)
560
+ lp = F.log_softmax(runtime.model(wt).float(), dim=-1)
561
+ scores.extend(lp.unbind(0))
562
+ stacked = torch.stack(scores, 0)
563
+ dl = decoder.decode(stacked)
564
+ if len(dl) != len(tids): dl = stacked.argmax(dim=1).tolist()
565
+ pli = {i: int(l) for i, l in enumerate(dl)}
566
+ pts = labels_to_spans(pli, runtime.label_info)
567
+ tb = [runtime.encoding.decode_single_token_bytes(t) for t in tids]
568
+ dt = b"".join(tb).decode("utf-8", errors="replace")
569
+ cbs, cbe = [], []
570
+ bc = 0
571
+ for ch in dt: cbs.append(bc); bc += len(ch.encode("utf-8")); cbe.append(bc)
572
+ cs, ce = [], []
573
+ tbc = 0
574
+ for rb in tb:
575
+ tbs = tbc; tbe = tbs + len(rb); tbc = tbe
576
+ cs.append(bisect_right(cbe, tbs)); ce.append(bisect_left(cbs, tbe))
577
+ pcs = token_spans_to_char_spans(pts, cs, ce)
578
+ pcs = trim_char_spans_whitespace(pcs, dt if dt != text else text)
579
+ src = dt if dt != text else text
580
+ detected = []
581
+ for li, s, e in pcs:
582
+ if 0 <= li < len(runtime.label_info.span_class_names):
583
+ lbl = runtime.label_info.span_class_names[li]
584
+ else:
585
+ lbl = f"label_{li}"
586
+ detected.append({"label": lbl, "start": s, "end": e, "text": src[s:e]})
587
+ return src, detected
588
+
589
+
590
+ # =====================================================================
591
+ # APPLICATION LAYER
592
+ # =====================================================================
593
+
594
+ def extract_text(file_path: str) -> str:
595
+ suffix = Path(file_path).suffix.lower()
596
+ if suffix == ".pdf":
597
+ import fitz
598
+ doc = fitz.open(file_path)
599
+ pages = [page.get_text() for page in doc]
600
+ doc.close()
601
+ return "\n\n".join(pages)
602
+ elif suffix in (".docx", ".doc"):
603
+ from docx import Document
604
+ doc = Document(file_path)
605
+ return "\n\n".join(p.text for p in doc.paragraphs if p.text.strip())
606
+ raise ValueError(f"Unsupported file type: {suffix}")
607
+
608
+
609
+ def compute_stats(text, spans):
610
+ total = len(text)
611
+ pii_chars = sum(s["end"] - s["start"] for s in spans)
612
+ by_cat = {}
613
+ for s in spans:
614
+ c = s["label"]
615
+ by_cat.setdefault(c, {"count": 0, "chars": 0})
616
+ by_cat[c]["count"] += 1; by_cat[c]["chars"] += s["end"] - s["start"]
617
+ return {
618
+ "total_chars": total, "pii_chars": pii_chars,
619
+ "pii_percentage": round(pii_chars / total * 100, 1) if total else 0,
620
+ "total_spans": len(spans), "categories": by_cat, "num_categories": len(by_cat),
621
+ "total_lines": text.count("\n") + 1 if total else 0,
622
+ }
623
+
624
+
625
+ def detect_speakers(text, spans):
626
+ patterns = [r"^([A-Z][a-zA-Z ]{1,30}):\s", r"^\[([^\]]{1,30})\]\s", r"^(Speaker\s*\d+):\s"]
627
+ line_sp, pos, cur = [], 0, None
628
+ for line in text.split("\n"):
629
+ for p in patterns:
630
+ m = re.match(p, line)
631
+ if m: cur = m.group(1).strip(); break
632
+ line_sp.append((pos, pos + len(line), cur)); pos += len(line) + 1
633
+ result = {}
634
+ for span in spans:
635
+ mid = (span["start"] + span["end"]) // 2
636
+ speaker = "Document"
637
+ for ls, le, sp in line_sp:
638
+ if ls <= mid <= le and sp: speaker = sp; break
639
+ result[speaker] = result.get(speaker, 0) + 1
640
+ return {} if list(result.keys()) == ["Document"] else result
641
+
642
+
643
+ @spaces.GPU
644
+ def run_pii_analysis(text: str):
645
+ """GPU-accelerated PII detection."""
646
+ runtime = get_runtime()
647
+ decoder = Decoder(label_info=runtime.label_info)
648
+ source_text, detected = predict_text(runtime, text, decoder)
649
+ return source_text, detected
650
+
651
+
652
+ # ── Gradio Server ────────────────────────────────────────────────
653
+ server = gr.Server()
654
+
655
+
656
+ @server.get("/", response_class=HTMLResponse)
657
+ async def homepage():
658
+ return FRONTEND_HTML
659
+
660
+
661
+ @server.post("/api/analyze")
662
+ async def analyze_document(file: UploadFile = File(...)):
663
+ suffix = Path(file.filename).suffix.lower()
664
+ if suffix not in (".pdf", ".doc", ".docx"):
665
+ return JSONResponse({"error": f"Unsupported: {suffix}. Use PDF, DOC, or DOCX."}, 400)
666
+ with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
667
+ tmp.write(await file.read()); tmp_path = tmp.name
668
+ try:
669
+ text = extract_text(tmp_path)
670
+ if not text.strip():
671
+ return JSONResponse({"error": "No text content found."}, 400)
672
+ source_text, spans = run_pii_analysis(text)
673
+ stats = compute_stats(source_text, spans)
674
+ speakers = detect_speakers(source_text, spans)
675
+ return JSONResponse({
676
+ "filename": file.filename, "text": source_text, "spans": spans,
677
+ "stats": stats, "speakers": speakers,
678
+ "categories_meta": {k: {"color": v["color"], "cls": v["cls"],
679
+ "label": v["label"], "mono": v["mono"]}
680
+ for k, v in CATEGORIES_META.items()},
681
+ })
682
+ except Exception as e:
683
+ return JSONResponse({"error": str(e)}, 500)
684
+ finally:
685
+ if os.path.exists(tmp_path): os.unlink(tmp_path)
686
+
687
+
688
+ @server.api(name="analyze_text")
689
+ def analyze_text_api(text: str) -> str:
690
+ """Gradio API: analyze raw text for PII."""
691
+ source_text, spans = run_pii_analysis(text)
692
+ stats = compute_stats(source_text, spans)
693
+ return json.dumps({"text": source_text, "spans": spans, "stats": stats}, ensure_ascii=False)
694
+
695
+
696
+ # ── Frontend HTML (v4 β€” Inspector, revised) ──────────────────────
697
+ FRONTEND_HTML = r"""<!DOCTYPE html>
698
+ <html lang="en">
699
+ <head>
700
+ <meta charset="UTF-8">
701
+ <meta name="viewport" content="width=device-width,initial-scale=1">
702
+ <title>PII Reveal β€” Inspector</title>
703
+ <link rel="preconnect" href="https://fonts.googleapis.com">
704
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
705
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&family=Source+Serif+4:opsz,wght@8..60,400;8..60,500&display=swap" rel="stylesheet">
706
+ <style>
707
+ /* =========================================================
708
+ Theme tokens β€” Gradio-compatible CSS variables with light
709
+ fallbacks; dark fallbacks driven by prefers-color-scheme
710
+ and an explicit .dark escape hatch.
711
+ ========================================================= */
712
+ :root{
713
+ --body-background-fill: #f1efea;
714
+ --block-background-fill: #faf9f6;
715
+ --block-background-fill-2: #f3f2ed;
716
+ --body-text-color: #17171a;
717
+ --body-text-color-subdued: #6b6b6b;
718
+ --body-text-color-faint: #9a9aa2;
719
+ --border-color-primary: rgba(23,23,26,0.08);
720
+ --border-color-accent: rgba(23,23,26,0.16);
721
+ --primary-bg: #17171a;
722
+ --primary-fg: #faf9f6;
723
+
724
+ --border-radius-lg: 10px;
725
+ --border-radius-md: 6px;
726
+ --border-radius-sm: 4px;
727
+
728
+ --font-sans: 'Inter', system-ui, -apple-system, 'Segoe UI', sans-serif;
729
+ --font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
730
+ --font-serif: 'Source Serif 4', 'Source Serif Pro', 'Iowan Old Style', Georgia, serif;
731
+ }
732
+
733
+ @media (prefers-color-scheme: dark){
734
+ :root{
735
+ --body-background-fill: #0e0e11;
736
+ --block-background-fill: #18181c;
737
+ --block-background-fill-2: #1f1f24;
738
+ --body-text-color: #e8e8ea;
739
+ --body-text-color-subdued: #a8a8ae;
740
+ --body-text-color-faint: #70707a;
741
+ --border-color-primary: rgba(255,255,255,0.08);
742
+ --border-color-accent: rgba(255,255,255,0.18);
743
+ --primary-bg: #f0f0f2;
744
+ --primary-fg: #0e0e11;
745
+ }
746
+ }
747
+
748
+ .dark, .dark :root, html.dark, body.dark{
749
+ --body-background-fill: #0e0e11;
750
+ --block-background-fill: #18181c;
751
+ --block-background-fill-2: #1f1f24;
752
+ --body-text-color: #e8e8ea;
753
+ --body-text-color-subdued: #a8a8ae;
754
+ --body-text-color-faint: #70707a;
755
+ --border-color-primary: rgba(255,255,255,0.08);
756
+ --border-color-accent: rgba(255,255,255,0.18);
757
+ --primary-bg: #f0f0f2;
758
+ --primary-fg: #0e0e11;
759
+ }
760
+
761
+ *,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
762
+ html,body{height:100%}
763
+ body{
764
+ font-family:var(--font-sans);
765
+ background:var(--body-background-fill);
766
+ color:var(--body-text-color);
767
+ font-size:13px;line-height:1.5;
768
+ -webkit-font-smoothing:antialiased;
769
+ font-feature-settings:"cv11","ss01";
770
+ }
771
+ button{font:inherit;color:inherit;background:transparent;border:0;cursor:pointer}
772
+ .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}
773
+
774
+ /* small-caps label treatment shared across stats + section headings */
775
+ .caps{
776
+ font-size:11px;
777
+ font-weight:500;
778
+ letter-spacing:0.06em;
779
+ text-transform:uppercase;
780
+ color:var(--body-text-color-subdued);
781
+ }
782
+
783
+ /* =========================================================
784
+ Centered container
785
+ ========================================================= */
786
+ .shell{
787
+ max-width:1080px;
788
+ margin:0 auto;
789
+ padding:40px 16px 48px;
790
+ }
791
+
792
+ /* =========================================================
793
+ Upload / landing view
794
+ ========================================================= */
795
+ #upload-view{min-height:100vh;display:flex;align-items:center;justify-content:center}
796
+ #upload-view .shell{width:100%}
797
+
798
+ .u-card{
799
+ display:grid;
800
+ grid-template-columns:1.05fr 0.95fr;
801
+ gap:0;
802
+ background:var(--block-background-fill);
803
+ border:0.5px solid var(--border-color-primary);
804
+ border-radius:var(--border-radius-lg);
805
+ overflow:hidden;
806
+ }
807
+
808
+ .u-left{padding:40px 36px 34px}
809
+ .u-right{
810
+ padding:40px 36px 34px;
811
+ background:var(--block-background-fill-2);
812
+ border-left:0.5px solid var(--border-color-primary);
813
+ display:flex;flex-direction:column;gap:14px;
814
+ }
815
+
816
+ .u-brand{display:flex;align-items:center;gap:10px;margin-bottom:24px}
817
+ .u-brand svg{color:var(--body-text-color)}
818
+ .u-brand-name{font-size:13px;font-weight:500}
819
+ .u-brand-name .sub{color:var(--body-text-color-faint);font-weight:400;margin-left:4px}
820
+ .u-title{
821
+ font-family:var(--font-serif);
822
+ font-size:28px;font-weight:400;letter-spacing:-0.015em;
823
+ line-height:1.18;margin-bottom:10px;
824
+ color:var(--body-text-color);
825
+ }
826
+ .u-sub{color:var(--body-text-color-subdued);font-size:13.5px;margin-bottom:18px;max-width:42ch}
827
+
828
+ /* feature chips β€” 8 categories, matches distribution legend */
829
+ .u-chips{
830
+ display:flex;flex-wrap:wrap;gap:6px 10px;margin-bottom:22px;
831
+ }
832
+ .u-chip{
833
+ display:inline-flex;align-items:center;gap:6px;
834
+ font-size:11.5px;color:var(--body-text-color-subdued);
835
+ }
836
+ .u-chip-dot{width:7px;height:7px;border-radius:2px}
837
+
838
+ .u-drop{
839
+ border:0.5px solid var(--border-color-primary);
840
+ background:color-mix(in srgb, var(--body-text-color) 2%, transparent);
841
+ border-radius:var(--border-radius-md);
842
+ padding:28px 20px;
843
+ cursor:pointer;text-align:center;
844
+ transition:background .15s,border-color .15s;
845
+ position:relative;
846
+ }
847
+ .u-drop:hover,.u-drop.dragover{
848
+ background:color-mix(in srgb, var(--body-text-color) 5%, transparent);
849
+ border-color:var(--border-color-accent);
850
+ }
851
+ .u-drop-icon{margin:0 auto 8px;color:var(--body-text-color-subdued)}
852
+ .u-drop-title{font-size:13px;font-weight:500;margin-bottom:3px}
853
+ .u-drop-sub{font-family:var(--font-mono);font-size:11px;color:var(--body-text-color-faint)}
854
+ .u-drop input{position:absolute;inset:0;opacity:0;cursor:pointer}
855
+
856
+ /* bottom metadata β€” mono with vertical rule separators */
857
+ .u-meta{
858
+ display:flex;flex-wrap:wrap;align-items:center;
859
+ margin-top:20px;
860
+ font-family:var(--font-mono);font-size:11px;color:var(--body-text-color-faint);
861
+ }
862
+ .u-meta > span{
863
+ padding:0 12px;
864
+ border-right:1px solid var(--border-color-primary);
865
+ line-height:1;
866
+ }
867
+ .u-meta > span:first-child{padding-left:0}
868
+ .u-meta > span:last-child{border-right:0;padding-right:0}
869
+
870
+ /* before -> after preview */
871
+ .prev-h{margin-bottom:8px}
872
+ .prev-row{
873
+ display:grid;grid-template-columns:1fr 16px 1fr;
874
+ gap:10px;align-items:start;
875
+ }
876
+ .prev-arrow{
877
+ align-self:center;
878
+ color:var(--body-text-color-faint);
879
+ font-family:var(--font-mono);font-size:12px;
880
+ text-align:center;
881
+ }
882
+ .prev-card{
883
+ background:var(--block-background-fill);
884
+ border:0.5px solid var(--border-color-primary);
885
+ border-radius:var(--border-radius-md);
886
+ padding:14px 14px 12px;
887
+ font-family:var(--font-serif);
888
+ font-size:12.5px;line-height:1.65;
889
+ color:var(--body-text-color);
890
+ min-height:148px;
891
+ }
892
+ .prev-label{
893
+ font-family:var(--font-sans);font-size:10px;font-weight:500;
894
+ letter-spacing:0.08em;text-transform:uppercase;
895
+ color:var(--body-text-color-faint);
896
+ display:block;margin-bottom:8px;
897
+ }
898
+ .prev-card p{margin:0 0 6px}
899
+ .prev-card p:last-child{margin-bottom:0}
900
+ .prev-bar{
901
+ display:inline-block;vertical-align:middle;
902
+ height:0.85em;border-radius:2px;
903
+ background:var(--body-text-color);
904
+ opacity:.82;
905
+ margin:0 1px;
906
+ }
907
+
908
+ /* stat card */
909
+ .u-stat{
910
+ margin-top:auto;
911
+ padding-top:14px;border-top:0.5px solid var(--border-color-primary);
912
+ display:flex;align-items:baseline;gap:8px;
913
+ color:var(--body-text-color-subdued);font-size:12px;
914
+ }
915
+ .u-stat b{
916
+ font-family:var(--font-serif);font-weight:500;font-size:18px;
917
+ color:var(--body-text-color);letter-spacing:-0.01em;
918
+ }
919
+
920
+ /* =========================================================
921
+ Results / inspector view
922
+ ========================================================= */
923
+ #results-view{display:none;min-height:100vh}
924
+ .pr-app{
925
+ font-family:var(--font-sans);
926
+ border:0.5px solid var(--border-color-primary);
927
+ border-radius:var(--border-radius-lg);
928
+ overflow:hidden;
929
+ background:var(--block-background-fill);
930
+ color:var(--body-text-color);
931
+ }
932
+
933
+ /* ── top bar ── */
934
+ .pr-top{
935
+ display:flex;align-items:center;gap:10px;
936
+ padding:11px 14px;
937
+ border-bottom:0.5px solid var(--border-color-primary);
938
+ }
939
+ .pr-logo{display:flex;align-items:center;gap:8px}
940
+ .pr-name{font-size:13px;font-weight:500}
941
+ .pr-name-sub{color:var(--body-text-color-faint);font-weight:400;margin-left:4px}
942
+ .pr-file-chip{
943
+ font-family:var(--font-mono);font-size:11.5px;
944
+ color:var(--body-text-color-subdued);
945
+ padding:4px 8px;
946
+ background:var(--block-background-fill-2);
947
+ border-radius:5px;margin-left:4px;
948
+ max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
949
+ }
950
+ .pr-grow{flex:1}
951
+ .pr-status{font-size:11.5px;color:var(--body-text-color-subdued);display:flex;align-items:center;gap:6px}
952
+ .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)}
953
+
954
+ .pr-top-actions{display:flex;align-items:center;gap:6px}
955
+ .pr-btn{
956
+ font-size:12px;padding:6px 10px;
957
+ border:0.5px solid var(--border-color-accent);
958
+ border-radius:5px;
959
+ background:transparent;color:var(--body-text-color);
960
+ cursor:pointer;
961
+ font-family:inherit;
962
+ display:inline-flex;align-items:center;gap:6px;
963
+ transition:background .12s,border-color .12s;
964
+ }
965
+ .pr-btn:hover{background:color-mix(in srgb, var(--body-text-color) 4%, transparent)}
966
+ .pr-btn-ghost{border-color:var(--border-color-primary);color:var(--body-text-color-subdued)}
967
+ .pr-btn-ghost:hover{color:var(--body-text-color);border-color:var(--border-color-accent)}
968
+ .pr-btn-prim{
969
+ background:var(--primary-bg);
970
+ color:var(--primary-fg);
971
+ border-color:var(--primary-bg);
972
+ font-weight:500;
973
+ }
974
+ .pr-btn-prim:hover{background:color-mix(in srgb, var(--primary-bg) 85%, var(--body-text-color))}
975
+ .pr-btn-arr{font-family:var(--font-mono);font-size:11px;opacity:0.6}
976
+
977
+ /* ── stats ── */
978
+ .pr-stats{padding:18px 18px 16px;border-bottom:0.5px solid var(--border-color-primary)}
979
+ .pr-stats-row{display:flex;align-items:flex-end;gap:34px;margin-bottom:14px;flex-wrap:wrap}
980
+ .pr-hero{
981
+ font-size:32px;font-weight:500;line-height:1;letter-spacing:-0.025em;
982
+ font-variant-numeric:tabular-nums;
983
+ }
984
+ .pr-hero-pct{font-size:17px;opacity:0.55;margin-left:1px;font-weight:400}
985
+ .pr-num{font-size:20px;font-weight:500;line-height:1;letter-spacing:-0.01em;font-variant-numeric:tabular-nums}
986
+ .pr-lab{margin-top:10px} /* overrides for .caps positioning */
987
+
988
+ .pr-bar{display:flex;height:4px;gap:2px;margin-bottom:12px;border-radius:2px;overflow:hidden}
989
+ .pr-bar > span{display:block;height:100%;border-radius:1px;min-width:4px;transition:opacity .15s}
990
+ .pr-bar > span:hover{opacity:.82}
991
+
992
+ .pr-legend{display:flex;flex-wrap:wrap;gap:8px 14px;font-size:12px}
993
+ .pr-leg{display:flex;align-items:center;gap:6px;color:var(--body-text-color-subdued);cursor:pointer;user-select:none}
994
+ .pr-leg-sw{width:8px;height:8px;border-radius:2px}
995
+ .pr-leg-ct{font-family:var(--font-mono);font-size:11px;color:var(--body-text-color-faint);margin-left:1px}
996
+ .pr-leg.off{opacity:.4}
997
+ .pr-leg.off .pr-leg-sw{opacity:.3}
998
+
999
+ /* ── body ── */
1000
+ .pr-body{display:grid;grid-template-columns:minmax(0,1fr) 220px}
1001
+
1002
+ /* ── doc pane ── */
1003
+ .pr-doc-pane{
1004
+ padding:18px 22px 26px;
1005
+ border-right:0.5px solid var(--border-color-primary);
1006
+ min-width:0;max-height:calc(100vh - 260px);overflow-y:auto;
1007
+ }
1008
+ .pr-doc-meta{
1009
+ font-family:var(--font-mono);font-size:11px;color:var(--body-text-color-faint);
1010
+ margin-bottom:14px;display:flex;gap:10px;flex-wrap:wrap;
1011
+ }
1012
+ .pr-doc-meta span + span::before{content:'Β·';margin-right:10px;color:var(--border-color-accent)}
1013
+
1014
+ .pr-text{
1015
+ font-family:var(--font-serif);
1016
+ font-size:14.5px;line-height:1.85;
1017
+ color:var(--body-text-color);
1018
+ white-space:pre-wrap;word-wrap:break-word;
1019
+ font-feature-settings:"liga","calt";
1020
+ }
1021
+
1022
+ /* highlights β€” rebuilt on color-mix() so they render on both themes */
1023
+ .h{
1024
+ padding:1px 1px;
1025
+ border-bottom:1.5px solid;
1026
+ transition:background .15s,opacity .15s;
1027
+ cursor:pointer;
1028
+ }
1029
+ .h:hover{filter:brightness(0.96) saturate(1.1)}
1030
+ .h.off{
1031
+ background:transparent !important;
1032
+ border-color:transparent !important;
1033
+ color:inherit;opacity:.9;
1034
+ }
1035
+ .hp {background:color-mix(in srgb, #E24B4A 12%, transparent); border-color:#E24B4A}
1036
+ .hd {background:color-mix(in srgb, #7F77DD 14%, transparent); border-color:#7F77DD}
1037
+ .ha {background:color-mix(in srgb, #1D9E75 12%, transparent); border-color:#1D9E75}
1038
+ .he {background:color-mix(in srgb, #378ADD 12%, transparent); border-color:#378ADD}
1039
+ .hac {background:color-mix(in srgb, #BA7517 14%, transparent); border-color:#BA7517}
1040
+ .hu {background:color-mix(in srgb, #D85A30 13%, transparent); border-color:#D85A30}
1041
+ .hs {background:color-mix(in srgb, #D4537E 14%, transparent); border-color:#D4537E}
1042
+ .hph {background:color-mix(in srgb, #639922 14%, transparent); border-color:#639922}
1043
+ .m{font-family:var(--font-mono);font-size:12.5px}
1044
+
1045
+ /* ── sidebar ── */
1046
+ .pr-side{
1047
+ background:var(--block-background-fill-2);
1048
+ padding:16px 14px;
1049
+ display:flex;flex-direction:column;gap:20px;
1050
+ min-width:0;
1051
+ }
1052
+
1053
+ .pr-side-head{
1054
+ display:flex;align-items:baseline;justify-content:space-between;gap:8px;
1055
+ margin-bottom:8px;
1056
+ }
1057
+ .pr-side-link{
1058
+ font-size:11px;color:var(--body-text-color-subdued);
1059
+ cursor:pointer;background:transparent;border:0;padding:0;
1060
+ font-family:inherit;
1061
+ }
1062
+ .pr-side-link:hover{color:var(--body-text-color);text-decoration:underline}
1063
+
1064
+ /* === interactive pill === */
1065
+ .pr-cat{
1066
+ position:relative;
1067
+ display:grid;
1068
+ grid-template-columns:9px 1fr auto;
1069
+ column-gap:8px;row-gap:4px;
1070
+ align-items:center;
1071
+ padding:8px 10px 7px;
1072
+ border-radius:var(--border-radius-sm);
1073
+ background:color-mix(in srgb, var(--body-text-color) 3%, transparent);
1074
+ border:0.5px solid transparent;
1075
+ cursor:pointer;user-select:none;
1076
+ transition:background .12s,border-color .12s,opacity .15s;
1077
+ margin-bottom:4px;
1078
+ overflow:hidden;
1079
+ }
1080
+ .pr-cat:hover{
1081
+ border-color:var(--border-color-accent);
1082
+ }
1083
+ .pr-cat-sw{width:9px;height:9px;border-radius:2px;flex-shrink:0;grid-row:1}
1084
+ .pr-cat-nm{grid-row:1;color:var(--body-text-color);font-size:12.5px;font-weight:500}
1085
+ .pr-cat-ct{grid-row:1;font-family:var(--font-mono);font-size:11px;color:var(--body-text-color-faint);text-align:right}
1086
+
1087
+ /* mini-bar β€” thin horizontal bar under the label, spans columns 2-3 */
1088
+ .pr-cat-mini{
1089
+ grid-column:2 / 4;grid-row:2;
1090
+ height:1.5px;width:100%;
1091
+ background:color-mix(in srgb, var(--body-text-color) 6%, transparent);
1092
+ border-radius:1px;overflow:hidden;position:relative;
1093
+ }
1094
+ .pr-cat-mini > span{
1095
+ display:block;height:100%;border-radius:1px;
1096
+ transition:width .2s,background .15s;
1097
+ }
1098
+
1099
+ /* active state */
1100
+ .pr-cat.on{
1101
+ background:color-mix(in srgb, var(--cat) 8%, transparent);
1102
+ box-shadow:inset 3px 0 0 0 var(--cat);
1103
+ padding-left:13px;
1104
+ }
1105
+ .pr-cat.on .pr-cat-nm{color:var(--body-text-color)}
1106
+
1107
+ /* off state */
1108
+ .pr-cat.off{opacity:.42;filter:saturate(.35)}
1109
+ .pr-cat.off .pr-cat-nm{text-decoration:line-through}
1110
+ .pr-cat.off .pr-cat-mini > span{background:var(--body-text-color-faint) !important}
1111
+
1112
+ /* speakers β€” simpler rows, no interactivity */
1113
+ .pr-speakers .pr-cat{
1114
+ cursor:default;background:transparent;border-color:transparent;padding:4px 2px;
1115
+ grid-template-columns:9px 1fr auto;
1116
+ }
1117
+ .pr-speakers .pr-cat:hover{background:transparent;border-color:transparent}
1118
+ .pr-speakers .pr-cat-sw{background:var(--body-text-color-faint);opacity:.5}
1119
+ .pr-speakers .pr-cat-mini{display:none}
1120
+
1121
+ /* empty state */
1122
+ .empty-rail{color:var(--body-text-color-faint);font-size:12px;font-style:italic}
1123
+
1124
+ /* loading */
1125
+ #loading{
1126
+ position:fixed;inset:0;
1127
+ background:color-mix(in srgb, var(--body-background-fill) 88%, transparent);
1128
+ backdrop-filter:blur(8px);
1129
+ display:none;flex-direction:column;align-items:center;justify-content:center;
1130
+ gap:10px;z-index:9999;
1131
+ }
1132
+ .l-ring{
1133
+ width:26px;height:26px;
1134
+ border:1.5px solid var(--border-color-accent);
1135
+ border-top-color:var(--body-text-color);
1136
+ border-radius:50%;
1137
+ animation:sp .7s linear infinite;
1138
+ }
1139
+ @keyframes sp{to{transform:rotate(360deg)}}
1140
+ .l-label{font-family:var(--font-mono);font-size:11.5px;color:var(--body-text-color-subdued)}
1141
+
1142
+ .error-banner{
1143
+ margin:14px 18px 0;padding:10px 14px;
1144
+ background:color-mix(in srgb, #E24B4A 10%, transparent);
1145
+ border:0.5px solid color-mix(in srgb, #E24B4A 45%, transparent);
1146
+ border-radius:var(--border-radius-md);
1147
+ color:#E24B4A;font-size:12.5px;display:none;
1148
+ }
1149
+
1150
+ /* tooltip */
1151
+ .tip{
1152
+ position:fixed;z-index:9998;
1153
+ font-family:var(--font-mono);font-size:11px;
1154
+ color:var(--primary-fg);
1155
+ background:var(--primary-bg);
1156
+ padding:4px 8px;border-radius:4px;
1157
+ pointer-events:none;white-space:nowrap;
1158
+ max-width:420px;overflow:hidden;text-overflow:ellipsis;
1159
+ }
1160
+
1161
+ @media(max-width:880px){
1162
+ .u-card{grid-template-columns:1fr}
1163
+ .u-right{border-left:0;border-top:0.5px solid var(--border-color-primary)}
1164
+ .pr-body{grid-template-columns:1fr}
1165
+ .pr-doc-pane{border-right:none;border-bottom:0.5px solid var(--border-color-primary);max-height:none}
1166
+ .pr-top-actions .pr-btn span{display:none}
1167
+ }
1168
+ @media(max-width:640px){
1169
+ .pr-top-actions .hide-sm{display:none}
1170
+ .shell{padding:24px 12px}
1171
+ }
1172
+ </style>
1173
+ </head>
1174
+ <body>
1175
+
1176
+ <!-- ============ UPLOAD VIEW ============ -->
1177
+ <div id="upload-view">
1178
+ <div class="shell">
1179
+ <div class="u-card">
1180
+ <div class="u-left">
1181
+ <div class="u-brand">
1182
+ <svg width="20" height="20" viewBox="0 0 20 20" fill="none">
1183
+ <rect x="0" y="0" width="20" height="20" rx="5" fill="currentColor"/>
1184
+ <circle cx="8.5" cy="8.5" r="3.2" stroke="var(--block-background-fill)" stroke-width="1.4" fill="none"/>
1185
+ <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"/>
1186
+ </svg>
1187
+ <span class="u-brand-name">PII Reveal<span class="sub">/ inspector</span></span>
1188
+ </div>
1189
+ <h1 class="u-title">See what your documents are leaking.</h1>
1190
+ <p class="u-sub">Find every PII span in a PDF, DOC or DOCX β€” names, accounts, secrets and five other entity types β€” in under two seconds.</p>
1191
+
1192
+ <div class="u-chips" id="upload-chips">
1193
+ <span class="u-chip"><span class="u-chip-dot" style="background:#E24B4A"></span>Person</span>
1194
+ <span class="u-chip"><span class="u-chip-dot" style="background:#378ADD"></span>Email</span>
1195
+ <span class="u-chip"><span class="u-chip-dot" style="background:#7F77DD"></span>Date</span>
1196
+ <span class="u-chip"><span class="u-chip-dot" style="background:#1D9E75"></span>Address</span>
1197
+ <span class="u-chip"><span class="u-chip-dot" style="background:#BA7517"></span>Account</span>
1198
+ <span class="u-chip"><span class="u-chip-dot" style="background:#D85A30"></span>URL</span>
1199
+ <span class="u-chip"><span class="u-chip-dot" style="background:#639922"></span>Phone</span>
1200
+ <span class="u-chip"><span class="u-chip-dot" style="background:#D4537E"></span>Secret</span>
1201
+ </div>
1202
+
1203
+ <div class="u-drop" id="dropzone">
1204
+ <div class="u-drop-icon">
1205
+ <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
1206
+ <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"/>
1207
+ </svg>
1208
+ </div>
1209
+ <div class="u-drop-title">Drop a document, or click to browse</div>
1210
+ <div class="u-drop-sub">pdf &middot; doc &middot; docx &middot; up to 128k tokens</div>
1211
+ <input type="file" id="file-input" accept=".pdf,.doc,.docx">
1212
+ </div>
1213
+
1214
+ <div class="u-meta">
1215
+ <span>openai privacy filter</span>
1216
+ <span>128k ctx</span>
1217
+ <span>bfloat16</span>
1218
+ <span>apache 2.0</span>
1219
+ </div>
1220
+ </div>
1221
+
1222
+ <div class="u-right" aria-hidden="true">
1223
+ <div class="prev-h caps">Before &rarr; after</div>
1224
+ <div class="prev-row">
1225
+ <div class="prev-card">
1226
+ <span class="prev-label">detected</span>
1227
+ <p>Reporter: <span class="h hp">Dr. Margaret Holloway-Chen</span> called at <span class="h hd m">03:42 GMT</span>.</p>
1228
+ <p>Email: <span class="h he m">margaret.h@protomail.co.uk</span>.</p>
1229
+ <p>Token: <span class="h hs m">sk_live_T3sT4zN9pQ2v</span>.</p>
1230
+ </div>
1231
+ <div class="prev-arrow">&rarr;</div>
1232
+ <div class="prev-card">
1233
+ <span class="prev-label">redacted</span>
1234
+ <p>Reporter: <span class="prev-bar" style="width:11em"></span> called at <span class="prev-bar" style="width:3.5em"></span>.</p>
1235
+ <p>Email: <span class="prev-bar" style="width:9em"></span>.</p>
1236
+ <p>Token: <span class="prev-bar" style="width:7em"></span>.</p>
1237
+ </div>
1238
+ </div>
1239
+ <div class="u-stat">
1240
+ <b>1.4s</b>
1241
+ <span>average scan time on a 128k-token document</span>
1242
+ </div>
1243
+ </div>
1244
+ </div>
1245
+ </div>
1246
+ </div>
1247
+
1248
+ <!-- ============ RESULTS VIEW ============ -->
1249
+ <div id="results-view">
1250
+ <div class="shell">
1251
+ <div class="pr-app" aria-label="PII Reveal inspector">
1252
+
1253
+ <div class="pr-top">
1254
+ <div class="pr-logo">
1255
+ <svg width="20" height="20" viewBox="0 0 20 20" fill="none" style="color: var(--body-text-color);">
1256
+ <rect x="0" y="0" width="20" height="20" rx="5" fill="currentColor"/>
1257
+ <circle cx="8.5" cy="8.5" r="3.2" stroke="var(--block-background-fill)" stroke-width="1.4" fill="none"/>
1258
+ <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"/>
1259
+ </svg>
1260
+ <span class="pr-name">PII Reveal<span class="pr-name-sub">/ inspector</span></span>
1261
+ </div>
1262
+ <span class="pr-file-chip" id="file-chip"></span>
1263
+ <span class="pr-status hide-sm" id="scan-status"><span class="pr-status-dot"></span>Scan complete</span>
1264
+ <div class="pr-grow"></div>
1265
+ <div class="pr-top-actions">
1266
+ <button class="pr-btn pr-btn-ghost" id="act-copy"><span>Copy sanitized</span></button>
1267
+ <button class="pr-btn pr-btn-ghost" id="act-report"><span>Report</span></button>
1268
+ <button class="pr-btn pr-btn-prim" id="act-redact"><span>Redact &amp; export</span><span class="pr-btn-arr">&rarr;</span></button>
1269
+ <button class="pr-btn" id="btn-new"><span>New file</span></button>
1270
+ </div>
1271
+ </div>
1272
+
1273
+ <div class="error-banner" id="error-banner"></div>
1274
+
1275
+ <div class="pr-stats">
1276
+ <div class="pr-stats-row">
1277
+ <div>
1278
+ <div class="pr-hero"><span id="hero-val">0</span><span class="pr-hero-pct">%</span></div>
1279
+ <div class="caps pr-lab">PII content</div>
1280
+ </div>
1281
+ <div>
1282
+ <div class="pr-num" id="num-spans">0</div>
1283
+ <div class="caps pr-lab">Spans detected</div>
1284
+ </div>
1285
+ <div>
1286
+ <div class="pr-num" id="num-cats">0 / 8</div>
1287
+ <div class="caps pr-lab">Categories present</div>
1288
+ </div>
1289
+ <div>
1290
+ <div class="pr-num" id="num-speakers">0</div>
1291
+ <div class="caps pr-lab">Speakers identified</div>
1292
+ </div>
1293
+ </div>
1294
+
1295
+ <div class="pr-bar" id="dist-bar"></div>
1296
+ <div class="pr-legend" id="legend"></div>
1297
+ </div>
1298
+
1299
+ <div class="pr-body">
1300
+ <div class="pr-doc-pane">
1301
+ <div class="pr-doc-meta" id="doc-meta"></div>
1302
+ <div class="pr-text" id="doc-text"></div>
1303
+ </div>
1304
+
1305
+ <aside class="pr-side">
1306
+ <div>
1307
+ <div class="pr-side-head">
1308
+ <span class="caps">Filter categories</span>
1309
+ <button class="pr-side-link" id="cat-toggle-all">Clear all</button>
1310
+ </div>
1311
+ <div id="cat-list"></div>
1312
+ </div>
1313
+ <div id="speakers-block" style="display:none">
1314
+ <div class="pr-side-head">
1315
+ <span class="caps">Speakers</span>
1316
+ </div>
1317
+ <div class="pr-speakers" id="speakers-list"></div>
1318
+ </div>
1319
+ </aside>
1320
+ </div>
1321
+ </div>
1322
+ </div>
1323
+ </div>
1324
+
1325
+ <div id="loading">
1326
+ <div class="l-ring"></div>
1327
+ <div class="l-label">scanning document&hellip;</div>
1328
+ </div>
1329
+
1330
+ <div class="tip" id="tip" style="display:none"></div>
1331
+
1332
+ <script>
1333
+ /* ===== state ===== */
1334
+ const S = {
1335
+ text:'', spans:[], stats:{}, speakers:{}, catMeta:{}, filename:'',
1336
+ activeCats:new Set(), scanMs:0, sortedSpans:[],
1337
+ };
1338
+
1339
+ const DEFAULT_META = {
1340
+ private_person: {color:'#E24B4A', cls:'hp', label:'Person', mono:false},
1341
+ private_date: {color:'#7F77DD', cls:'hd', label:'Date', mono:true},
1342
+ private_address: {color:'#1D9E75', cls:'ha', label:'Address', mono:false},
1343
+ private_email: {color:'#378ADD', cls:'he', label:'Email', mono:true},
1344
+ account_number: {color:'#BA7517', cls:'hac', label:'Account', mono:true},
1345
+ private_url: {color:'#D85A30', cls:'hu', label:'URL', mono:true},
1346
+ secret: {color:'#D4537E', cls:'hs', label:'Secret', mono:true},
1347
+ private_phone: {color:'#639922', cls:'hph', label:'Phone', mono:true},
1348
+ };
1349
+ const ORDER = ['private_person','private_address','private_email','private_phone',
1350
+ 'private_url','private_date','account_number','secret'];
1351
+
1352
+ const metaFor = c => ({...(DEFAULT_META[c]||{color:'#999',cls:'',label:c,mono:false}), ...(S.catMeta[c]||{})});
1353
+
1354
+ /* ===== upload flow ===== */
1355
+ const dz = document.getElementById('dropzone');
1356
+ const fi = document.getElementById('file-input');
1357
+ ['dragenter','dragover'].forEach(e => dz.addEventListener(e, ev => { ev.preventDefault(); dz.classList.add('dragover'); }));
1358
+ ['dragleave','drop'].forEach(e => dz.addEventListener(e, ev => { ev.preventDefault(); dz.classList.remove('dragover'); }));
1359
+ dz.addEventListener('drop', ev => { if (ev.dataTransfer.files[0]) uploadFile(ev.dataTransfer.files[0]); });
1360
+ fi.addEventListener('change', ev => { if (ev.target.files[0]) uploadFile(ev.target.files[0]); });
1361
+
1362
+ async function uploadFile(file){
1363
+ const ext = file.name.split('.').pop().toLowerCase();
1364
+ if (!['pdf','doc','docx'].includes(ext)) { showError('Unsupported file type.'); return; }
1365
+ document.getElementById('loading').style.display='flex';
1366
+ document.getElementById('upload-view').style.display='none';
1367
+ const form = new FormData(); form.append('file', file);
1368
+ const t0 = performance.now();
1369
+ try{
1370
+ const r = await fetch('/api/analyze', {method:'POST', body:form});
1371
+ const d = await r.json();
1372
+ if (d.error) { showError(d.error); return; }
1373
+ S.scanMs = performance.now() - t0;
1374
+ S.text = d.text; S.spans = d.spans; S.stats = d.stats;
1375
+ S.speakers = d.speakers||{}; S.catMeta = d.categories_meta||{};
1376
+ S.filename = d.filename;
1377
+ S.activeCats = new Set(Object.keys(d.stats.categories));
1378
+ S.sortedSpans = [...S.spans].sort((a,b) => a.start - b.start);
1379
+ renderResults();
1380
+ } catch(e){ showError('Analysis failed: '+e.message); }
1381
+ finally { document.getElementById('loading').style.display='none'; }
1382
+ }
1383
+
1384
+ function showError(m){
1385
+ document.getElementById('loading').style.display='none';
1386
+ document.getElementById('upload-view').style.display='flex';
1387
+ document.getElementById('results-view').style.display='none';
1388
+ alert(m);
1389
+ }
1390
+
1391
+ function resetView(){
1392
+ document.getElementById('results-view').style.display='none';
1393
+ document.getElementById('upload-view').style.display='flex';
1394
+ fi.value = '';
1395
+ }
1396
+ document.getElementById('btn-new').addEventListener('click', resetView);
1397
+
1398
+ /* ===== render ===== */
1399
+ function renderResults(){
1400
+ document.getElementById('results-view').style.display='block';
1401
+ document.getElementById('file-chip').textContent = S.filename;
1402
+ document.getElementById('scan-status').innerHTML =
1403
+ `<span class="pr-status-dot"></span>Scan complete &middot; ${(S.scanMs/1000).toFixed(1)}s`;
1404
+ renderStats();
1405
+ renderBar();
1406
+ renderLegend();
1407
+ renderDocMeta();
1408
+ renderDoc();
1409
+ renderCats();
1410
+ renderSpeakers();
1411
+ updateToggleAllLabel();
1412
+ }
1413
+
1414
+ function renderStats(){
1415
+ const s = S.stats;
1416
+ document.getElementById('hero-val').textContent = (s.pii_percentage ?? 0).toFixed(1);
1417
+ document.getElementById('num-spans').textContent = s.total_spans;
1418
+ document.getElementById('num-cats').textContent = `${s.num_categories} / 8`;
1419
+ const n = Object.keys(S.speakers).length;
1420
+ document.getElementById('num-speakers').textContent = n || 'β€”';
1421
+ }
1422
+
1423
+ function renderBar(){
1424
+ const bar = document.getElementById('dist-bar');
1425
+ bar.innerHTML = '';
1426
+ const cats = S.stats.categories;
1427
+ const total = Object.values(cats).reduce((a,b) => a + b.chars, 0) || 1;
1428
+ const ordered = ORDER.filter(c => cats[c]);
1429
+ if (!ordered.length) {
1430
+ const span = document.createElement('span');
1431
+ span.style.cssText = 'flex:1;background:var(--border-color-primary);opacity:.4';
1432
+ bar.appendChild(span); return;
1433
+ }
1434
+ for (const c of ordered) {
1435
+ const m = metaFor(c);
1436
+ const span = document.createElement('span');
1437
+ span.style.background = m.color;
1438
+ span.style.flex = cats[c].chars / total;
1439
+ span.dataset.cat = c;
1440
+ span.addEventListener('mouseenter', ev => showTip(ev, `${m.label} Β· ${cats[c].count}`));
1441
+ span.addEventListener('mousemove', moveTip);
1442
+ span.addEventListener('mouseleave', hideTip);
1443
+ if (!S.activeCats.has(c)) span.style.opacity = '.25';
1444
+ bar.appendChild(span);
1445
+ }
1446
+ }
1447
+
1448
+ function renderLegend(){
1449
+ const leg = document.getElementById('legend');
1450
+ leg.innerHTML = '';
1451
+ const cats = S.stats.categories;
1452
+ const ordered = ORDER.filter(c => cats[c]);
1453
+ for (const c of ordered) {
1454
+ const m = metaFor(c);
1455
+ const el = document.createElement('span');
1456
+ el.className = 'pr-leg' + (S.activeCats.has(c) ? '' : ' off');
1457
+ el.dataset.cat = c;
1458
+ el.innerHTML = `<span class="pr-leg-sw" style="background:${m.color}"></span>${m.label}<span class="pr-leg-ct">${cats[c].count}</span>`;
1459
+ el.addEventListener('click', () => toggleCat(c));
1460
+ leg.appendChild(el);
1461
+ }
1462
+ }
1463
+
1464
+ function renderDocMeta(){
1465
+ const s = S.stats;
1466
+ const meta = document.getElementById('doc-meta');
1467
+ const parts = [
1468
+ `${s.total_chars.toLocaleString()} characters`,
1469
+ `${s.total_lines.toLocaleString()} lines`,
1470
+ `scanned in ${(S.scanMs/1000).toFixed(1)}s`,
1471
+ ];
1472
+ meta.innerHTML = parts.map(p => `<span>${p}</span>`).join('');
1473
+ }
1474
+
1475
+ function esc(s){ const d=document.createElement('div'); d.textContent=s; return d.innerHTML; }
1476
+
1477
+ function renderDoc(){
1478
+ const { text, sortedSpans, activeCats } = S;
1479
+ const el = document.getElementById('doc-text');
1480
+ let html = '', pos = 0;
1481
+ for (const sp of sortedSpans) {
1482
+ if (sp.start < pos) continue;
1483
+ if (sp.start > pos) html += esc(text.substring(pos, sp.start));
1484
+ const m = metaFor(sp.label);
1485
+ const cls = ['h', m.cls];
1486
+ if (m.mono) cls.push('m');
1487
+ if (!activeCats.has(sp.label)) cls.push('off');
1488
+ html += `<span class="${cls.join(' ')}" data-cat="${sp.label}">${esc(text.substring(sp.start, sp.end))}</span>`;
1489
+ pos = sp.end;
1490
+ }
1491
+ if (pos < text.length) html += esc(text.substring(pos));
1492
+ el.innerHTML = html;
1493
+
1494
+ el.querySelectorAll('.h').forEach(span => {
1495
+ const cat = span.dataset.cat, m = metaFor(cat);
1496
+ span.addEventListener('mouseenter', ev => showTip(ev, `${m.label}: ${span.textContent.trim()}`));
1497
+ span.addEventListener('mousemove', moveTip);
1498
+ span.addEventListener('mouseleave', hideTip);
1499
+ });
1500
+ }
1501
+
1502
+ function renderCats(){
1503
+ const box = document.getElementById('cat-list');
1504
+ box.innerHTML = '';
1505
+ const cats = S.stats.categories;
1506
+ const ordered = ORDER.filter(c => cats[c]);
1507
+ if (!ordered.length) { box.innerHTML = '<div class="empty-rail">No entities detected.</div>'; return; }
1508
+ const totalSpans = S.stats.total_spans || 1;
1509
+ for (const c of ordered) {
1510
+ const m = metaFor(c);
1511
+ const count = cats[c].count;
1512
+ const share = (count / totalSpans) * 100;
1513
+ const active = S.activeCats.has(c);
1514
+ const el = document.createElement('div');
1515
+ el.className = 'pr-cat' + (active ? ' on' : ' off');
1516
+ el.dataset.cat = c;
1517
+ el.style.setProperty('--cat', m.color);
1518
+ el.innerHTML = `
1519
+ <span class="pr-cat-sw" style="background:${m.color}"></span>
1520
+ <span class="pr-cat-nm">${m.label}</span>
1521
+ <span class="pr-cat-ct">${count}</span>
1522
+ <span class="pr-cat-mini"><span style="width:${share.toFixed(1)}%;background:${m.color}"></span></span>`;
1523
+ el.addEventListener('click', () => toggleCat(c));
1524
+ box.appendChild(el);
1525
+ }
1526
+ }
1527
+
1528
+ function renderSpeakers(){
1529
+ const names = Object.keys(S.speakers);
1530
+ const block = document.getElementById('speakers-block');
1531
+ const box = document.getElementById('speakers-list');
1532
+ if (!names.length) { block.style.display = 'none'; return; }
1533
+ block.style.display = 'block';
1534
+ box.innerHTML = '';
1535
+ for (const n of names) {
1536
+ const el = document.createElement('div');
1537
+ el.className = 'pr-cat';
1538
+ 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>`;
1539
+ box.appendChild(el);
1540
+ }
1541
+ }
1542
+
1543
+ function toggleCat(c){
1544
+ if (S.activeCats.has(c)) S.activeCats.delete(c);
1545
+ else S.activeCats.add(c);
1546
+ const on = S.activeCats.has(c);
1547
+ document.querySelectorAll(`.pr-cat[data-cat="${c}"]`).forEach(el => {
1548
+ el.classList.toggle('on', on); el.classList.toggle('off', !on);
1549
+ });
1550
+ document.querySelectorAll(`.pr-leg[data-cat="${c}"]`).forEach(el => el.classList.toggle('off', !on));
1551
+ document.querySelectorAll(`.h[data-cat="${c}"]`).forEach(el => el.classList.toggle('off', !on));
1552
+ document.querySelectorAll(`.pr-bar span[data-cat="${c}"]`).forEach(el => el.style.opacity = on ? '1' : '.25');
1553
+ updateToggleAllLabel();
1554
+ }
1555
+
1556
+ function updateToggleAllLabel(){
1557
+ const btn = document.getElementById('cat-toggle-all');
1558
+ if (!btn) return;
1559
+ const all = Object.keys(S.stats.categories||{});
1560
+ const allOn = all.length > 0 && all.every(c => S.activeCats.has(c));
1561
+ btn.textContent = allOn ? 'Clear all' : 'Select all';
1562
+ }
1563
+
1564
+ document.getElementById('cat-toggle-all').addEventListener('click', () => {
1565
+ const all = Object.keys(S.stats.categories||{});
1566
+ const allOn = all.every(c => S.activeCats.has(c));
1567
+ all.forEach(c => {
1568
+ const want = !allOn;
1569
+ const has = S.activeCats.has(c);
1570
+ if (want !== has) toggleCat(c);
1571
+ });
1572
+ });
1573
+
1574
+ /* tooltip */
1575
+ function showTip(ev, text){ const t = document.getElementById('tip'); t.textContent = text; t.style.display = 'block'; moveTip(ev); }
1576
+ function moveTip(ev){ const t = document.getElementById('tip'); t.style.left = (ev.clientX + 12) + 'px'; t.style.top = (ev.clientY - 26) + 'px'; }
1577
+ function hideTip(){ document.getElementById('tip').style.display = 'none'; }
1578
+
1579
+ /* ===== actions ===== */
1580
+ function sanitizedText(){
1581
+ const parts = []; let pos = 0;
1582
+ for (const sp of S.sortedSpans) {
1583
+ if (sp.start < pos) continue;
1584
+ if (sp.start > pos) parts.push(S.text.substring(pos, sp.start));
1585
+ const m = metaFor(sp.label);
1586
+ parts.push(S.activeCats.has(sp.label) ? `[${m.label.toUpperCase()}]` : S.text.substring(sp.start, sp.end));
1587
+ pos = sp.end;
1588
+ }
1589
+ if (pos < S.text.length) parts.push(S.text.substring(pos));
1590
+ return parts.join('');
1591
+ }
1592
+
1593
+ function download(name, content, type){
1594
+ const blob = new Blob([content], { type: type || 'text/plain' });
1595
+ const a = document.createElement('a');
1596
+ a.href = URL.createObjectURL(blob); a.download = name;
1597
+ document.body.appendChild(a); a.click(); a.remove();
1598
+ setTimeout(() => URL.revokeObjectURL(a.href), 1000);
1599
+ }
1600
+
1601
+ function baseName(){
1602
+ const f = S.filename || 'document';
1603
+ const i = f.lastIndexOf('.');
1604
+ return i > 0 ? f.slice(0, i) : f;
1605
+ }
1606
+
1607
+ document.getElementById('act-redact').addEventListener('click', () => {
1608
+ download(baseName() + '.redacted.txt', sanitizedText(), 'text/plain');
1609
+ flash('act-redact', 'Exported');
1610
+ });
1611
+ document.getElementById('act-copy').addEventListener('click', async () => {
1612
+ try { await navigator.clipboard.writeText(sanitizedText()); flash('act-copy', 'Copied'); }
1613
+ catch { flash('act-copy', 'Copy failed'); }
1614
+ });
1615
+ document.getElementById('act-report').addEventListener('click', () => {
1616
+ const report = {
1617
+ filename: S.filename,
1618
+ scanned_in_ms: Math.round(S.scanMs),
1619
+ stats: S.stats,
1620
+ speakers: S.speakers,
1621
+ active_categories: [...S.activeCats],
1622
+ spans: S.spans,
1623
+ };
1624
+ download(baseName() + '.report.json', JSON.stringify(report, null, 2), 'application/json');
1625
+ flash('act-report', 'Downloaded');
1626
+ });
1627
+
1628
+ const _flashTimers = {};
1629
+ function flash(id, msg){
1630
+ const btn = document.getElementById(id);
1631
+ const span = btn.querySelector('span');
1632
+ const prev = span ? span.textContent : btn.textContent;
1633
+ if (span) span.textContent = msg; else btn.textContent = msg;
1634
+ clearTimeout(_flashTimers[id]);
1635
+ _flashTimers[id] = setTimeout(() => { if (span) span.textContent = prev; else btn.textContent = prev; }, 1300);
1636
+ }
1637
+ </script>
1638
+ </body>
1639
+ </html>"""
1640
+
1641
+ # ── launch ───────────────────────────────────────────────────────
1642
+ if __name__ == "__main__":
1643
+ server.launch(server_name="0.0.0.0", server_port=7860)