use gradio server

#2
by akhaliq HF Staff - opened
Files changed (2) hide show
  1. app.py +39 -159
  2. index.html +233 -0
app.py CHANGED
@@ -11,10 +11,11 @@ from dataclasses import dataclass
11
  from pathlib import Path
12
  from typing import Final
13
 
14
- import gradio as gr
15
  import spaces
16
  import torch
17
  import torch.nn.functional as F
 
 
18
  from safetensors import safe_open
19
 
20
  import tiktoken
@@ -1229,20 +1230,6 @@ def predict_text(
1229
  return source_text, detected
1230
 
1231
 
1232
- @spaces.GPU
1233
- def predict(text: str) -> dict[str, object]:
1234
- text = text or ""
1235
- if not text.strip():
1236
- return EMPTY_HIGHLIGHT_PAYLOAD
1237
- runtime = get_runtime()
1238
- decoder = Decoder(label_info=runtime.label_info)
1239
- filtered_text, spans = predict_text(runtime, text, decoder)
1240
- return {
1241
- "text": filtered_text,
1242
- "entities": spans,
1243
- }
1244
-
1245
-
1246
  def build_redacted_text(text: str, entities: Sequence[dict[str, object]]) -> str:
1247
  if not text or not entities:
1248
  return text
@@ -1278,159 +1265,52 @@ def build_redacted_text(text: str, entities: Sequence[dict[str, object]]) -> str
1278
  return "".join(redacted_parts)
1279
 
1280
 
1281
- def summarize_entities_markdown(entities: Sequence[dict[str, object]]) -> str:
1282
- if not entities:
1283
- return EMPTY_SUMMARY_MARKDOWN
1284
 
1285
- counts: dict[str, int] = {}
1286
- for entity in entities:
1287
- label = entity.get("entity")
1288
- if not isinstance(label, str):
1289
- continue
1290
- counts[label] = counts.get(label, 0) + 1
1291
- if not counts:
1292
- return EMPTY_SUMMARY_MARKDOWN
1293
 
1294
- ordered_labels = sorted(counts.items(), key=lambda item: (-item[1], item[0]))
1295
- lines = ["**Detected entities**"]
1296
- lines.extend(f"- `{label}`: {count}" for label, count in ordered_labels)
1297
- return "\n".join(lines)
1298
 
1299
 
 
1300
  @spaces.GPU
1301
- def predict_for_demo(text: str) -> tuple[dict[str, object], str, str]:
1302
- prediction = predict(text)
1303
- detected = prediction.get("entities")
1304
- source_text = prediction.get("text")
1305
- entities = detected if isinstance(detected, list) else []
1306
- display_text = source_text if isinstance(source_text, str) else (text or "")
 
 
 
 
 
 
 
 
 
 
 
1307
  redacted_text = build_redacted_text(display_text, entities)
1308
- summary = summarize_entities_markdown(entities)
1309
- return prediction, redacted_text, summary
1310
 
 
 
 
 
 
1311
 
1312
- def build_demo() -> gr.Blocks:
1313
- config_path = MODEL_DIR / "config.json"
1314
- checkpoint_config = json.loads(config_path.read_text(encoding="utf-8"))
1315
- if not isinstance(checkpoint_config, dict):
1316
- raise ValueError(f"Invalid checkpoint config payload at {config_path}")
1317
- validate_model_config_contract(
1318
- checkpoint_config,
1319
- context=str(config_path),
1320
- )
1321
- span_class_names = SPAN_CLASS_NAMES
1322
- web_color_palette = (
1323
- "#e6194b",
1324
- "#3cb44b",
1325
- "#4363d8",
1326
- "#f58231",
1327
- "#911eb4",
1328
- "#008080",
1329
- "#9a6324",
1330
- "#f032e6",
1331
- "#b59f00",
1332
- "#800000",
1333
- "#000075",
1334
- "#808080",
1335
- )
1336
- with gr.Blocks(
1337
- **supported_kwargs(
1338
- gr.Blocks,
1339
- title="OpenAI Privacy Filter",
1340
- fill_width=True,
1341
- elem_id="privacy-filter-app",
1342
- )
1343
- ) as demo:
1344
- gr.Markdown("# OpenAI Privacy Filter Demo")
1345
- gr.Markdown(
1346
- "Detect and redact personal identifiers using `openai/privacy-filter`.\n\n"
1347
- "This demo highlights predicted spans and generates a redacted text variant "
1348
- "with label placeholders."
1349
- )
1350
 
1351
- with gr.Column(variant="panel"):
1352
- input_text = gr.Textbox(
1353
- **supported_kwargs(
1354
- gr.Textbox,
1355
- lines=6,
1356
- label="Input text with PII",
1357
- placeholder="Paste text to detect personal identifiers and generate redacted output...",
1358
- container=False,
1359
- )
1360
- )
1361
- with gr.Row():
1362
- submit_button = gr.Button("Detect & Redact", variant="primary")
1363
- clear_button = gr.Button("Clear")
1364
-
1365
- with gr.Column(variant="panel"):
1366
- output_text = gr.HighlightedText(
1367
- **supported_kwargs(
1368
- gr.HighlightedText,
1369
- label="Detected entities (highlighted)",
1370
- value=EMPTY_HIGHLIGHT_PAYLOAD,
1371
- color_map={
1372
- label: web_color_palette[idx % len(web_color_palette)]
1373
- for idx, label in enumerate(
1374
- label for label in span_class_names if label != BACKGROUND_CLASS_LABEL
1375
- )
1376
- },
1377
- combine_adjacent=False,
1378
- show_legend=False,
1379
- container=True,
1380
- )
1381
- )
1382
- redacted_output = gr.Textbox(
1383
- **supported_kwargs(
1384
- gr.Textbox,
1385
- label="Redacted text output",
1386
- lines=6,
1387
- show_copy_button=True,
1388
- interactive=False,
1389
- )
1390
- )
1391
- entity_summary = gr.Markdown(EMPTY_SUMMARY_MARKDOWN)
1392
- with gr.Accordion("How to read results", open=False):
1393
- gr.Markdown(
1394
- "- Detects 8 span categories: person, email, phone, address, date, URL, "
1395
- "account number, and secrets.\n"
1396
- "- Uses sequence decoding (BIOES + constrained Viterbi) for cleaner boundaries.\n"
1397
- "- Best treated as a redaction aid, not a standalone compliance or anonymization guarantee.\n"
1398
- "- Official card notes strongest support is English, with limited multilingual robustness."
1399
- )
1400
- submit_button.click(
1401
- fn=predict_for_demo,
1402
- inputs=input_text,
1403
- outputs=[output_text, redacted_output, entity_summary],
1404
- api_name="predict_and_redact",
1405
- )
1406
- input_text.submit(
1407
- fn=predict_for_demo,
1408
- inputs=input_text,
1409
- outputs=[output_text, redacted_output, entity_summary],
1410
- )
1411
- clear_button.click(
1412
- lambda: ("", EMPTY_HIGHLIGHT_PAYLOAD, "", EMPTY_SUMMARY_MARKDOWN),
1413
- outputs=[input_text, output_text, redacted_output, entity_summary],
1414
- )
1415
 
1416
- gr.Markdown("### Multilingual quick examples")
1417
- gr.Examples(
1418
- examples=[
1419
- ["Alice was born on 1990-01-02 and lives at 1 Main St."],
1420
- ["Email me at alice@example.com or call 415-555-0101."],
1421
- ["Me llamo Laura Gómez y vivo en Calle de Alcalá 21, Madrid."],
1422
- ["Mon e-mail est jean.dupont@example.fr et mon téléphone est +33 6 12 34 56 78."],
1423
- ["私の名前は山田太郎です。メールはtaro.yamada@example.jpです。"],
1424
- ["اسمي أحمد وبريدي هو ahmed@example.com ورقم هاتفي +971501234567."],
1425
- ],
1426
- inputs=input_text,
1427
- outputs=[output_text, redacted_output, entity_summary],
1428
- fn=predict_for_demo,
1429
- cache_examples=False,
1430
- )
1431
- return demo
1432
 
1433
 
1434
- if __name__ == "__main__":
1435
- demo = build_demo()
1436
- demo.launch()
 
11
  from pathlib import Path
12
  from typing import Final
13
 
 
14
  import spaces
15
  import torch
16
  import torch.nn.functional as F
17
+ from fastapi.responses import HTMLResponse
18
+ from gradio import Server
19
  from safetensors import safe_open
20
 
21
  import tiktoken
 
1230
  return source_text, detected
1231
 
1232
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1233
  def build_redacted_text(text: str, entities: Sequence[dict[str, object]]) -> str:
1234
  if not text or not entities:
1235
  return text
 
1265
  return "".join(redacted_parts)
1266
 
1267
 
 
 
 
1268
 
 
 
 
 
 
 
 
 
1269
 
1270
+
1271
+ app = Server()
 
 
1272
 
1273
 
1274
+ @app.api()
1275
  @spaces.GPU
1276
+ def predict_and_redact(text: str) -> dict:
1277
+ """Detect PII entities in text and return highlighted entities plus redacted text."""
1278
+ text = text or ""
1279
+ if not text.strip():
1280
+ return {
1281
+ "text": "",
1282
+ "entities": [],
1283
+ "redacted": "",
1284
+ "summary": {},
1285
+ }
1286
+ runtime = get_runtime()
1287
+ decoder = Decoder(label_info=runtime.label_info)
1288
+ filtered_text, spans = predict_text(runtime, text, decoder)
1289
+
1290
+ entities = spans if isinstance(spans, list) else []
1291
+ display_text = filtered_text if isinstance(filtered_text, str) else text
1292
+
1293
  redacted_text = build_redacted_text(display_text, entities)
 
 
1294
 
1295
+ counts: dict[str, int] = {}
1296
+ for entity in entities:
1297
+ label = entity.get("entity")
1298
+ if isinstance(label, str):
1299
+ counts[label] = counts.get(label, 0) + 1
1300
 
1301
+ return {
1302
+ "text": display_text,
1303
+ "entities": entities,
1304
+ "redacted": redacted_text,
1305
+ "summary": counts,
1306
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1307
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1308
 
1309
+ @app.get("/")
1310
+ async def homepage():
1311
+ html_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "index.html")
1312
+ with open(html_path, "r", encoding="utf-8") as f:
1313
+ return HTMLResponse(content=f.read())
 
 
 
 
 
 
 
 
 
 
 
1314
 
1315
 
1316
+ app.launch(show_error=True)
 
 
index.html ADDED
@@ -0,0 +1,233 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>OpenAI Privacy Filter</title>
7
+ <link href="https://fonts.googleapis.com/css2?family=Söhne,ui-sans-serif,system-ui,-apple-system,Segoe+UI,Roboto,Ubuntu,Cantarell,Noto+Sans,sans-serif&display=swap" rel="stylesheet">
8
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
9
+ <style>
10
+ *{margin:0;padding:0;box-sizing:border-box}
11
+ :root{
12
+ --bg:#0d0d0d;--sidebar-bg:#171717;--surface:#1e1e1e;--surface-2:#2a2a2a;
13
+ --border:rgba(255,255,255,.08);--border-hover:rgba(255,255,255,.15);
14
+ --text:#e3e3e3;--text-2:#a1a1a1;--text-3:#6e6e6e;
15
+ --green:#10a37f;--green-dim:rgba(16,163,127,.12);--green-hover:#0d8a6a;
16
+ --white:#fff;--red:#ef4146;
17
+ }
18
+ html,body{height:100%;font-family:'Inter',-apple-system,BlinkMacSystemFont,sans-serif;background:var(--bg);color:var(--text);-webkit-font-smoothing:antialiased}
19
+
20
+ .app{display:flex;height:100vh;overflow:hidden}
21
+
22
+ /* ── Sidebar ── */
23
+ .sidebar{width:260px;background:var(--sidebar-bg);display:flex;flex-direction:column;padding:8px;flex-shrink:0}
24
+ .s-new{display:flex;align-items:center;gap:8px;padding:10px 12px;border-radius:10px;border:none;background:transparent;color:var(--text);cursor:pointer;font-size:14px;width:100%;transition:.15s}
25
+ .s-new:hover{background:var(--surface-2)}
26
+ .s-new svg{opacity:.7}
27
+ .s-label{font-size:12px;font-weight:500;color:var(--text-3);padding:16px 12px 6px;text-transform:uppercase;letter-spacing:.04em}
28
+ .s-list{flex:1;overflow-y:auto}
29
+ .s-item{padding:8px 12px;border-radius:8px;font-size:13px;color:var(--text-2);cursor:pointer;transition:.15s;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
30
+ .s-item:hover{background:var(--surface-2);color:var(--text)}
31
+ .s-bottom{border-top:1px solid var(--border);padding-top:8px;margin-top:8px}
32
+ .s-bottom a{display:flex;align-items:center;gap:8px;padding:8px 12px;border-radius:8px;font-size:13px;color:var(--text-2);text-decoration:none;transition:.15s}
33
+ .s-bottom a:hover{background:var(--surface-2);color:var(--text)}
34
+
35
+ /* ── Main ── */
36
+ .main{flex:1;display:flex;flex-direction:column;position:relative;min-width:0}
37
+ .topbar{height:44px;display:flex;align-items:center;padding:0 16px;gap:8px}
38
+ .topbar span{font-size:15px;font-weight:600;color:var(--text)}
39
+ .badge{font-size:10px;font-weight:600;background:var(--green);color:var(--white);padding:2px 7px;border-radius:5px}
40
+
41
+ /* ── Chat ── */
42
+ .chat{flex:1;overflow-y:auto;display:flex;flex-direction:column;align-items:center}
43
+ .chat-inner{max-width:680px;width:100%;padding:16px 20px 140px}
44
+
45
+ /* Welcome */
46
+ .welcome{display:flex;flex-direction:column;align-items:center;padding:80px 20px 40px;text-align:center}
47
+ .w-logo{width:48px;height:48px;border-radius:50%;background:var(--green);display:grid;place-items:center;margin-bottom:20px}
48
+ .w-logo svg{width:22px;height:22px;fill:var(--white)}
49
+ .welcome h1{font-size:22px;font-weight:600;margin-bottom:6px;color:var(--white)}
50
+ .welcome p{font-size:14px;color:var(--text-2);max-width:440px;line-height:1.6;margin-bottom:28px}
51
+ .examples{display:grid;grid-template-columns:1fr 1fr;gap:8px;width:100%;max-width:520px}
52
+ .ex-btn{padding:12px 14px;background:var(--surface);border:1px solid var(--border);border-radius:10px;color:var(--text-2);font-size:13px;line-height:1.45;cursor:pointer;text-align:left;transition:.2s}
53
+ .ex-btn:hover{border-color:var(--border-hover);color:var(--text);background:var(--surface-2)}
54
+
55
+ /* Messages */
56
+ .msg{padding:18px 0;animation:fadeUp .25s ease}
57
+ @keyframes fadeUp{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}
58
+ .msg-row{display:flex;gap:14px}
59
+ .ava{width:28px;height:28px;border-radius:50%;display:grid;place-items:center;flex-shrink:0;font-size:12px;font-weight:600}
60
+ .ava.u{background:var(--surface-2);color:var(--text-2)}
61
+ .ava.a{background:var(--green);color:var(--white)}
62
+ .ava.a svg{width:14px;height:14px;fill:var(--white)}
63
+ .msg-c{flex:1;min-width:0}
64
+ .msg-c .role{font-size:13px;font-weight:600;margin-bottom:4px;color:var(--white)}
65
+ .msg-c .body{font-size:14px;line-height:1.7;color:var(--text)}
66
+
67
+ /* Entity tags */
68
+ .ent{padding:1px 5px;border-radius:3px;font-weight:500;font-size:13px;position:relative;cursor:default;border-bottom:2px solid}
69
+ .ent:hover::after{content:attr(data-t);position:absolute;bottom:calc(100% + 4px);left:50%;transform:translateX(-50%);background:var(--white);color:#000;font-size:10px;font-weight:600;padding:2px 7px;border-radius:4px;white-space:nowrap;z-index:9}
70
+ .ent-private_person{background:rgba(239,68,68,.12);color:#fca5a5;border-color:rgba(239,68,68,.5)}
71
+ .ent-private_email{background:rgba(99,102,241,.12);color:#a5b4fc;border-color:rgba(99,102,241,.5)}
72
+ .ent-private_phone{background:rgba(168,85,247,.12);color:#d8b4fe;border-color:rgba(168,85,247,.5)}
73
+ .ent-private_address{background:rgba(245,158,11,.12);color:#fcd34d;border-color:rgba(245,158,11,.5)}
74
+ .ent-private_date{background:rgba(20,184,166,.12);color:#5eead4;border-color:rgba(20,184,166,.5)}
75
+ .ent-private_url{background:rgba(249,115,22,.12);color:#fdba74;border-color:rgba(249,115,22,.5)}
76
+ .ent-account_number{background:rgba(236,72,153,.12);color:#f9a8d4;border-color:rgba(236,72,153,.5)}
77
+ .ent-secret{background:rgba(220,38,38,.15);color:#fca5a5;border-color:rgba(220,38,38,.6)}
78
+
79
+ /* Redacted card */
80
+ .r-card{margin-top:14px;border:1px solid var(--border);border-radius:10px;overflow:hidden;background:var(--surface)}
81
+ .r-head{display:flex;align-items:center;justify-content:space-between;padding:8px 14px;border-bottom:1px solid var(--border)}
82
+ .r-head span{font-size:11px;font-weight:600;color:var(--text-3);text-transform:uppercase;letter-spacing:.04em}
83
+ .cp-btn{padding:3px 8px;border:1px solid var(--border);border-radius:5px;background:transparent;color:var(--text-2);font-size:11px;cursor:pointer;transition:.15s}
84
+ .cp-btn:hover{background:var(--surface-2);color:var(--text)}
85
+ .r-body{padding:12px 14px;font-size:13px;line-height:1.7;color:var(--text-2);white-space:pre-wrap;word-break:break-word}
86
+ .r-body .tag{color:var(--green);font-weight:600}
87
+
88
+ /* Summary */
89
+ .pills{display:flex;flex-wrap:wrap;gap:5px;margin-top:10px}
90
+ .pill{padding:3px 9px;border:1px solid var(--border);border-radius:16px;font-size:11px;color:var(--text-2);background:var(--surface)}
91
+ .pill b{color:var(--green);margin-right:3px}
92
+
93
+ /* Dots */
94
+ .dots{display:flex;gap:4px;padding:2px 0}
95
+ .dots i{width:6px;height:6px;background:var(--text-3);border-radius:50%;animation:pulse .6s infinite alternate;font-style:normal}
96
+ .dots i:nth-child(2){animation-delay:.15s}
97
+ .dots i:nth-child(3){animation-delay:.3s}
98
+ @keyframes pulse{to{opacity:.25;transform:translateY(-3px)}}
99
+
100
+ /* ── Input bar ── */
101
+ .input-wrap{position:fixed;bottom:0;left:260px;right:0;padding:12px 20px 20px;background:linear-gradient(transparent,var(--bg) 40%)}
102
+ .input-inner{max-width:680px;margin:0 auto}
103
+ .input-box{display:flex;align-items:flex-end;gap:8px;background:var(--surface);border:1px solid var(--border);border-radius:20px;padding:6px 6px 6px 18px;transition:.2s}
104
+ .input-box:focus-within{border-color:rgba(255,255,255,.2)}
105
+ .input-box textarea{flex:1;background:none;border:none;outline:none;color:var(--text);font-size:14px;font-family:inherit;resize:none;max-height:140px;min-height:22px;line-height:1.5;padding:6px 0}
106
+ .input-box textarea::placeholder{color:var(--text-3)}
107
+ .send{width:32px;height:32px;border-radius:50%;border:none;cursor:pointer;display:grid;place-items:center;transition:.15s;flex-shrink:0;background:var(--white)}
108
+ .send:hover{opacity:.85;transform:scale(1.04)}
109
+ .send:disabled{background:var(--surface-2);cursor:default;transform:none;opacity:.5}
110
+ .send svg{width:14px;height:14px;fill:#000}
111
+ .foot{text-align:center;font-size:11px;color:var(--text-3);padding-top:6px}
112
+
113
+ ::-webkit-scrollbar{width:5px}
114
+ ::-webkit-scrollbar-thumb{background:rgba(255,255,255,.1);border-radius:3px}
115
+ @media(max-width:768px){.sidebar{display:none}.input-wrap{left:0}.examples{grid-template-columns:1fr}}
116
+ </style>
117
+ </head>
118
+ <body>
119
+ <div class="app">
120
+ <div class="sidebar">
121
+ <button class="s-new" onclick="resetChat()">
122
+ <svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
123
+ New analysis
124
+ </button>
125
+ <div class="s-label">Recent</div>
126
+ <div class="s-list" id="hist"></div>
127
+ <div class="s-bottom">
128
+ <a href="https://huggingface.co/openai/privacy-filter" target="_blank">
129
+ <svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4M12 8h.01"/></svg>
130
+ Model Card
131
+ </a>
132
+ </div>
133
+ </div>
134
+
135
+ <div class="main">
136
+ <div class="topbar">
137
+ <span>Privacy Filter</span>
138
+ <span class="badge">openai</span>
139
+ </div>
140
+
141
+ <div class="chat" id="chat">
142
+ <div class="chat-inner" id="chatInner">
143
+ <div class="welcome" id="welcome">
144
+ <div class="w-logo"><svg viewBox="0 0 24 24"><path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1s3.1 1.39 3.1 3.1v2z"/></svg></div>
145
+ <h1>Privacy Filter</h1>
146
+ <p>Detect and redact PII using OpenAI's privacy-filter model. Supports names, emails, phones, addresses, dates, URLs, account numbers, and secrets.</p>
147
+ <div class="examples">
148
+ <button class="ex-btn" onclick="useEx(this)">Alice was born on 1990-01-02 and lives at 1 Main St.</button>
149
+ <button class="ex-btn" onclick="useEx(this)">Email me at alice@example.com or call 415-555-0101.</button>
150
+ <button class="ex-btn" onclick="useEx(this)">Me llamo Laura Gómez y vivo en Calle de Alcalá 21, Madrid.</button>
151
+ <button class="ex-btn" onclick="useEx(this)">Mon e-mail est jean.dupont@example.fr, tél +33 6 12 34 56 78.</button>
152
+ </div>
153
+ </div>
154
+ <div id="msgs"></div>
155
+ </div>
156
+ </div>
157
+
158
+ <div class="input-wrap">
159
+ <div class="input-inner">
160
+ <div class="input-box">
161
+ <textarea id="inp" rows="1" placeholder="Paste text to scan for PII…" oninput="this.style.height='auto';this.style.height=Math.min(this.scrollHeight,140)+'px'"></textarea>
162
+ <button class="send" id="sendBtn" onclick="send()"><svg viewBox="0 0 24 24"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/></svg></button>
163
+ </div>
164
+ <div class="foot">8 PII categories · BIOES + Viterbi decoding</div>
165
+ </div>
166
+ </div>
167
+ </div>
168
+ </div>
169
+
170
+ <script type="module">
171
+ import{Client}from"https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js";
172
+ let C=null,busy=false;
173
+ (async()=>{try{C=await Client.connect(location.origin)}catch(e){console.error(e)}})();
174
+
175
+ const $=id=>document.getElementById(id);
176
+ const esc=s=>{const d=document.createElement('div');d.textContent=s;return d.innerHTML};
177
+
178
+ function addMsg(role,html){
179
+ $('welcome').style.display='none';
180
+ const d=document.createElement('div');d.className='msg';
181
+ const isA=role==='a';
182
+ const av=isA?'<svg viewBox="0 0 24 24"><path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1s3.1 1.39 3.1 3.1v2z"/></svg>':'Y';
183
+ d.innerHTML=`<div class="msg-row"><div class="ava ${isA?'a':'u'}">${av}</div><div class="msg-c"><div class="role">${isA?'Privacy Filter':'You'}</div><div class="body">${html}</div></div></div>`;
184
+ $('msgs').appendChild(d);
185
+ $('chat').scrollTop=$('chat').scrollHeight;
186
+ return d;
187
+ }
188
+
189
+ function highlight(text,ents){
190
+ if(!ents||!ents.length)return esc(text);
191
+ const s=[...ents].sort((a,b)=>a.start-b.start);
192
+ let h='',c=0;
193
+ for(const e of s){if(e.start<c)continue;h+=esc(text.slice(c,e.start));h+=`<span class="ent ent-${e.entity}" data-t="${e.entity}">${esc(text.slice(e.start,e.end))}</span>`;c=e.end;}
194
+ return h+esc(text.slice(c));
195
+ }
196
+
197
+ function fmtRedact(r){return esc(r).replace(/\[(PERSON|EMAIL|PHONE|ADDRESS|DATE|URL|ACCOUNT_NUMBER|SECRET|REDACTED)\]/g,'<span class="tag">[$1]</span>')}
198
+
199
+ function result(d){
200
+ const{text,entities,redacted,summary}=d;
201
+ let h='<div style="margin-bottom:2px"><strong>Detected Entities</strong></div>';
202
+ h+='<div style="line-height:2">'+highlight(text,entities)+'</div>';
203
+ if(entities&&entities.length){
204
+ h+='<div class="pills">';
205
+ for(const[k,v]of Object.entries(summary||{}))h+=`<div class="pill"><b>${v}</b>${k.replace('private_','')}</div>`;
206
+ h+='</div>';
207
+ const rid='r'+Date.now();
208
+ h+=`<div class="r-card"><div class="r-head"><span>Redacted Output</span><button class="cp-btn" onclick="navigator.clipboard.writeText($('${rid}').innerText).then(()=>{this.textContent='Copied!';setTimeout(()=>this.textContent='Copy',1200)})">Copy</button></div><div class="r-body" id="${rid}">${fmtRedact(redacted)}</div></div>`;
209
+ }else{h+='<div style="margin-top:10px;color:var(--green);font-size:13px">✓ No personal identifiers detected.</div>';}
210
+ return h;
211
+ }
212
+
213
+ window.send=async function(){
214
+ const inp=$('inp'),text=inp.value.trim();
215
+ if(!text||busy)return;
216
+ busy=true;inp.value='';inp.style.height='auto';$('sendBtn').disabled=true;
217
+ addMsg('u',esc(text));
218
+ const ld=addMsg('a','<div class="dots"><i></i><i></i><i></i></div>');
219
+ // history
220
+ const hi=document.createElement('div');hi.className='s-item';hi.textContent=text.slice(0,36)+(text.length>36?'…':'');$('hist').prepend(hi);
221
+ try{
222
+ if(!C)C=await Client.connect(location.origin);
223
+ const r=await C.predict("/predict_and_redact",{text});
224
+ ld.remove();addMsg('a',result(r.data[0]));
225
+ }catch(e){ld.remove();addMsg('a',`<span style="color:var(--red)">Error: ${esc(e.message||'Request failed')}</span>`);}
226
+ busy=false;$('sendBtn').disabled=false;inp.focus();
227
+ };
228
+ window.useEx=function(b){$('inp').value=b.textContent;send()};
229
+ window.resetChat=function(){$('msgs').innerHTML='';$('welcome').style.display='';$('inp').value='';};
230
+ $('inp').addEventListener('keydown',e=>{if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();send();}});
231
+ </script>
232
+ </body>
233
+ </html>