codelion commited on
Commit
4a355c8
·
verified ·
1 Parent(s): 873ae84

UI redesign: riso-print aesthetic, unified input, branded share card, char-aware truncation, trafilatura URL extraction

Browse files
Files changed (2) hide show
  1. app.py +931 -395
  2. requirements.txt +1 -0
app.py CHANGED
@@ -17,10 +17,18 @@ from adaptive_classifier import AdaptiveClassifier
17
  # ---------------------------------------------------------------------------
18
  print("Loading model...")
19
  classifier = AdaptiveClassifier.from_pretrained(
20
- "adaptive-classifier/ai-detector", use_onnx=False
21
  )
22
  print("Model loaded!")
23
 
 
 
 
 
 
 
 
 
24
  # ---------------------------------------------------------------------------
25
  # Persistent dataset via CommitScheduler
26
  # ---------------------------------------------------------------------------
@@ -66,7 +74,7 @@ def _save_dataset(records: list[dict], message: str = "Update dataset"):
66
 
67
 
68
  def save_prediction(pred_id: str, text: str, url: str, label: str, confidence: float):
69
- """Save a prediction to memory and push to HF dataset."""
70
  record = {
71
  "id": pred_id,
72
  "text": text,
@@ -77,12 +85,17 @@ def save_prediction(pred_id: str, text: str, url: str, label: str, confidence: f
77
  "timestamp": datetime.now().isoformat(),
78
  }
79
  _predictions[pred_id] = record
80
- try:
81
- records = _load_dataset()
82
- records.append(record)
83
- _save_dataset(records, f"Add prediction {pred_id}")
84
- except Exception as e:
85
- print(f"Warning: failed to push prediction: {e}")
 
 
 
 
 
86
 
87
 
88
  def lookup_prediction(pred_id: str) -> dict | None:
@@ -97,21 +110,26 @@ def lookup_prediction(pred_id: str) -> dict | None:
97
 
98
 
99
  def save_feedback(pred_id: str, feedback: str):
100
- """Update the existing prediction record with feedback."""
101
  if pred_id in _predictions:
102
  _predictions[pred_id]["feedback"] = feedback
103
- try:
104
- records = _load_dataset()
105
- updated = False
106
- for rec in records:
107
- if rec.get("id") == pred_id:
108
- rec["feedback"] = feedback
109
- updated = True
110
- break
111
- if updated:
112
- _save_dataset(records, f"Add feedback for {pred_id}")
113
- except Exception as e:
114
- print(f"Warning: failed to push feedback: {e}")
 
 
 
 
 
115
 
116
 
117
  # ---------------------------------------------------------------------------
@@ -129,149 +147,497 @@ HEADERS = {
129
  "Accept-Language": "en-US,en;q=0.9",
130
  }
131
 
132
- CSS = """
133
- @import url('https://fonts.googleapis.com/css2?family=DM+Mono:wght@300;400;500&family=Outfit:wght@300;400;500;600;700&display=swap');
 
 
 
134
 
 
135
  :root {
136
- --bg-deep: #0a0e17;
137
- --bg-surface: #111827;
138
- --bg-card: #1a2234;
139
- --bg-input: #0f1729;
140
- --border-subtle: #1e2d45;
141
- --border-accent: #2563eb;
142
- --text-primary: #e2e8f0;
143
- --text-secondary: #8892a6;
144
- --text-muted: #4a5568;
145
- --accent-blue: #3b82f6;
146
- --accent-cyan: #06b6d4;
147
- --accent-human: #10b981;
148
- --accent-ai: #f59e0b;
149
- --glow-blue: rgba(59, 130, 246, 0.15);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
150
  }
151
 
152
  .gradio-container {
153
- background: var(--bg-deep) !important;
154
- font-family: 'Outfit', sans-serif !important;
155
- max-width: 820px !important;
 
 
156
  margin: 0 auto !important;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
157
  }
158
- .main, .contain { background: transparent !important; }
159
  footer { display: none !important; }
160
 
161
- .header-block { text-align: center; padding: 2rem 1rem 1rem; }
162
- .header-block h1 {
163
- font-family: 'DM Mono', monospace !important;
164
- font-size: 1.6rem !important; font-weight: 500 !important;
165
- color: var(--text-primary) !important; letter-spacing: 0.04em;
166
- margin-bottom: 0.4rem !important;
 
 
 
 
 
 
 
 
 
 
 
167
  }
168
- .header-block p {
169
- font-size: 0.85rem !important; color: var(--text-secondary) !important;
170
- line-height: 1.5 !important; max-width: 560px; margin: 0 auto !important;
 
 
 
171
  }
172
- .header-block a {
173
- color: var(--accent-cyan) !important; text-decoration: none !important;
174
- border-bottom: 1px solid rgba(6, 182, 212, 0.3);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
175
  }
176
- .header-block a:hover { border-bottom-color: var(--accent-cyan); }
177
 
 
178
  .tabs { background: transparent !important; border: none !important; }
179
  .tab-nav {
180
- background: transparent !important; border: none !important;
181
- justify-content: center !important; gap: 0.25rem !important;
182
- padding: 0.5rem 0 !important;
 
 
 
 
183
  }
184
  .tab-nav button {
185
- font-family: 'DM Mono', monospace !important;
186
- font-size: 0.8rem !important; font-weight: 400 !important;
187
- letter-spacing: 0.06em; text-transform: uppercase;
188
- color: var(--text-muted) !important; background: transparent !important;
189
- border: 1px solid var(--border-subtle) !important;
190
- border-radius: 6px !important; padding: 0.5rem 1.5rem !important;
191
- transition: all 0.2s ease !important;
192
- }
193
- .tab-nav button:hover { color: var(--text-secondary) !important; border-color: var(--text-muted) !important; }
 
 
 
 
 
 
 
 
 
 
194
  .tab-nav button.selected {
195
- color: var(--accent-cyan) !important;
196
- background: rgba(6, 182, 212, 0.08) !important;
197
- border-color: var(--accent-cyan) !important;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
198
  }
199
- .tabitem { background: transparent !important; border: none !important; padding: 0 !important; min-height: 520px !important; }
200
 
 
201
  .input-card {
202
- background: var(--bg-card) !important; border: 1px solid var(--border-subtle) !important;
203
- border-radius: 10px !important; padding: 1.25rem !important; margin-top: 0.75rem !important;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
204
  }
205
 
 
206
  textarea, input[type="text"] {
207
- font-family: 'DM Mono', monospace !important; font-size: 0.85rem !important;
208
- line-height: 1.65 !important; color: var(--text-primary) !important;
209
- background: var(--bg-input) !important; border: 1px solid var(--border-subtle) !important;
210
- border-radius: 8px !important; padding: 0.85rem 1rem !important;
211
- transition: border-color 0.2s ease !important;
 
 
 
 
 
 
 
 
 
212
  }
213
  textarea:focus, input[type="text"]:focus {
214
- border-color: var(--accent-blue) !important;
215
- box-shadow: 0 0 0 3px var(--glow-blue) !important; outline: none !important;
 
216
  }
217
  label span {
218
- font-family: 'DM Mono', monospace !important; font-size: 0.7rem !important;
219
- text-transform: uppercase !important; letter-spacing: 0.08em !important;
220
- color: var(--text-muted) !important;
 
221
  }
222
 
 
223
  .detect-btn {
224
- font-family: 'DM Mono', monospace !important; font-size: 0.8rem !important;
225
- font-weight: 500 !important; letter-spacing: 0.06em !important;
226
- text-transform: uppercase !important;
227
- background: linear-gradient(135deg, var(--accent-blue), var(--accent-cyan)) !important;
228
- color: #fff !important; border: none !important; border-radius: 8px !important;
229
- padding: 0.7rem 2rem !important; cursor: pointer !important;
230
- transition: all 0.25s ease !important;
231
- box-shadow: 0 2px 12px rgba(59, 130, 246, 0.25) !important;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
232
  }
233
- .detect-btn:hover { box-shadow: 0 4px 20px rgba(59, 130, 246, 0.4) !important; transform: translateY(-1px) !important; }
234
 
235
- .result-card {
236
- background: var(--bg-card) !important; border: 1px solid var(--border-subtle) !important;
237
- border-radius: 10px !important; padding: 1.25rem !important; margin-top: 0.5rem !important;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
238
  }
239
- .result-card .output-class { font-family: 'DM Mono', monospace !important; }
240
- .output-label { background: transparent !important; }
241
- .output-label .label-name { font-family: 'DM Mono', monospace !important; font-size: 1.1rem !important; }
242
 
 
243
  .examples-heading {
244
- font-family: 'DM Mono', monospace !important; font-size: 0.7rem !important;
245
- text-transform: uppercase !important; letter-spacing: 0.08em !important;
246
- color: var(--text-muted) !important; margin-top: 1.25rem !important; margin-bottom: 0.5rem !important;
247
- }
248
- .gallery { gap: 0.5rem !important; }
249
- .gallery .gallery-item {
250
- background: var(--bg-input) !important; border: 1px solid var(--border-subtle) !important;
251
- border-radius: 8px !important; padding: 0.75rem !important; transition: border-color 0.2s ease !important;
252
- }
253
- .gallery .gallery-item:hover { border-color: var(--text-muted) !important; }
254
- .preview-box textarea { color: var(--text-secondary) !important; font-size: 0.78rem !important; opacity: 0.85; }
255
- .gr-group, .gr-block, .gr-box, .gr-panel { background: transparent !important; border: none !important; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
256
  .gr-padded { padding: 0 !important; }
257
 
258
- .info-strip { text-align: center; padding: 1.25rem 1rem; }
259
- .info-strip p { font-family: 'DM Mono', monospace !important; font-size: 0.68rem !important; color: var(--text-muted) !important; letter-spacing: 0.03em; }
260
- .info-strip a { color: var(--text-secondary) !important; text-decoration: none !important; border-bottom: 1px dotted var(--text-muted); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
261
 
 
262
  #share-card .image-toolbar, #text-share-card .image-toolbar,
263
  #share-card .icon-buttons, #text-share-card .icon-buttons,
264
  #share-card button[aria-label], #text-share-card button[aria-label] { display: none !important; }
265
-
266
- .feedback-btn {
267
- font-family: 'DM Mono', monospace !important; font-size: 0.75rem !important;
268
- border-radius: 6px !important; padding: 0.4rem 1rem !important;
269
- cursor: pointer !important; transition: all 0.2s ease !important;
270
- }
271
- .feedback-msg {
272
- font-family: 'DM Mono', monospace !important; font-size: 0.75rem !important;
273
- color: var(--accent-cyan) !important; padding: 0.4rem 0 !important;
274
- }
275
  """
276
 
277
  HUMAN_EXAMPLE = (
@@ -326,6 +692,13 @@ class _TextExtractor(HTMLParser):
326
  return re.sub(r"\s+", " ", " ".join(self._parts)).strip()
327
 
328
 
 
 
 
 
 
 
 
329
  def fetch_url(url: str) -> str:
330
  if not url or not url.strip():
331
  return ""
@@ -335,6 +708,22 @@ def fetch_url(url: str) -> str:
335
  req = urllib.request.Request(url, headers=HEADERS)
336
  with urllib.request.urlopen(req, timeout=15) as resp:
337
  html = resp.read().decode("utf-8", errors="ignore")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
338
  parser = _TextExtractor()
339
  parser.feed(html)
340
  return parser.get_text()
@@ -343,75 +732,217 @@ def fetch_url(url: str) -> str:
343
  # ---------------------------------------------------------------------------
344
  # Result card image
345
  # ---------------------------------------------------------------------------
346
- def make_result_card(source: str, label: str, confidence: float) -> Image.Image:
347
- W, H = 800, 418
348
- bg, card_bg = "#0a0e17", "#1a2234"
349
- human_color, ai_color = "#10b981", "#f59e0b"
350
- text_color, muted_color = "#e2e8f0", "#8892a6"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
351
 
352
- img = Image.new("RGB", (W, H), bg)
 
 
 
 
353
  draw = ImageDraw.Draw(img)
354
- try:
355
- fl = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 32)
356
- fm = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 20)
357
- fs = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 14)
358
- fmono = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", 16)
359
- except OSError:
360
- fl = fm = fs = fmono = ImageFont.load_default()
361
 
362
- draw.rounded_rectangle([30, 30, W - 30, H - 30], radius=16, fill=card_bg)
363
- draw.text((60, 55), "AI Text Detector", fill=text_color, font=fl)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
364
 
365
- is_ai = label.lower() == "ai"
366
- rc = ai_color if is_ai else human_color
367
- draw.text((60, 120), "Result:", fill=muted_color, font=fm)
368
- draw.text((60, 155), "AI-Generated" if is_ai else "Human-Written", fill=rc, font=fl)
369
- draw.text((60, 200), f"Confidence: {confidence * 100:.1f}%", fill=text_color, font=fm)
370
-
371
- bx, by, bw, bh = 60, 240, W - 120, 24
372
- draw.rounded_rectangle([bx, by, bx + bw, by + bh], radius=12, fill="#0f1729")
373
- fw = int(bw * confidence)
374
- if fw > 0:
375
- draw.rounded_rectangle([bx, by, bx + fw, by + bh], radius=12, fill=rc)
376
-
377
- is_url = source.startswith("http") or source.startswith("www")
378
- draw.text((60, 285), "URL analyzed:" if is_url else "Text analyzed:", fill=muted_color, font=fs)
379
-
380
- max_text_w = W - 120 # 60px padding each side
381
- if is_url:
382
- # URLs: single line, truncate with ...
383
- disp = source
384
- while fmono.getlength(disp) > max_text_w and len(disp) > 10:
385
- disp = disp[:-4] + "..."
386
- draw.text((60, 308), disp, fill=text_color, font=fmono)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
387
  else:
388
- # Text: wrap into up to 3 lines
389
- words = source.split()
390
- lines = []
391
- current = ""
392
- for w in words:
393
- test = f"{current} {w}".strip()
394
- if fmono.getlength(test) > max_text_w:
395
- if current:
396
- lines.append(current)
397
- current = w
398
- else:
399
- current = test
400
- if len(lines) >= 2:
401
- current = current + "..."
402
- break
403
- if current:
404
- lines.append(current)
405
- for i, line in enumerate(lines[:3]):
406
- draw.text((60, 308 + i * 20), line, fill=text_color, font=fmono)
407
-
408
- draw.text((60, H - 60), "adaptive-classifier-ai-detector.hf.space", fill=muted_color, font=fs)
409
- return img
 
 
 
 
 
410
 
 
411
 
412
- # ---------------------------------------------------------------------------
413
- # Detection logic
414
- # ---------------------------------------------------------------------------
415
  def _error_label(msg: str) -> dict:
416
  return {msg: 1.0}
417
 
@@ -425,15 +956,16 @@ def _classify(text: str) -> dict:
425
 
426
  def detect_text_full(text: str):
427
  """Returns (result, share_link, card, pred_id)"""
428
- result = _classify(text)
 
 
429
  if any(k.startswith("Please") for k in result):
430
  return result, "", None, ""
431
  top_label = max(result, key=result.get)
432
  pred_id = uuid4().hex[:12]
433
- save_prediction(pred_id, text, "", top_label, result[top_label])
434
  share_link = f"{SPACE_URL}/?id={pred_id}"
435
- preview = text.strip()[:80] + ("..." if len(text.strip()) > 80 else "")
436
- card = make_result_card(preview, top_label, result[top_label])
437
  return result, share_link, card, pred_id
438
 
439
 
@@ -453,243 +985,247 @@ def detect_url_full(url: str):
453
  return _error_label("This site requires JavaScript (e.g. Twitter/X). Paste the text directly instead."), "", "", None, ""
454
  if len(text.split()) < 10:
455
  return _error_label("Not enough readable text found at that URL"), text[:500], "", None, ""
456
- words = text.split()
457
- if len(words) > 2000:
458
- text = " ".join(words[:2000])
459
- result = _classify(text)
 
460
  if any(k.startswith("Please") or k.startswith("Not enough") for k in result):
461
- return result, text[:500], "", None, ""
462
  top_label = max(result, key=result.get)
463
  pred_id = uuid4().hex[:12]
464
- save_prediction(pred_id, text, url.strip(), top_label, result[top_label])
465
- preview = text[:1500] + ("..." if len(text) > 1500 else "")
466
  share_link = f"{SPACE_URL}/?id={pred_id}"
467
- card = make_result_card(url.strip(), top_label, result[top_label])
468
- return result, preview, share_link, card, pred_id
469
 
470
 
471
  # ---------------------------------------------------------------------------
472
  # UI
473
  # ---------------------------------------------------------------------------
474
- with gr.Blocks(css=CSS, title="AI Text Detector", theme=gr.themes.Base()) as demo:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
475
 
476
  gr.HTML("""
477
  <div class="header-block">
478
- <h1>AI Text Detector</h1>
479
- <p>
480
- Classify text as <strong>human-written</strong> or <strong>AI-generated</strong>.
481
- Built with <a href="https://github.com/codelion/adaptive-classifier">adaptive-classifier</a>
482
- and trained on the <a href="https://huggingface.co/datasets/pangram/editlens_iclr">EditLens</a> dataset.
483
- Paste text directly or enter a URL to fetch and analyze.
484
- </p>
485
  </div>
486
  """)
487
 
488
- with gr.Tabs() as tabs:
489
- # ---- TEXT TAB ----
490
- with gr.TabItem("Text", id="text-tab"):
491
- with gr.Group(elem_classes="input-card"):
492
- text_input = gr.Textbox(lines=7, placeholder="Paste text here to analyze...", label="Input Text", show_label=True)
493
- text_btn = gr.Button("Analyze", variant="primary", elem_classes="detect-btn")
494
-
495
- with gr.Group(elem_classes="result-card"):
496
- text_output = gr.Label(num_top_classes=2, label="Result")
497
- text_pred_id = gr.Textbox(visible=False, elem_id="text-pred-id")
498
- # Feedback
499
- with gr.Row(visible=False) as text_fb_row:
500
- text_fb_up = gr.Button("Correct", size="sm", elem_classes="feedback-btn")
501
- text_fb_down = gr.Button("Incorrect", size="sm", elem_classes="feedback-btn")
502
- text_fb_msg = gr.HTML(visible=False, elem_classes="feedback-msg")
503
- # Share
504
- text_share_link = gr.Textbox(label="Share this result", visible=False, interactive=False, elem_id="text-share-url")
505
- with gr.Row(visible=False) as text_share_row:
506
- text_copy_link_btn = gr.Button("Copy Link", size="sm", elem_classes="detect-btn")
507
- text_copy_img_btn = gr.Button("Copy Image", size="sm", elem_classes="detect-btn")
508
- text_dl_img_btn = gr.Button("Download Image", size="sm", elem_classes="detect-btn")
509
- text_share_card = gr.Image(label="Result card", visible=False, type="pil", elem_id="text-share-card")
510
-
511
- def run_text(text):
512
- result, link, card, pid = detect_text_full(text)
513
- has = bool(link)
514
- return (
515
- result, gr.update(value=pid, visible=False),
516
- gr.update(visible=has), gr.update(visible=False),
517
- gr.update(value=link, visible=has), gr.update(visible=has),
518
- gr.update(value=card, visible=has),
519
- )
520
-
521
- text_btn.click(
522
- fn=run_text, inputs=text_input,
523
- outputs=[text_output, text_pred_id, text_fb_row, text_fb_msg, text_share_link, text_share_row, text_share_card],
524
- api_name="detect",
525
- )
526
-
527
- def text_fb_positive(pid):
528
- if pid:
529
- save_feedback(pid, "correct")
530
- return gr.update(visible=False), gr.update(value='<span class="feedback-msg">Thanks for your feedback!</span>', visible=True)
531
-
532
- def text_fb_negative(pid):
533
- if pid:
534
- save_feedback(pid, "incorrect")
535
- return gr.update(visible=False), gr.update(value='<span class="feedback-msg">Thanks for your feedback!</span>', visible=True)
536
-
537
- text_fb_up.click(fn=text_fb_positive, inputs=text_pred_id, outputs=[text_fb_row, text_fb_msg])
538
- text_fb_down.click(fn=text_fb_negative, inputs=text_pred_id, outputs=[text_fb_row, text_fb_msg])
539
-
540
- text_copy_link_btn.click(fn=None, inputs=text_share_link, js="(u) => { navigator.clipboard.writeText(u); }")
541
- text_copy_img_btn.click(fn=None, js="""() => {
542
- const img = document.querySelector('#text-share-card img');
543
- if (img) { const c = document.createElement('canvas'); c.width = img.naturalWidth; c.height = img.naturalHeight;
544
- c.getContext('2d').drawImage(img, 0, 0);
545
- c.toBlob(b => navigator.clipboard.write([new ClipboardItem({'image/png': b})]), 'image/png'); }
546
- }""")
547
- text_dl_img_btn.click(fn=None, js="""() => {
548
- const img = document.querySelector('#text-share-card img');
549
- if (img) { const a = document.createElement('a'); a.href = img.src; a.download = 'ai-detector-result.png'; a.click(); }
550
- }""")
551
-
552
- gr.HTML('<div class="examples-heading">Try an example</div>')
553
- gr.Examples(examples=[[HUMAN_EXAMPLE], [AI_EXAMPLE]], inputs=text_input, label="")
554
-
555
- # ---- URL TAB ----
556
- with gr.TabItem("URL", id="url-tab"):
557
- with gr.Group(elem_classes="input-card"):
558
- url_input = gr.Textbox(lines=1, placeholder="https://example.com/article", label="Web Page URL", show_label=True)
559
- url_btn = gr.Button("Fetch & Analyze", variant="primary", elem_classes="detect-btn")
560
-
561
- with gr.Group(elem_classes="result-card"):
562
- url_output = gr.Label(num_top_classes=2, label="Result")
563
- url_pred_id = gr.Textbox(visible=False, elem_id="url-pred-id")
564
- # Feedback
565
- with gr.Row(visible=False) as url_fb_row:
566
- url_fb_up = gr.Button("Correct", size="sm", elem_classes="feedback-btn")
567
- url_fb_down = gr.Button("Incorrect", size="sm", elem_classes="feedback-btn")
568
- url_fb_msg = gr.HTML(visible=False, elem_classes="feedback-msg")
569
- # Share
570
- share_link = gr.Textbox(label="Share this result", visible=False, interactive=False, elem_id="share-url")
571
- with gr.Row(visible=False) as share_row:
572
- copy_link_btn = gr.Button("Copy Link", size="sm", elem_classes="detect-btn")
573
- copy_img_btn = gr.Button("Copy Image", size="sm", elem_classes="detect-btn")
574
- dl_img_btn = gr.Button("Download Image", size="sm", elem_classes="detect-btn")
575
- share_card = gr.Image(label="Result card", visible=False, type="pil", elem_id="share-card")
576
-
577
- with gr.Group(elem_classes="input-card"):
578
- url_preview = gr.Textbox(label="Extracted Text", lines=5, interactive=False, elem_classes="preview-box")
579
-
580
- def run_url(url):
581
- result, preview, link, card, pid = detect_url_full(url)
582
- has = bool(link)
583
- return (
584
- result, gr.update(value=pid, visible=False),
585
- gr.update(visible=has), gr.update(visible=False),
586
- gr.update(value=link, visible=has), gr.update(visible=has),
587
- gr.update(value=card, visible=has), preview,
588
- )
589
-
590
- url_btn.click(
591
- fn=run_url, inputs=url_input,
592
- outputs=[url_output, url_pred_id, url_fb_row, url_fb_msg, share_link, share_row, share_card, url_preview],
593
- api_name="detect_url",
594
- )
595
 
596
- def url_fb_positive(pid):
597
- if pid:
598
- save_feedback(pid, "correct")
599
- return gr.update(visible=False), gr.update(value='<span class="feedback-msg">Thanks for your feedback!</span>', visible=True)
600
-
601
- def url_fb_negative(pid):
602
- if pid:
603
- save_feedback(pid, "incorrect")
604
- return gr.update(visible=False), gr.update(value='<span class="feedback-msg">Thanks for your feedback!</span>', visible=True)
605
-
606
- url_fb_up.click(fn=url_fb_positive, inputs=url_pred_id, outputs=[url_fb_row, url_fb_msg])
607
- url_fb_down.click(fn=url_fb_negative, inputs=url_pred_id, outputs=[url_fb_row, url_fb_msg])
608
-
609
- copy_link_btn.click(fn=None, inputs=share_link, js="(u) => { navigator.clipboard.writeText(u); }")
610
- copy_img_btn.click(fn=None, js="""() => {
611
- const img = document.querySelector('#share-card img');
612
- if (img) { const c = document.createElement('canvas'); c.width = img.naturalWidth; c.height = img.naturalHeight;
613
- c.getContext('2d').drawImage(img, 0, 0);
614
- c.toBlob(b => navigator.clipboard.write([new ClipboardItem({'image/png': b})]), 'image/png'); }
615
- }""")
616
- dl_img_btn.click(fn=None, js="""() => {
617
- const img = document.querySelector('#share-card img');
618
- if (img) { const a = document.createElement('a'); a.href = img.src; a.download = 'ai-detector-result.png'; a.click(); }
619
- }""")
620
-
621
- gr.HTML('<div class="examples-heading">Try an example</div>')
622
- gr.Examples(
623
- examples=[
624
- ["https://en.wikipedia.org/wiki/Constitution_of_the_United_States"],
625
- ["https://garryslist.org/posts/richmond-just-voted-to-reinstate-their-flock-cameras-after-crime-spiked"],
626
- ],
627
- inputs=url_input, label="",
628
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
629
 
630
  gr.HTML("""
631
  <div class="info-strip">
632
- <p>
633
- Model: <a href="https://huggingface.co/adaptive-classifier/ai-detector">adaptive-classifier/ai-detector</a>
634
- &nbsp;&middot;&nbsp;
635
- <a href="https://github.com/codelion/adaptive-classifier">GitHub</a>
636
- &nbsp;&middot;&nbsp;
637
- Best with 50+ words
 
 
638
  </p>
639
  </div>
640
  """)
641
 
642
- # Handle ?id= share links on page load
643
  def _on_load(request: gr.Request):
644
  pred_id = request.query_params.get("id", "")
645
-
646
- # Defaults for all outputs
647
- d = gr.update()
648
- n = ""
649
- hide = gr.update(visible=False)
650
- empty = {}
651
- defaults = (d, n, empty, hide, hide, hide, n, hide, hide, n, empty, hide, hide, hide, hide, hide, hide, n)
652
-
653
  rec = lookup_prediction(pred_id) if pred_id else None
654
- if rec:
655
- result = {rec["prediction"]: rec["confidence"]}
656
- other = "human" if rec["prediction"] == "ai" else "ai"
657
- result[other] = round(1.0 - rec["confidence"], 4)
658
- source = rec["url"] or rec["text"][:80] + "..."
659
- card = make_result_card(source, rec["prediction"], rec["confidence"])
660
- link = f"{SPACE_URL}/?id={pred_id}"
661
- if rec["url"]:
662
- return (
663
- gr.update(selected="url-tab"),
664
- n, empty, hide, hide, hide, n, hide, hide,
665
- rec["url"], result, gr.update(value=pred_id, visible=False),
666
- gr.update(visible=True), hide,
667
- gr.update(value=link, visible=True), gr.update(visible=True),
668
- gr.update(value=card, visible=True), rec["text"][:1500],
669
- )
670
- else:
671
- return (
672
- gr.update(selected="text-tab"),
673
- rec["text"], result, gr.update(value=pred_id, visible=False),
674
- gr.update(visible=True), hide,
675
- gr.update(value=link, visible=True), gr.update(visible=True),
676
- gr.update(value=card, visible=True),
677
- n, empty, hide, hide, hide, hide, hide, hide, n,
678
- )
679
-
680
- return defaults
681
 
682
  demo.load(
683
  fn=_on_load, inputs=None,
684
- outputs=[
685
- tabs,
686
- # text tab
687
- text_input, text_output, text_pred_id, text_fb_row, text_fb_msg,
688
- text_share_link, text_share_row, text_share_card,
689
- # url tab
690
- url_input, url_output, url_pred_id, url_fb_row, url_fb_msg,
691
- share_link, share_row, share_card, url_preview,
692
- ],
693
  )
694
 
695
  if __name__ == "__main__":
 
17
  # ---------------------------------------------------------------------------
18
  print("Loading model...")
19
  classifier = AdaptiveClassifier.from_pretrained(
20
+ "adaptive-classifier/ai-detector", use_onnx="auto", prefer_quantized=True
21
  )
22
  print("Model loaded!")
23
 
24
+ # Warm-up: first inference triggers ONNX session init / JIT — do it eagerly so
25
+ # the first user-facing prediction doesn't pay that cost.
26
+ try:
27
+ classifier.predict("This is a short warm-up passage so the model session initializes before the first request.", k=2)
28
+ print("Warm-up complete.")
29
+ except Exception as e:
30
+ print(f"Warm-up skipped: {e}")
31
+
32
  # ---------------------------------------------------------------------------
33
  # Persistent dataset via CommitScheduler
34
  # ---------------------------------------------------------------------------
 
74
 
75
 
76
  def save_prediction(pred_id: str, text: str, url: str, label: str, confidence: float):
77
+ """Save a prediction to memory now, push to HF dataset in a background thread."""
78
  record = {
79
  "id": pred_id,
80
  "text": text,
 
85
  "timestamp": datetime.now().isoformat(),
86
  }
87
  _predictions[pred_id] = record
88
+
89
+ def _push():
90
+ try:
91
+ records = _load_dataset()
92
+ records.append(record)
93
+ _save_dataset(records, f"Add prediction {pred_id}")
94
+ except Exception as e:
95
+ print(f"Warning: failed to push prediction: {e}")
96
+
97
+ import threading
98
+ threading.Thread(target=_push, daemon=True).start()
99
 
100
 
101
  def lookup_prediction(pred_id: str) -> dict | None:
 
110
 
111
 
112
  def save_feedback(pred_id: str, feedback: str):
113
+ """Update the existing prediction record with feedback (HF write in background)."""
114
  if pred_id in _predictions:
115
  _predictions[pred_id]["feedback"] = feedback
116
+
117
+ def _push():
118
+ try:
119
+ records = _load_dataset()
120
+ updated = False
121
+ for rec in records:
122
+ if rec.get("id") == pred_id:
123
+ rec["feedback"] = feedback
124
+ updated = True
125
+ break
126
+ if updated:
127
+ _save_dataset(records, f"Add feedback for {pred_id}")
128
+ except Exception as e:
129
+ print(f"Warning: failed to push feedback: {e}")
130
+
131
+ import threading
132
+ threading.Thread(target=_push, daemon=True).start()
133
 
134
 
135
  # ---------------------------------------------------------------------------
 
147
  "Accept-Language": "en-US,en;q=0.9",
148
  }
149
 
150
+ HEAD = """
151
+ <link rel="preconnect" href="https://fonts.googleapis.com">
152
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
153
+ <link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wdth,wght@12..96,75..100,300;12..96,75..100,500;12..96,75..100,800&family=JetBrains+Mono:wght@400;700&display=swap" rel="stylesheet">
154
+ """
155
 
156
+ CSS = """
157
  :root {
158
+ --paper: #fffaf0;
159
+ --paper-soft: #fbf2dc;
160
+ --ink: #0a0908;
161
+ --ink-soft: #2a2826;
162
+ --ink-faded: #6b6864;
163
+ --ai: #f15baa; /* fluorescent pink */
164
+ --ai-soft: #fde0ee;
165
+ --human: #1f4dd6; /* federal blue */
166
+ --human-soft: #d8e1f7;
167
+ --yellow: #ffd400;
168
+ --mint: #b8eccb;
169
+ color-scheme: light;
170
+ }
171
+
172
+ * { box-sizing: border-box; }
173
+
174
+ /* Force light mode — override Gradio's dark-mode wrappers */
175
+ html, body, .dark, .dark .gradio-container, body.dark {
176
+ color-scheme: light !important;
177
+ background: var(--paper) !important;
178
+ color: var(--ink) !important;
179
+ }
180
+ .dark .input-card,
181
+ .dark .preview-box,
182
+ .dark .preview-box textarea,
183
+ .dark .section-label,
184
+ .dark .section-label * {
185
+ background: transparent !important;
186
+ color: var(--ink) !important;
187
+ }
188
+ .dark .input-card { background: var(--paper) !important; }
189
+ .dark textarea, .dark input[type="text"] {
190
+ background: var(--paper) !important;
191
+ color: var(--ink) !important;
192
  }
193
 
194
  .gradio-container {
195
+ background: var(--paper) !important;
196
+ font-family: 'Bricolage Grotesque', system-ui, sans-serif !important;
197
+ font-variation-settings: 'wdth' 100, 'wght' 400, 'opsz' 14;
198
+ color: var(--ink) !important;
199
+ max-width: 860px !important;
200
  margin: 0 auto !important;
201
+ padding: 0 !important;
202
+ position: relative;
203
+ }
204
+
205
+ /* Halftone dot field — riso print signature */
206
+ .gradio-container::before {
207
+ content: '';
208
+ position: fixed; inset: 0;
209
+ pointer-events: none; z-index: 0;
210
+ background-image:
211
+ radial-gradient(circle at center, rgba(241, 91, 170, 0.16) 1.1px, transparent 1.6px),
212
+ radial-gradient(circle at center, rgba(31, 77, 214, 0.10) 1.1px, transparent 1.6px);
213
+ background-size: 9px 9px, 13px 13px;
214
+ background-position: 0 0, 5px 5px;
215
+ }
216
+
217
+ /* Tri-color register bar across top — pink / blue / yellow */
218
+ .gradio-container::after {
219
+ content: '';
220
+ position: fixed; top: 0; left: 0; right: 0;
221
+ height: 14px;
222
+ background:
223
+ linear-gradient(90deg,
224
+ var(--ai) 0 38%,
225
+ var(--human) 38% 72%,
226
+ var(--yellow) 72% 100%);
227
+ pointer-events: none; z-index: 2;
228
+ border-bottom: 2px solid var(--ink);
229
+ }
230
+
231
+ .main, .contain {
232
+ background: transparent !important;
233
+ position: relative; z-index: 1;
234
+ padding: 0 1.6rem !important;
235
  }
 
236
  footer { display: none !important; }
237
 
238
+ /* ============ HEADER ============ */
239
+ .header-block {
240
+ padding: 3rem 0 1.2rem 0;
241
+ margin-bottom: 0.4rem;
242
+ position: relative;
243
+ }
244
+ .wordmark {
245
+ font-family: 'Bricolage Grotesque', sans-serif !important;
246
+ font-variation-settings: 'wdth' 88, 'wght' 800, 'opsz' 96;
247
+ font-size: 4.4rem;
248
+ font-weight: 800;
249
+ letter-spacing: -0.06em;
250
+ line-height: 0.9;
251
+ color: var(--ink);
252
+ text-decoration: none !important;
253
+ display: inline-block;
254
+ margin: 0;
255
  }
256
+ .wordmark .slash {
257
+ color: var(--ai);
258
+ font-weight: 800;
259
+ padding: 0;
260
+ display: inline-block;
261
+ transform: translateY(-0.04em);
262
  }
263
+ a.wordmark { transition: transform 0.2s ease; }
264
+ a.wordmark:hover { transform: rotate(-1deg); }
265
+ .subline {
266
+ font-family: 'Bricolage Grotesque', sans-serif;
267
+ font-variation-settings: 'wdth' 100, 'wght' 400, 'opsz' 14;
268
+ font-size: 1.05rem;
269
+ color: var(--ink-soft);
270
+ line-height: 1.5;
271
+ margin: 1.1rem 0 0 0;
272
+ max-width: 540px;
273
+ }
274
+ .subline em {
275
+ font-style: italic;
276
+ font-weight: 700;
277
+ color: var(--ink);
278
+ background: linear-gradient(transparent 62%, var(--yellow) 62%);
279
+ padding: 0 0.1rem;
280
  }
 
281
 
282
+ /* ============ TABS ============ */
283
  .tabs { background: transparent !important; border: none !important; }
284
  .tab-nav {
285
+ background: transparent !important;
286
+ border: none !important;
287
+ border-bottom: 2px solid var(--ink) !important;
288
+ border-radius: 0 !important;
289
+ padding: 0 !important;
290
+ margin-top: 1.6rem !important;
291
+ gap: 0 !important;
292
  }
293
  .tab-nav button {
294
+ font-family: 'JetBrains Mono', monospace !important;
295
+ font-size: 0.78rem !important; font-weight: 700 !important;
296
+ letter-spacing: 0.22em !important; text-transform: uppercase !important;
297
+ color: var(--ink) !important;
298
+ background: var(--paper) !important;
299
+ border: 2px solid var(--ink) !important;
300
+ border-bottom: 2px solid var(--ink) !important;
301
+ border-radius: 0 !important;
302
+ padding: 0.7rem 0 !important;
303
+ margin: 0 0.5rem -2px 0 !important;
304
+ transition: background 0.12s ease, color 0.12s ease !important;
305
+ position: relative;
306
+ min-width: 120px !important;
307
+ text-align: center !important;
308
+ box-sizing: border-box !important;
309
+ }
310
+ .tab-nav button:hover {
311
+ background: var(--yellow) !important;
312
+ }
313
  .tab-nav button.selected {
314
+ color: var(--paper) !important;
315
+ background: var(--ink) !important;
316
+ border: 2px solid var(--ink) !important;
317
+ border-bottom: 2px solid var(--ink) !important;
318
+ }
319
+ .tab-nav button.selected::after {
320
+ content: '';
321
+ position: absolute;
322
+ left: 0; right: 0; bottom: -8px;
323
+ height: 4px;
324
+ background: var(--ai);
325
+ }
326
+
327
+ .tabitem {
328
+ background: transparent !important; border: none !important;
329
+ padding: 1.4rem 0 !important; min-height: 540px !important;
330
+ }
331
+
332
+ /* ============ SECTION LABEL ============ */
333
+ .section-label {
334
+ font-family: 'JetBrains Mono', monospace !important;
335
+ font-size: 0.62rem !important; font-weight: 700 !important;
336
+ color: var(--ink) !important;
337
+ background: transparent !important;
338
+ letter-spacing: 0.24em !important; text-transform: uppercase !important;
339
+ margin: 0 0 0.6rem 0 !important; padding: 0 !important;
340
+ display: flex !important; align-items: baseline !important; gap: 0.6rem !important;
341
+ }
342
+ .section-label .tag {
343
+ font-family: 'Bricolage Grotesque', sans-serif !important;
344
+ color: var(--ink-faded) !important;
345
+ background: transparent !important;
346
+ font-weight: 400 !important; letter-spacing: 0 !important;
347
+ text-transform: none !important;
348
+ font-size: 0.78rem !important;
349
  }
 
350
 
351
+ /* ============ CARDS — bold-bordered riso zine blocks ============ */
352
  .input-card {
353
+ background: var(--paper) !important;
354
+ border: 2px solid var(--ink) !important;
355
+ border-radius: 0 !important;
356
+ padding: 1.2rem 1.3rem !important;
357
+ margin: 0 0 1.4rem 0 !important;
358
+ position: relative !important;
359
+ box-shadow: 5px 5px 0 var(--ink) !important;
360
+ }
361
+
362
+ /* ============ INPUT HINT — live char-counter ============ */
363
+ .input-hint {
364
+ margin: 0.4rem 0 0 0 !important;
365
+ padding: 0 !important;
366
+ background: transparent !important;
367
+ border: none !important;
368
+ font-family: 'JetBrains Mono', monospace !important;
369
+ font-size: 0.7rem !important;
370
+ color: var(--ink-faded) !important;
371
+ letter-spacing: 0.04em !important;
372
+ }
373
+ .input-hint strong { color: var(--ink); font-weight: 700; }
374
+ .input-hint .hint-meta { color: var(--ink); font-weight: 700; }
375
+ .input-hint .hint-warn {
376
+ color: var(--ink);
377
+ background: var(--yellow);
378
+ padding: 0 0.4rem;
379
+ font-weight: 700;
380
+ border: 1px solid var(--ink);
381
+ }
382
+
383
+ /* ============ STATUS / ERROR MESSAGE ============ */
384
+ .status-msg {
385
+ margin: 0.4rem 0 1rem 0 !important;
386
+ padding: 0 !important;
387
+ background: transparent !important;
388
+ border: none !important;
389
+ }
390
+ .status-msg .status-inner {
391
+ background: var(--yellow);
392
+ border: 2px solid var(--ink);
393
+ box-shadow: 4px 4px 0 var(--ink);
394
+ padding: 0.9rem 1.1rem;
395
+ font-family: 'Bricolage Grotesque', sans-serif;
396
+ font-size: 0.95rem;
397
+ font-weight: 500;
398
+ color: var(--ink);
399
  }
400
 
401
+ /* ============ FORM CONTROLS ============ */
402
  textarea, input[type="text"] {
403
+ font-family: 'Bricolage Grotesque', system-ui, sans-serif !important;
404
+ font-variation-settings: 'wdth' 100, 'wght' 400, 'opsz' 14;
405
+ font-size: 1rem !important; line-height: 1.55 !important;
406
+ color: var(--ink) !important;
407
+ background: var(--paper) !important;
408
+ border: 1.5px solid var(--ink) !important;
409
+ border-radius: 0 !important;
410
+ padding: 0.75rem 0.85rem !important;
411
+ transition: all 0.12s ease !important;
412
+ caret-color: var(--ai) !important;
413
+ box-shadow: none !important;
414
+ }
415
+ textarea::placeholder, input[type="text"]::placeholder {
416
+ color: var(--ink-faded) !important;
417
  }
418
  textarea:focus, input[type="text"]:focus {
419
+ border-color: var(--ai) !important;
420
+ box-shadow: 3px 3px 0 var(--ai) !important;
421
+ outline: none !important;
422
  }
423
  label span {
424
+ font-family: 'JetBrains Mono', monospace !important;
425
+ font-size: 0.58rem !important; font-weight: 700 !important;
426
+ text-transform: uppercase !important; letter-spacing: 0.28em !important;
427
+ color: var(--ink-faded) !important;
428
  }
429
 
430
+ /* ============ PRIMARY BUTTON — pink stamp ============ */
431
  .detect-btn {
432
+ font-family: 'JetBrains Mono', monospace !important;
433
+ font-size: 0.72rem !important; font-weight: 700 !important;
434
+ letter-spacing: 0.22em !important; text-transform: uppercase !important;
435
+ background: var(--ink) !important;
436
+ color: var(--paper) !important;
437
+ border: 2px solid var(--ink) !important;
438
+ border-radius: 0 !important;
439
+ padding: 0.9rem 1.8rem !important;
440
+ cursor: pointer !important;
441
+ transition: all 0.12s ease !important;
442
+ box-shadow: 4px 4px 0 var(--ai) !important;
443
+ margin-top: 0.8rem !important;
444
+ }
445
+ .detect-btn:hover {
446
+ background: var(--ai) !important;
447
+ color: var(--ink) !important;
448
+ box-shadow: 4px 4px 0 var(--human) !important;
449
+ transform: translate(-2px, -2px) !important;
450
+ }
451
+ .detect-btn:active {
452
+ transform: translate(2px, 2px) !important;
453
+ box-shadow: 0 0 0 var(--ai) !important;
454
  }
 
455
 
456
+ /* ============ ACTION ROWS (feedback + share) ============ */
457
+ .action-row {
458
+ margin: 0.7rem 0 0 0 !important;
459
+ gap: 0.5rem !important;
460
+ display: flex !important;
461
+ flex-direction: row !important;
462
+ flex-wrap: wrap !important;
463
+ justify-content: flex-start !important;
464
+ align-items: center !important;
465
+ background: transparent !important;
466
+ border: none !important;
467
+ }
468
+
469
+ /* Share-card image is the result display */
470
+ #share-card {
471
+ margin: 0.3rem 0 0.6rem 0 !important;
472
+ padding: 0 !important;
473
+ background: transparent !important;
474
+ border: none !important;
475
+ }
476
+ #share-card .image-frame,
477
+ #share-card .image-container,
478
+ #share-card .gradio-image {
479
+ background: transparent !important;
480
+ border: none !important;
481
+ padding: 0 !important;
482
+ border-radius: 0 !important;
483
+ }
484
+ #share-card img {
485
+ display: block !important;
486
+ max-width: 100% !important;
487
+ width: 100% !important;
488
+ height: auto !important;
489
+ border: 2px solid var(--ink) !important;
490
+ border-radius: 0 !important;
491
+ box-shadow: 5px 5px 0 var(--ink);
492
+ }
493
+
494
+
495
+ /* ============ CHIP BUTTONS — natural width, stamp shadow ============ */
496
+ .chip-btn {
497
+ font-family: 'JetBrains Mono', monospace !important;
498
+ font-size: 0.64rem !important; font-weight: 700 !important;
499
+ letter-spacing: 0.18em !important; text-transform: uppercase !important;
500
+ background: var(--paper) !important;
501
+ color: var(--ink) !important;
502
+ border: 1.5px solid var(--ink) !important;
503
+ border-radius: 0 !important;
504
+ padding: 0.55rem 1rem !important;
505
+ margin: 0 !important;
506
+ cursor: pointer !important;
507
+ transition: background 0.12s ease, transform 0.12s ease, box-shadow 0.12s ease !important;
508
+ box-shadow: 2px 2px 0 var(--ink) !important;
509
+ flex: 0 0 auto !important;
510
+ width: auto !important;
511
+ min-width: 0 !important;
512
+ max-width: none !important;
513
+ white-space: nowrap !important;
514
+ line-height: 1 !important;
515
+ }
516
+ .chip-btn:hover { transform: translate(-1px, -1px) !important; box-shadow: 3px 3px 0 var(--ink) !important; }
517
+ .chip-btn:active { transform: translate(1px, 1px) !important; box-shadow: 1px 1px 0 var(--ink) !important; }
518
+ .chip-btn.ok:hover { background: var(--mint) !important; }
519
+ .chip-btn.no:hover { background: var(--ai-soft) !important; }
520
+ .chip-btn.share:hover { background: var(--yellow) !important; }
521
+
522
+ .feedback-msg {
523
+ font-family: 'JetBrains Mono', monospace !important;
524
+ font-size: 0.68rem !important; font-weight: 700 !important;
525
+ color: var(--ink) !important;
526
+ background: var(--mint);
527
+ border: 1.5px solid var(--ink);
528
+ padding: 0.55rem 0.9rem !important;
529
+ letter-spacing: 0.16em !important;
530
+ text-transform: uppercase;
531
+ display: inline-block;
532
+ margin-top: 0.6rem;
533
  }
534
+ .feedback-msg::before { content: ''; color: var(--human); font-weight: 700; }
 
 
535
 
536
+ /* ============ EXAMPLES & PREVIEW ============ */
537
  .examples-heading {
538
+ font-family: 'JetBrains Mono', monospace !important;
539
+ font-size: 0.6rem !important; font-weight: 700 !important;
540
+ letter-spacing: 0.3em !important; text-transform: uppercase !important;
541
+ color: var(--ink) !important;
542
+ margin: 1.6rem 0 0.6rem 0 !important;
543
+ display: flex; align-items: center; gap: 0.6rem;
544
+ }
545
+ .examples-heading::before {
546
+ content: '';
547
+ display: inline-block;
548
+ width: 12px; height: 12px;
549
+ background: var(--human);
550
+ border: 1.5px solid var(--ink);
551
+ }
552
+ .gallery { gap: 0.6rem !important; flex-wrap: wrap !important; }
553
+ .gallery .gallery-item,
554
+ .gallery > div > div {
555
+ background: var(--paper) !important;
556
+ border: 1.5px solid var(--ink) !important;
557
+ border-radius: 0 !important;
558
+ padding: 0.7rem 0.9rem !important;
559
+ transition: all 0.12s ease !important;
560
+ cursor: pointer !important;
561
+ color: var(--ink) !important;
562
+ font-family: 'Bricolage Grotesque', sans-serif !important;
563
+ font-size: 0.88rem !important;
564
+ line-height: 1.45 !important;
565
+ box-shadow: 3px 3px 0 var(--ink) !important;
566
+ }
567
+ .gallery .gallery-item:hover,
568
+ .gallery > div > div:hover {
569
+ background: var(--yellow) !important;
570
+ transform: translate(-2px, -2px);
571
+ box-shadow: 5px 5px 0 var(--ink) !important;
572
+ }
573
+ .gallery .gallery-item *,
574
+ .gallery .gallery-item textarea,
575
+ .gallery .gallery-item span,
576
+ .gallery .gallery-item div {
577
+ color: var(--ink) !important;
578
+ background: transparent !important;
579
+ font-family: 'Bricolage Grotesque', sans-serif !important;
580
+ font-size: 0.88rem !important;
581
+ border: none !important;
582
+ }
583
+ .preview-box textarea {
584
+ color: var(--ink-soft) !important;
585
+ font-size: 0.95rem !important;
586
+ font-family: 'Bricolage Grotesque', sans-serif !important;
587
+ background: var(--paper-soft) !important;
588
+ border: 1.5px solid var(--ink) !important;
589
+ }
590
+ .gr-group, .gr-block, .gr-box, .gr-panel {
591
+ background: transparent !important; border: none !important;
592
+ }
593
  .gr-padded { padding: 0 !important; }
594
 
595
+ /* ============ FOOTER ============ */
596
+ .info-strip {
597
+ border-top: 1.5px solid var(--ink);
598
+ margin-top: 2.4rem;
599
+ padding: 1rem 0 2.5rem 0;
600
+ text-align: left;
601
+ }
602
+ .info-strip p.links {
603
+ font-family: 'JetBrains Mono', monospace !important;
604
+ font-size: 0.68rem !important; font-weight: 700 !important;
605
+ color: var(--ink) !important;
606
+ letter-spacing: 0.18em !important; text-transform: uppercase !important;
607
+ margin: 0 0 0.5rem 0 !important; line-height: 1.6;
608
+ }
609
+ .info-strip .note {
610
+ font-weight: 400 !important;
611
+ color: var(--ink-faded) !important;
612
+ text-transform: none !important;
613
+ letter-spacing: 0.06em !important;
614
+ font-style: italic;
615
+ margin-left: 0.1rem;
616
+ }
617
+ .info-strip a {
618
+ color: var(--ink) !important;
619
+ text-decoration: none !important;
620
+ border-bottom: 1.5px solid var(--ink);
621
+ padding: 0 1px 1px 1px;
622
+ transition: color 0.12s ease, border-color 0.12s ease;
623
+ background: none;
624
+ }
625
+ .info-strip a:hover {
626
+ color: var(--ai) !important;
627
+ border-bottom-color: var(--ai);
628
+ }
629
+ .info-strip .sep {
630
+ color: var(--ink-faded);
631
+ margin: 0 0.55rem;
632
+ background: none; border: none; padding: 0;
633
+ display: inline;
634
+ font-weight: 400;
635
+ }
636
 
637
+ /* hide share-card image toolbars */
638
  #share-card .image-toolbar, #text-share-card .image-toolbar,
639
  #share-card .icon-buttons, #text-share-card .icon-buttons,
640
  #share-card button[aria-label], #text-share-card button[aria-label] { display: none !important; }
 
 
 
 
 
 
 
 
 
 
641
  """
642
 
643
  HUMAN_EXAMPLE = (
 
692
  return re.sub(r"\s+", " ", " ".join(self._parts)).strip()
693
 
694
 
695
+ try:
696
+ import trafilatura
697
+ _HAVE_TRAFILATURA = True
698
+ except ImportError:
699
+ _HAVE_TRAFILATURA = False
700
+
701
+
702
  def fetch_url(url: str) -> str:
703
  if not url or not url.strip():
704
  return ""
 
708
  req = urllib.request.Request(url, headers=HEADERS)
709
  with urllib.request.urlopen(req, timeout=15) as resp:
710
  html = resp.read().decode("utf-8", errors="ignore")
711
+
712
+ # Prefer trafilatura — it extracts just the main article body,
713
+ # dropping nav menus, sidebars, footers, infoboxes, edit links, ToCs, references.
714
+ if _HAVE_TRAFILATURA:
715
+ extracted = trafilatura.extract(
716
+ html, url=url,
717
+ include_comments=False,
718
+ include_tables=False,
719
+ include_links=False,
720
+ deduplicate=True,
721
+ favor_recall=False,
722
+ )
723
+ if extracted and len(extracted.split()) >= 50:
724
+ return extracted
725
+
726
+ # Fallback: simple home-grown HTML stripper.
727
  parser = _TextExtractor()
728
  parser.feed(html)
729
  return parser.get_text()
 
732
  # ---------------------------------------------------------------------------
733
  # Result card image
734
  # ---------------------------------------------------------------------------
735
+ def make_result_card(source: str, label: str, confidence: float, extracted_text: str = "") -> Image.Image:
736
+ """Share card layout (top-down):
737
+ 1. Tri-color register strip
738
+ 2. Header strip: wordmark left, TRY IT -> URL top-right
739
+ 3. Verdict block (in one paper sheet):
740
+ - VERDICT label
741
+ - Small verdict word + percentage
742
+ - Hatched confidence bar
743
+ - TEXT/URL ANALYZED label
744
+ - 3 lines of source preview
745
+ """
746
+ W, H = 1100, 560
747
+ PAPER = "#fffaf0"
748
+ PAPER_SOFT = "#fbf2dc"
749
+ INK = "#0a0908"
750
+ INK_F = "#6b6864"
751
+ AI = "#f15baa"
752
+ HUMAN = "#1f4dd6"
753
+ YELLOW = "#ffd400"
754
 
755
+ is_ai = label.lower() == "ai"
756
+ accent = AI if is_ai else HUMAN
757
+ verdict_word = "ai" if is_ai else "human"
758
+
759
+ img = Image.new("RGB", (W, H), PAPER)
760
  draw = ImageDraw.Draw(img)
 
 
 
 
 
 
 
761
 
762
+ def load(*paths_sizes):
763
+ for path, size in paths_sizes:
764
+ try:
765
+ return ImageFont.truetype(path, size)
766
+ except OSError:
767
+ continue
768
+ return ImageFont.load_default()
769
+
770
+ # Smaller verdict (was 160)
771
+ f_verdict = load(
772
+ ("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 112),
773
+ ("/System/Library/Fonts/Supplemental/Arial Bold.ttf", 112),
774
+ )
775
+ f_wordmark = load(
776
+ ("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 26),
777
+ ("/System/Library/Fonts/Supplemental/Arial Bold.ttf", 26),
778
+ )
779
+ f_try = load(
780
+ ("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 14),
781
+ ("/System/Library/Fonts/Supplemental/Arial Bold.ttf", 14),
782
+ )
783
+ f_url = load(
784
+ ("/usr/share/fonts/truetype/dejavu/DejaVuSansMono-Bold.ttf", 14),
785
+ ("/System/Library/Fonts/Menlo.ttc", 14),
786
+ )
787
+ f_pct = load(
788
+ ("/usr/share/fonts/truetype/dejavu/DejaVuSansMono-Bold.ttf", 44),
789
+ ("/System/Library/Fonts/Menlo.ttc", 44),
790
+ )
791
+ f_pct_mark = load(
792
+ ("/usr/share/fonts/truetype/dejavu/DejaVuSansMono-Bold.ttf", 24),
793
+ ("/System/Library/Fonts/Menlo.ttc", 24),
794
+ )
795
+ f_mono_s = load(
796
+ ("/usr/share/fonts/truetype/dejavu/DejaVuSansMono-Bold.ttf", 13),
797
+ ("/System/Library/Fonts/Menlo.ttc", 13),
798
+ )
799
+ f_preview = load(
800
+ ("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 17),
801
+ ("/System/Library/Fonts/Supplemental/Arial.ttf", 17),
802
+ )
803
+ f_url_preview = load(
804
+ ("/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", 15),
805
+ ("/System/Library/Fonts/Menlo.ttc", 15),
806
+ )
807
 
808
+ # ===== Tri-color strip =====
809
+ bar_h = 8
810
+ third = W // 3
811
+ draw.rectangle([0, 0, third, bar_h], fill=AI)
812
+ draw.rectangle([third, 0, third * 2, bar_h], fill=HUMAN)
813
+ draw.rectangle([third * 2, 0, W, bar_h], fill=YELLOW)
814
+
815
+ # ===== Header strip =====
816
+ strip_top = bar_h
817
+ strip_h = 90
818
+ draw.line([(0, strip_top + strip_h), (W, strip_top + strip_h)], fill=INK, width=1)
819
+
820
+ # Wordmark left
821
+ wm_x, wm_y = 40, strip_top + 22
822
+ draw.text((wm_x, wm_y), "ai", fill=INK, font=f_wordmark)
823
+ bbox = draw.textbbox((wm_x, wm_y), "ai", font=f_wordmark)
824
+ draw.text((bbox[2] + 2, wm_y), "/", fill=AI, font=f_wordmark)
825
+ bbox2 = draw.textbbox((bbox[2] + 2, wm_y), "/", font=f_wordmark)
826
+ draw.text((bbox2[2] + 2, wm_y), "detector", fill=INK, font=f_wordmark)
827
+ draw.text((wm_x, wm_y + 36), "HUMAN-vs-AI TEXT CLASSIFIER", fill=INK_F, font=f_mono_s)
828
+
829
+ # TRY IT -> URL top-right
830
+ full_url = "adaptive-classifier-ai-detector.hf.space"
831
+ url_bbox = draw.textbbox((0, 0), full_url, font=f_url)
832
+ url_w = url_bbox[2] - url_bbox[0]
833
+ try_bbox = draw.textbbox((0, 0), "TRY IT →", font=f_try)
834
+ try_w = try_bbox[2] - try_bbox[0]
835
+ line_w = max(url_w, try_w)
836
+ right_edge = W - 40
837
+ line1_x = right_edge - try_w
838
+ line2_x = right_edge - url_w
839
+ draw.text((line1_x, strip_top + 22), "TRY IT →", fill=INK, font=f_try)
840
+ draw.text((line2_x, strip_top + 42), full_url, fill=INK, font=f_url)
841
+ # Underline the URL so it reads as clickable
842
+ draw.line([(line2_x, strip_top + 60), (line2_x + url_w, strip_top + 60)], fill=accent, width=2)
843
+
844
+ # ===== Verdict block =====
845
+ bx, by = 40, strip_top + strip_h + 26
846
+ bw = W - 80
847
+ bh = H - by - 30
848
+ # offset shadow
849
+ draw.rectangle([bx + 7, by + 7, bx + bw + 7, by + bh + 7], fill=accent)
850
+ # main block
851
+ draw.rectangle([bx, by, bx + bw, by + bh], fill=PAPER, outline=INK, width=2)
852
+
853
+ pad_x = 36
854
+ inner_x = bx + pad_x
855
+ inner_right = bx + bw - pad_x
856
+
857
+ # VERDICT label
858
+ cur_y = by + 26
859
+ draw.text((inner_x, cur_y), "VERDICT", fill=INK_F, font=f_mono_s)
860
+ cur_y += 22
861
+
862
+ # Verdict word + colored period + percentage on the right
863
+ word_y = cur_y
864
+ draw.text((inner_x, word_y), verdict_word, fill=INK, font=f_verdict)
865
+ wbb = draw.textbbox((inner_x, word_y), verdict_word, font=f_verdict)
866
+ draw.text((wbb[2] + 4, word_y), ".", fill=accent, font=f_verdict)
867
+
868
+ # Percentage on the right, vertically centered with verdict word
869
+ pct = int(round(confidence * 100))
870
+ pct_str = str(pct)
871
+ pct_bbox = draw.textbbox((0, 0), pct_str, font=f_pct)
872
+ pct_w = pct_bbox[2] - pct_bbox[0]
873
+ mark_bbox = draw.textbbox((0, 0), "%", font=f_pct_mark)
874
+ mark_w = mark_bbox[2] - mark_bbox[0]
875
+ pct_total_w = pct_w + 6 + mark_w
876
+ pct_x = inner_right - pct_total_w
877
+ pct_y = word_y + 30
878
+ draw.text((pct_x, pct_y), pct_str, fill=INK, font=f_pct)
879
+ draw.text((pct_x + pct_w + 6, pct_y + 16), "%", fill=INK_F, font=f_pct_mark)
880
+
881
+ cur_y = word_y + 120
882
+
883
+ # Hatched confidence bar
884
+ bar_y = cur_y
885
+ bar_h_px = 20
886
+ bar_w = bw - 2 * pad_x
887
+ draw.rectangle([inner_x, bar_y, inner_x + bar_w, bar_y + bar_h_px], fill=PAPER_SOFT, outline=INK, width=2)
888
+ fw = int(bar_w * confidence)
889
+ if fw > 4:
890
+ draw.rectangle([inner_x + 2, bar_y + 2, inner_x + fw - 2, bar_y + bar_h_px - 2], fill=accent)
891
+ for x in range(inner_x - bar_h_px, inner_x + fw, 6):
892
+ draw.line([(x, bar_y + bar_h_px - 2), (x + bar_h_px - 2, bar_y + 2)], fill=PAPER, width=1)
893
+
894
+ cur_y = bar_y + bar_h_px + 26
895
+
896
+ # Source preview — under verdict
897
+ is_url_src = source.startswith("http") or source.startswith("www")
898
+ src_label = "URL ANALYZED" if is_url_src else "TEXT ANALYZED"
899
+ draw.text((inner_x, cur_y), src_label, fill=INK_F, font=f_mono_s)
900
+ cur_y += 22
901
+
902
+ max_w = bw - 2 * pad_x
903
+
904
+ # URL case: show URL on one mono line, then 3 lines of extracted text below.
905
+ # Text case: show 4 lines of the input text.
906
+ if is_url_src:
907
+ url_line = source.strip()
908
+ while f_url_preview.getlength(url_line) > max_w and len(url_line) > 10:
909
+ url_line = url_line[:-4] + "..."
910
+ draw.text((inner_x, cur_y), url_line, fill=INK, font=f_url_preview)
911
+ cur_y += 24
912
+ body = (extracted_text or "").strip().replace("\n", " ")
913
+ pf = f_preview
914
+ max_lines = 3
915
  else:
916
+ body = source.strip().replace("\n", " ")
917
+ pf = f_preview
918
+ max_lines = 4
919
+
920
+ lines, current = [], ""
921
+ for w in body.split():
922
+ test = (current + " " + w).strip()
923
+ if pf.getlength(test) > max_w:
924
+ if current:
925
+ lines.append(current)
926
+ current = w
927
+ else:
928
+ current = test
929
+ if len(lines) >= max_lines:
930
+ break
931
+ if current and len(lines) < max_lines:
932
+ lines.append(current)
933
+ if lines:
934
+ total_in_lines = sum(len(l) for l in lines) + len(lines) - 1
935
+ if len(body) > total_in_lines + 5:
936
+ last = lines[-1]
937
+ while pf.getlength(last + "...") > max_w and len(last) > 4:
938
+ last = last[:-1]
939
+ lines[-1] = last + "..."
940
+ line_h = 24
941
+ for i, line in enumerate(lines):
942
+ draw.text((inner_x, cur_y + i * line_h), line, fill=INK, font=pf)
943
 
944
+ return img
945
 
 
 
 
946
  def _error_label(msg: str) -> dict:
947
  return {msg: 1.0}
948
 
 
956
 
957
  def detect_text_full(text: str):
958
  """Returns (result, share_link, card, pred_id)"""
959
+ # Mirror the URL flow: cap at 2,000 chars (~512 tokens) before scoring.
960
+ scored_text = text.strip()[:2000]
961
+ result = _classify(scored_text)
962
  if any(k.startswith("Please") for k in result):
963
  return result, "", None, ""
964
  top_label = max(result, key=result.get)
965
  pred_id = uuid4().hex[:12]
966
+ save_prediction(pred_id, scored_text, "", top_label, result[top_label])
967
  share_link = f"{SPACE_URL}/?id={pred_id}"
968
+ card = make_result_card(scored_text, top_label, result[top_label])
 
969
  return result, share_link, card, pred_id
970
 
971
 
 
985
  return _error_label("This site requires JavaScript (e.g. Twitter/X). Paste the text directly instead."), "", "", None, ""
986
  if len(text.split()) < 10:
987
  return _error_label("Not enough readable text found at that URL"), text[:500], "", None, ""
988
+ # full_text: everything trafilatura extracted (shown to user for assurance).
989
+ # scored_text: first ~2000 chars (~512 tokens, RoBERTa limit) what the model sees.
990
+ full_text = text
991
+ scored_text = text[:2000]
992
+ result = _classify(scored_text)
993
  if any(k.startswith("Please") or k.startswith("Not enough") for k in result):
994
+ return result, full_text[:500], "", None, ""
995
  top_label = max(result, key=result.get)
996
  pred_id = uuid4().hex[:12]
997
+ save_prediction(pred_id, scored_text, url.strip(), top_label, result[top_label])
 
998
  share_link = f"{SPACE_URL}/?id={pred_id}"
999
+ card = make_result_card(url.strip(), top_label, result[top_label], extracted_text=scored_text)
1000
+ return result, full_text, share_link, card, pred_id
1001
 
1002
 
1003
  # ---------------------------------------------------------------------------
1004
  # UI
1005
  # ---------------------------------------------------------------------------
1006
+ URL_PATTERN = re.compile(r"^(https?://|www\.)\S+", re.IGNORECASE)
1007
+
1008
+
1009
+ def _is_url(s: str) -> bool:
1010
+ s = s.strip()
1011
+ if URL_PATTERN.match(s):
1012
+ return True
1013
+ # Bare domain like "example.com/path"
1014
+ if " " not in s and len(s) < 250 and re.match(r"^[a-zA-Z0-9.-]+\.[a-z]{2,}(/.*)?$", s):
1015
+ return True
1016
+ return False
1017
+
1018
+
1019
+ def detect_any(input_text: str):
1020
+ """Return (result_dict, preview_text, share_link, card, pred_id, was_url)."""
1021
+ s = (input_text or "").strip()
1022
+ if not s:
1023
+ return _error_label("Please enter text or a URL"), "", "", None, "", False
1024
+ if _is_url(s):
1025
+ result, preview, link, card, pid = detect_url_full(s)
1026
+ return result, preview, link, card, pid, True
1027
+ result, link, card, pid = detect_text_full(s)
1028
+ return result, "", link, card, pid, False
1029
+
1030
+
1031
+ def _render_verdict(result: dict) -> str:
1032
+ """Render the verdict block as HTML — single confidence row for the top class."""
1033
+ if not result:
1034
+ return ""
1035
+ if any(k.startswith(("Please", "Not enough", "Could not", "This site"))
1036
+ for k in result):
1037
+ msg = next(iter(result))
1038
+ return (
1039
+ '<div class="verdict-block error">'
1040
+ f' <div class="verdict-error">{msg}</div>'
1041
+ '</div>'
1042
+ )
1043
+
1044
+ top = max(result, key=result.get)
1045
+ top_conf = result[top]
1046
+ accent_class = "ai" if top == "ai" else "human"
1047
+ pct = int(round(top_conf * 100))
1048
+
1049
+ return f'''
1050
+ <div class="verdict-block {accent_class}">
1051
+ <div class="verdict-meta">VERDICT</div>
1052
+ <div class="verdict-word">{top}<span class="verdict-dot">.</span></div>
1053
+ <div class="verdict-row">
1054
+ <div class="vrow-bar"><div class="vrow-fill" style="width: {top_conf*100:.1f}%;"></div></div>
1055
+ <span class="vrow-pct">{pct}<span class="vrow-pct-mark">%</span></span>
1056
+ </div>
1057
+ </div>
1058
+ '''
1059
+
1060
+
1061
+ with gr.Blocks(css=CSS, head=HEAD, title="AI Detector", theme=gr.themes.Base()) as demo:
1062
 
1063
  gr.HTML("""
1064
  <div class="header-block">
1065
+ <a class="wordmark" href="https://huggingface.co/adaptive-classifier/ai-detector" target="_blank">ai<span class="slash">/</span>detector</a>
1066
+ <div class="subline">
1067
+ A small classifier that reads a passage of text, or a web page at a URL,
1068
+ and tells you whether it was written by a <em>human</em> or by a <em>language model</em>.
1069
+ </div>
 
 
1070
  </div>
1071
  """)
1072
 
1073
+ with gr.Group(elem_classes="input-card"):
1074
+ main_input = gr.Textbox(
1075
+ lines=6,
1076
+ placeholder="paste text or a URL here…",
1077
+ label="Input",
1078
+ show_label=False,
1079
+ )
1080
+ input_hint = gr.HTML(visible=False, elem_classes="input-hint")
1081
+ main_btn = gr.Button("Analyze", variant="primary", elem_classes="detect-btn")
1082
+
1083
+ status_msg = gr.HTML(visible=False, elem_classes="status-msg")
1084
+ # The share card IS the result display — no separate verdict block.
1085
+ share_card = gr.Image(visible=False, type="pil", elem_id="share-card", show_label=False, container=False)
1086
+ main_pred_id = gr.Textbox(visible=False, elem_id="main-pred-id")
1087
+ share_link = gr.Textbox(visible=False, interactive=False, elem_id="share-url", show_label=False, container=False)
1088
+ # Single action row: feedback (correct / incorrect) + share (link / image / download)
1089
+ with gr.Row(visible=False, elem_classes="action-row") as actions_row:
1090
+ fb_up = gr.Button("Correct", elem_classes="chip-btn ok")
1091
+ fb_down = gr.Button("Incorrect", elem_classes="chip-btn no")
1092
+ copy_link_btn = gr.Button("Copy link", elem_classes="chip-btn share")
1093
+ copy_img_btn = gr.Button("Copy image", elem_classes="chip-btn share")
1094
+ dl_img_btn = gr.Button("Download image", elem_classes="chip-btn share")
1095
+ fb_msg = gr.HTML(visible=False, elem_classes="feedback-msg")
1096
+
1097
+ with gr.Group(elem_classes="input-card", visible=False) as preview_group:
1098
+ gr.HTML('<div class="section-label">Extracted from URL<span class="tag">full text shown &middot; first 2,000 characters sent to the model</span></div>')
1099
+ url_preview = gr.Textbox(label="", lines=5, interactive=False, elem_classes="preview-box", show_label=False)
1100
+
1101
+ def run_and_show(input_text):
1102
+ result, preview, link, card, pid, is_url = detect_any(input_text)
1103
+ has_card = card is not None
1104
+ is_error = (not has_card) and result and any(
1105
+ k.startswith(("Please", "Not enough", "Could not", "This site"))
1106
+ for k in result
1107
+ )
1108
+ error_html = ""
1109
+ if is_error:
1110
+ msg = next(iter(result))
1111
+ error_html = f'<div class="status-inner">⚠ {msg}</div>'
1112
+ return (
1113
+ gr.update(value=error_html, visible=is_error),
1114
+ gr.update(value=card, visible=has_card),
1115
+ gr.update(value=pid, visible=False),
1116
+ gr.update(value=link, visible=has_card),
1117
+ gr.update(visible=has_card),
1118
+ gr.update(visible=False),
1119
+ gr.update(visible=is_url and has_card),
1120
+ preview,
1121
+ )
1122
+
1123
+ main_btn.click(
1124
+ fn=run_and_show, inputs=main_input,
1125
+ outputs=[status_msg, share_card, main_pred_id, share_link, actions_row, fb_msg, preview_group, url_preview],
1126
+ api_name="detect",
1127
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1128
 
1129
+ def _update_hint(text):
1130
+ s = (text or "").strip()
1131
+ n = len(s)
1132
+ if n == 0:
1133
+ return gr.update(value="", visible=False)
1134
+ # Treat URL inputs separately — no truncation warning, just say it'll be fetched.
1135
+ if _is_url(s):
1136
+ return gr.update(value=f'<span class="hint-meta">URL detected</span> &middot; will fetch and analyze the page', visible=True)
1137
+ if n > 2000:
1138
+ extra = n - 2000
1139
+ return gr.update(
1140
+ value=f'<span class="hint-warn">{n:,} chars</span> &middot; first <strong>2,000</strong> sent to the model ({extra:,} chars truncated)',
1141
+ visible=True,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1142
  )
1143
+ return gr.update(value=f'<span class="hint-meta">{n:,} chars</span> &middot; under the 2,000-char model limit', visible=True)
1144
+
1145
+ main_input.change(fn=_update_hint, inputs=main_input, outputs=input_hint, show_progress="hidden")
1146
+
1147
+ def fb_positive(pid):
1148
+ if pid:
1149
+ save_feedback(pid, "correct")
1150
+ return gr.update(value='Thanks for your feedback!', visible=True)
1151
+
1152
+ def fb_negative(pid):
1153
+ if pid:
1154
+ save_feedback(pid, "incorrect")
1155
+ return gr.update(value='Thanks for your feedback!', visible=True)
1156
+
1157
+ fb_up.click(fn=fb_positive, inputs=main_pred_id, outputs=[fb_msg])
1158
+ fb_down.click(fn=fb_negative, inputs=main_pred_id, outputs=[fb_msg])
1159
+
1160
+ copy_link_btn.click(fn=None, inputs=share_link, js="(u) => { navigator.clipboard.writeText(u); }")
1161
+ copy_img_btn.click(fn=None, js="""() => {
1162
+ const img = document.querySelector('#share-card img');
1163
+ if (img) { const c = document.createElement('canvas'); c.width = img.naturalWidth; c.height = img.naturalHeight;
1164
+ c.getContext('2d').drawImage(img, 0, 0);
1165
+ c.toBlob(b => navigator.clipboard.write([new ClipboardItem({'image/png': b})]), 'image/png'); }
1166
+ }""")
1167
+ dl_img_btn.click(fn=None, js="""() => {
1168
+ const img = document.querySelector('#share-card img');
1169
+ if (img) { const a = document.createElement('a'); a.href = img.src; a.download = 'ai-detector-result.png'; a.click(); }
1170
+ }""")
1171
+
1172
+ gr.HTML('<div class="examples-heading">Try one</div>')
1173
+ gr.Examples(
1174
+ examples=[
1175
+ [HUMAN_EXAMPLE],
1176
+ [AI_EXAMPLE],
1177
+ ["https://en.wikipedia.org/wiki/Constitution_of_the_United_States"],
1178
+ ["https://garryslist.org/posts/richmond-just-voted-to-reinstate-their-flock-cameras-after-crime-spiked"],
1179
+ ],
1180
+ inputs=main_input, label="",
1181
+ )
1182
 
1183
  gr.HTML("""
1184
  <div class="info-strip">
1185
+ <p class="links">
1186
+ <a href="https://huggingface.co/adaptive-classifier/ai-detector">Model</a>
1187
+ <span class="sep">&middot;</span>
1188
+ <a href="https://huggingface.co/datasets/adaptive-classifier/ai-detector-data">Dataset</a>
1189
+ <span class="sep">&middot;</span>
1190
+ <a href="https://github.com/codelion/adaptive-classifier">Source code</a>
1191
+ <span class="sep">&middot;</span>
1192
+ <span class="note">best with 50+ words</span>
1193
  </p>
1194
  </div>
1195
  """)
1196
 
 
1197
  def _on_load(request: gr.Request):
1198
  pred_id = request.query_params.get("id", "")
 
 
 
 
 
 
 
 
1199
  rec = lookup_prediction(pred_id) if pred_id else None
1200
+
1201
+ if not rec:
1202
+ hide = gr.update(visible=False)
1203
+ return ("", gr.update(value="", visible=False), gr.update(value=None, visible=False),
1204
+ gr.update(value="", visible=False), gr.update(value="", visible=False), hide, hide, hide, "")
1205
+
1206
+ is_url = bool(rec.get("url"))
1207
+ if is_url:
1208
+ card = make_result_card(rec["url"], rec["prediction"], rec["confidence"], extracted_text=rec["text"])
1209
+ else:
1210
+ card = make_result_card(rec["text"], rec["prediction"], rec["confidence"])
1211
+ link = f"{SPACE_URL}/?id={pred_id}"
1212
+ main_value = rec["url"] if is_url else rec["text"]
1213
+
1214
+ return (
1215
+ main_value,
1216
+ gr.update(value="", visible=False),
1217
+ gr.update(value=card, visible=True),
1218
+ gr.update(value=pred_id, visible=False),
1219
+ gr.update(value=link, visible=True),
1220
+ gr.update(visible=True),
1221
+ gr.update(visible=False),
1222
+ gr.update(visible=is_url),
1223
+ rec["text"] if is_url else "",
1224
+ )
 
 
1225
 
1226
  demo.load(
1227
  fn=_on_load, inputs=None,
1228
+ outputs=[main_input, status_msg, share_card, main_pred_id, share_link, actions_row, fb_msg, preview_group, url_preview],
 
 
 
 
 
 
 
 
1229
  )
1230
 
1231
  if __name__ == "__main__":
requirements.txt CHANGED
@@ -2,3 +2,4 @@ adaptive-classifier>=0.1.2
2
  torch>=2.0.0
3
  transformers>=4.30.0
4
  Pillow>=10.0.0
 
 
2
  torch>=2.0.0
3
  transformers>=4.30.0
4
  Pillow>=10.0.0
5
+ trafilatura>=1.12