ysharma HF Staff commited on
Commit
a5e9411
Β·
verified Β·
1 Parent(s): 0234c4b

Upload app_v2.py

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