Spaces:
Running on Zero
Running on Zero
Commit ·
7c51531
1
Parent(s): a949557
initial commit
Browse files- .gitignore +47 -0
- app.py +181 -0
- conftest.py +6 -0
- models.py +102 -0
- rendering.py +160 -0
- requirements.txt +9 -0
- styles.py +91 -0
- validation.py +88 -0
.gitignore
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Byte-compiled / optimized files
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*$py.class
|
| 5 |
+
|
| 6 |
+
# Virtual environments
|
| 7 |
+
.venv/
|
| 8 |
+
venv/
|
| 9 |
+
env/
|
| 10 |
+
ENV/
|
| 11 |
+
|
| 12 |
+
# Packaging / build artifacts
|
| 13 |
+
build/
|
| 14 |
+
dist/
|
| 15 |
+
*.egg-info/
|
| 16 |
+
.eggs/
|
| 17 |
+
|
| 18 |
+
# Testing / coverage / linting caches
|
| 19 |
+
.pytest_cache/
|
| 20 |
+
.ruff_cache/
|
| 21 |
+
.mypy_cache/
|
| 22 |
+
.coverage
|
| 23 |
+
htmlcov/
|
| 24 |
+
|
| 25 |
+
# Gradio runtime artifacts
|
| 26 |
+
.gradio/
|
| 27 |
+
flagged/
|
| 28 |
+
gradio_cached_examples/
|
| 29 |
+
|
| 30 |
+
# Hugging Face / model caches
|
| 31 |
+
.cache/
|
| 32 |
+
hf_cache/
|
| 33 |
+
|
| 34 |
+
# IDE / editor
|
| 35 |
+
.idea/
|
| 36 |
+
.vscode/
|
| 37 |
+
*.swp
|
| 38 |
+
|
| 39 |
+
# OS noise
|
| 40 |
+
.DS_Store
|
| 41 |
+
Thumbs.db
|
| 42 |
+
|
| 43 |
+
# Local environment
|
| 44 |
+
.env
|
| 45 |
+
|
| 46 |
+
# Tests (not deployed to the Space)
|
| 47 |
+
tests/
|
app.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""GigaCheck demo: classify text and localize AI-written spans.
|
| 2 |
+
|
| 3 |
+
Two tabs back two Mistral-7B models from the LLMTrace / GigaCheck project:
|
| 4 |
+
a binary human/AI classifier and a fine-grained AI-span detector.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import gradio as gr
|
| 10 |
+
from loguru import logger
|
| 11 |
+
|
| 12 |
+
from models import classify_text, detect_intervals
|
| 13 |
+
from rendering import build_classifier_card, build_detector_card
|
| 14 |
+
from styles import CSS, THEME
|
| 15 |
+
from validation import CONFIG, validate_text
|
| 16 |
+
|
| 17 |
+
LANG_NOTE = (
|
| 18 |
+
'<div class="gc-lang-note">⚠️ These models work with <b>English</b> and '
|
| 19 |
+
f"<b>Russian</b> text only. Enter between {CONFIG.min_words} and "
|
| 20 |
+
f"{CONFIG.max_words} words.</div>"
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
CLASSIFIER_EXAMPLES = [
|
| 24 |
+
# human
|
| 25 |
+
[
|
| 26 |
+
"Netflix has laid off around 300 people across a number of departments. The company also laid off 150 workers back in May."
|
| 27 |
+
],
|
| 28 |
+
# ai
|
| 29 |
+
[
|
| 30 |
+
"@GOP @PamBondi Maybe we should talk about Gordon Sondland, who had ZERO experience or qualifications to be an Ambassador to the EU but since he donated a million dollars to trump he got the job anyway. I'm outraged!"
|
| 31 |
+
],
|
| 32 |
+
# ai
|
| 33 |
+
[
|
| 34 |
+
"В прекрасное утро, поразмыслив о том, как множество радостей приносит нам природа, я вспомнила о своей милой питомице, мадам Леске, и о ее необъяснимой страсти к дыне. \"Как же,\" подумала я, \"этот нежный плод вмещает в себе не только великолепие вкуса, но и целую кладезь полезных веществ\". И вдруг осенило меня: разве не заслуживает моя преданная спутница опробовать хоть крохотный кусочек этого плода?"
|
| 35 |
+
],
|
| 36 |
+
]
|
| 37 |
+
|
| 38 |
+
DETECTOR_EXAMPLES = [
|
| 39 |
+
[
|
| 40 |
+
"The critic's review of the recent publication was scathing. The book failed miserably in portraying the harmful subjective discourses associated with the hegemony of the political system."
|
| 41 |
+
|
| 42 |
+
],
|
| 43 |
+
[
|
| 44 |
+
"Университет Шеффилд Холлем имеет в Шеффилде два кампуса. Один из них — это Городской кампус, который находится в центре города. Второй кампус, Коллегиальный, расположен на юго-западе Шеффилда. Оба кампуса предлагают современные учебные здания и ресурсы для студентов. История университета Шеффилд Холлем началась в 1843 году с основания Шеффилдской школы дизайна. В 1960-х годах несколько независимых колледжей (включая Школу дизайна) объединились в Шеффилдский Политехникум, с 1976 года — Шеффилдский городской политехникум, с 1992 года — Университет Шеффилд Холлем."
|
| 45 |
+
],
|
| 46 |
+
]
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def update_counter(text: str) -> str:
|
| 50 |
+
"""Render a live word counter, flagging out-of-bounds input in red.
|
| 51 |
+
|
| 52 |
+
Args:
|
| 53 |
+
text: Current textbox content.
|
| 54 |
+
|
| 55 |
+
Returns:
|
| 56 |
+
An HTML snippet showing the word count and any validation message.
|
| 57 |
+
"""
|
| 58 |
+
result = validate_text(text)
|
| 59 |
+
bad = (not result.ok) and result.word_count > 0
|
| 60 |
+
css_class = "gc-counter gc-bad" if bad else "gc-counter"
|
| 61 |
+
note = f" — {result.message}" if bad else ""
|
| 62 |
+
return (
|
| 63 |
+
f'<div class="{css_class}">{result.word_count} / '
|
| 64 |
+
f"{CONFIG.max_words} words{note}</div>"
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def run_classifier(text: str) -> str:
|
| 69 |
+
"""Validate then classify the text, returning a result card.
|
| 70 |
+
|
| 71 |
+
Args:
|
| 72 |
+
text: User-supplied text.
|
| 73 |
+
|
| 74 |
+
Returns:
|
| 75 |
+
HTML for the classifier result card.
|
| 76 |
+
|
| 77 |
+
Raises:
|
| 78 |
+
gr.Error: If the text falls outside the configured word bounds.
|
| 79 |
+
"""
|
| 80 |
+
result = validate_text(text)
|
| 81 |
+
if not result.ok:
|
| 82 |
+
raise gr.Error(result.message)
|
| 83 |
+
label, p_human, p_ai = classify_text(text)
|
| 84 |
+
logger.info("classifier: label={} p_human={:.3f}", label, p_human)
|
| 85 |
+
return build_classifier_card(label, p_human, p_ai)
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def run_detector(text: str, conf_threshold: float) -> str:
|
| 89 |
+
"""Validate then run AI-span detection, returning a result card.
|
| 90 |
+
|
| 91 |
+
Args:
|
| 92 |
+
text: User-supplied text.
|
| 93 |
+
conf_threshold: Confidence threshold for keeping a span.
|
| 94 |
+
|
| 95 |
+
Returns:
|
| 96 |
+
HTML for the detector result card.
|
| 97 |
+
|
| 98 |
+
Raises:
|
| 99 |
+
gr.Error: If the text falls outside the configured word bounds.
|
| 100 |
+
"""
|
| 101 |
+
result = validate_text(text)
|
| 102 |
+
if not result.ok:
|
| 103 |
+
raise gr.Error(result.message)
|
| 104 |
+
intervals = detect_intervals(text, conf_threshold)
|
| 105 |
+
logger.info("detector: {} interval(s) at thresh={}", len(intervals), conf_threshold)
|
| 106 |
+
return build_detector_card(text, intervals)
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def build_classifier_tab() -> None:
|
| 110 |
+
"""Build the classifier tab UI and wire its events."""
|
| 111 |
+
gr.HTML(LANG_NOTE)
|
| 112 |
+
text_in = gr.Textbox(
|
| 113 |
+
label="Text",
|
| 114 |
+
placeholder="Paste English or Russian text to classify…",
|
| 115 |
+
lines=8,
|
| 116 |
+
)
|
| 117 |
+
counter = gr.HTML(update_counter(""))
|
| 118 |
+
analyze_btn = gr.Button("Analyze", variant="primary")
|
| 119 |
+
output = gr.HTML()
|
| 120 |
+
gr.Examples(examples=CLASSIFIER_EXAMPLES, inputs=[text_in], label="Examples")
|
| 121 |
+
|
| 122 |
+
text_in.change(update_counter, inputs=[text_in], outputs=[counter])
|
| 123 |
+
analyze_btn.click(run_classifier, inputs=[text_in], outputs=[output])
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def build_detector_tab() -> None:
|
| 127 |
+
"""Build the detector tab UI and wire its events."""
|
| 128 |
+
gr.HTML(LANG_NOTE)
|
| 129 |
+
text_in = gr.Textbox(
|
| 130 |
+
label="Text",
|
| 131 |
+
placeholder="Paste English or Russian text to scan for AI-written spans…",
|
| 132 |
+
lines=8,
|
| 133 |
+
)
|
| 134 |
+
counter = gr.HTML(update_counter(""))
|
| 135 |
+
conf = gr.Slider(
|
| 136 |
+
label="Confidence threshold",
|
| 137 |
+
minimum=0.0,
|
| 138 |
+
maximum=1.0,
|
| 139 |
+
value=CONFIG.default_conf_threshold,
|
| 140 |
+
step=0.05,
|
| 141 |
+
)
|
| 142 |
+
detect_btn = gr.Button("Detect", variant="primary")
|
| 143 |
+
output = gr.HTML()
|
| 144 |
+
gr.Examples(examples=DETECTOR_EXAMPLES, inputs=[text_in], label="Examples")
|
| 145 |
+
|
| 146 |
+
text_in.change(update_counter, inputs=[text_in], outputs=[counter])
|
| 147 |
+
detect_btn.click(run_detector, inputs=[text_in, conf], outputs=[output])
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def build_demo() -> gr.Blocks:
|
| 151 |
+
"""Assemble the full Gradio demo.
|
| 152 |
+
|
| 153 |
+
Returns:
|
| 154 |
+
The configured :class:`gradio.Blocks` application.
|
| 155 |
+
"""
|
| 156 |
+
with gr.Blocks(theme=THEME, css=CSS, title="GigaCheck") as demo:
|
| 157 |
+
gr.HTML(
|
| 158 |
+
'<div id="gc-header"><h1>GigaCheck</h1>'
|
| 159 |
+
"<p>Detect AI-generated text — binary classification and "
|
| 160 |
+
"fine-grained span localization</p></div>"
|
| 161 |
+
)
|
| 162 |
+
with gr.Tabs():
|
| 163 |
+
with gr.Tab("Classifier"):
|
| 164 |
+
gr.Markdown(
|
| 165 |
+
"Binary **human / AI** classifier — "
|
| 166 |
+
"[GigaCheck-Classifier-Multi]"
|
| 167 |
+
"(https://huggingface.co/iitolstykh/GigaCheck-Classifier-Multi)"
|
| 168 |
+
)
|
| 169 |
+
build_classifier_tab()
|
| 170 |
+
with gr.Tab("Detector"):
|
| 171 |
+
gr.Markdown(
|
| 172 |
+
"Fine-grained **AI-span detector** — "
|
| 173 |
+
"[GigaCheck-Detector-Multi]"
|
| 174 |
+
"(https://huggingface.co/iitolstykh/GigaCheck-Detector-Multi)"
|
| 175 |
+
)
|
| 176 |
+
build_detector_tab()
|
| 177 |
+
return demo
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
if __name__ == "__main__":
|
| 181 |
+
build_demo().queue(max_size=50).launch()
|
conftest.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pytest configuration.
|
| 2 |
+
|
| 3 |
+
The presence of this file at the repository root makes pytest insert this
|
| 4 |
+
directory onto ``sys.path``, so the flat demo modules (``validation``,
|
| 5 |
+
``rendering``) can be imported directly from the test suite.
|
| 6 |
+
"""
|
models.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Model loading and GPU-aware inference wrappers for the GigaCheck demo."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import torch
|
| 6 |
+
from loguru import logger
|
| 7 |
+
from transformers import AutoModel
|
| 8 |
+
|
| 9 |
+
from rendering import AiInterval
|
| 10 |
+
from validation import CONFIG
|
| 11 |
+
|
| 12 |
+
try:
|
| 13 |
+
import spaces
|
| 14 |
+
|
| 15 |
+
gpu = spaces.GPU
|
| 16 |
+
HAS_SPACES = True
|
| 17 |
+
except ImportError:
|
| 18 |
+
HAS_SPACES = False
|
| 19 |
+
|
| 20 |
+
def gpu(*args, **kwargs):
|
| 21 |
+
"""No-op stand-in for :func:`spaces.GPU` when ``spaces`` is unavailable.
|
| 22 |
+
|
| 23 |
+
Supports both bare ``@gpu`` and parameterized ``@gpu(duration=...)`` usage.
|
| 24 |
+
"""
|
| 25 |
+
if args and callable(args[0]):
|
| 26 |
+
return args[0]
|
| 27 |
+
|
| 28 |
+
def wrap(fn):
|
| 29 |
+
return fn
|
| 30 |
+
|
| 31 |
+
return wrap
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def select_device() -> str:
|
| 35 |
+
"""Pick the inference device, preferring GPU when it is reachable.
|
| 36 |
+
|
| 37 |
+
On ZeroGPU, CUDA is not visible at import time but is materialized inside the
|
| 38 |
+
decorated function, so the presence of the ``spaces`` package implies a GPU.
|
| 39 |
+
|
| 40 |
+
Returns:
|
| 41 |
+
``"cuda:0"`` when a GPU is (or will be) available, otherwise ``"cpu"``.
|
| 42 |
+
"""
|
| 43 |
+
if torch.cuda.is_available() or HAS_SPACES:
|
| 44 |
+
return "cuda:0"
|
| 45 |
+
return "cpu"
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
DEVICE = select_device()
|
| 49 |
+
logger.info("GigaCheck demo device: {} (spaces={})", DEVICE, HAS_SPACES)
|
| 50 |
+
|
| 51 |
+
logger.info("Loading classifier: {}", CONFIG.classifier_model_id)
|
| 52 |
+
classifier_model = AutoModel.from_pretrained(
|
| 53 |
+
CONFIG.classifier_model_id,
|
| 54 |
+
trust_remote_code=True,
|
| 55 |
+
device_map=DEVICE,
|
| 56 |
+
torch_dtype=torch.bfloat16,
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
logger.info("Loading detector: {}", CONFIG.detector_model_id)
|
| 60 |
+
detector_model = AutoModel.from_pretrained(
|
| 61 |
+
CONFIG.detector_model_id,
|
| 62 |
+
trust_remote_code=True,
|
| 63 |
+
device_map=DEVICE,
|
| 64 |
+
torch_dtype=torch.float32,
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
@gpu(duration=120)
|
| 69 |
+
def classify_text(text: str) -> tuple[str, float, float]:
|
| 70 |
+
"""Classify a text as human-written or AI-generated.
|
| 71 |
+
|
| 72 |
+
Args:
|
| 73 |
+
text: Input text (English or Russian).
|
| 74 |
+
|
| 75 |
+
Returns:
|
| 76 |
+
A tuple ``(label, p_human, p_ai)`` where ``label`` is the raw model
|
| 77 |
+
label and the probabilities are floats in ``[0, 1]``.
|
| 78 |
+
"""
|
| 79 |
+
with torch.inference_mode():
|
| 80 |
+
output = classifier_model([text.replace("\n", " ")])
|
| 81 |
+
# classification_head_probs: [batch, 2] ordered as [p_ai, p_human].
|
| 82 |
+
probs = output.classification_head_probs[0].to(torch.float32).tolist()
|
| 83 |
+
p_ai, p_human = float(probs[0]), float(probs[1])
|
| 84 |
+
label = classifier_model.config.id2label[int(output.pred_label_ids[0])]
|
| 85 |
+
return label, p_human, p_ai
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
@gpu(duration=120)
|
| 89 |
+
def detect_intervals(text: str, conf_threshold: float) -> list[AiInterval]:
|
| 90 |
+
"""Detect character spans likely written by an AI model.
|
| 91 |
+
|
| 92 |
+
Args:
|
| 93 |
+
text: Input text (English or Russian).
|
| 94 |
+
conf_threshold: Confidence threshold for keeping a span.
|
| 95 |
+
|
| 96 |
+
Returns:
|
| 97 |
+
A list of ``(start_char, end_char, score)`` tuples.
|
| 98 |
+
"""
|
| 99 |
+
with torch.inference_mode():
|
| 100 |
+
output = detector_model([text], conf_interval_thresh=conf_threshold)
|
| 101 |
+
raw = output.ai_intervals[0].cpu()
|
| 102 |
+
return [(int(span[0]), int(span[1]), float(span[2])) for span in raw]
|
rendering.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""HTML rendering helpers for classifier and detector results."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import html
|
| 6 |
+
|
| 7 |
+
from validation import CONFIG
|
| 8 |
+
|
| 9 |
+
AiInterval = tuple[int, int, float]
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def prettify_label(label: str) -> str:
|
| 13 |
+
"""Convert a raw model label into a display label.
|
| 14 |
+
|
| 15 |
+
Args:
|
| 16 |
+
label: Raw label from the model config (e.g. ``"ai"`` or ``"human"``).
|
| 17 |
+
|
| 18 |
+
Returns:
|
| 19 |
+
A capitalized, human-friendly label (``"AI"`` or ``"Human"``).
|
| 20 |
+
"""
|
| 21 |
+
mapping = {"ai": "AI", "human": "Human", "mixed": "Mixed"}
|
| 22 |
+
return mapping.get(label.lower(), label.capitalize())
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def build_classifier_card(label: str, p_human: float, p_ai: float) -> str:
|
| 26 |
+
"""Render the classifier result as a labeled split probability bar.
|
| 27 |
+
|
| 28 |
+
Args:
|
| 29 |
+
label: Predicted raw label (``"ai"`` or ``"human"``).
|
| 30 |
+
p_human: Probability that the text is human-written, in ``[0, 1]``.
|
| 31 |
+
p_ai: Probability that the text is AI-generated, in ``[0, 1]``.
|
| 32 |
+
|
| 33 |
+
Returns:
|
| 34 |
+
An HTML string with the predicted label and a green/red split bar.
|
| 35 |
+
"""
|
| 36 |
+
human_pct = round(p_human * 100)
|
| 37 |
+
ai_pct = 100 - human_pct
|
| 38 |
+
verdict = prettify_label(label)
|
| 39 |
+
verdict_color = CONFIG.ai_color if verdict == "AI" else CONFIG.human_color
|
| 40 |
+
return f"""
|
| 41 |
+
<div class="gc-card">
|
| 42 |
+
<div class="gc-verdict">
|
| 43 |
+
Predicted: <span style="color:{verdict_color}">{verdict}</span>
|
| 44 |
+
</div>
|
| 45 |
+
<div class="gc-bar">
|
| 46 |
+
<div class="gc-bar-human" style="width:{human_pct}%"></div>
|
| 47 |
+
<div class="gc-bar-ai" style="width:{ai_pct}%"></div>
|
| 48 |
+
</div>
|
| 49 |
+
<div class="gc-bar-legend">
|
| 50 |
+
<span class="gc-legend-human">Human · {human_pct}%</span>
|
| 51 |
+
<span class="gc-legend-ai">AI · {ai_pct}%</span>
|
| 52 |
+
</div>
|
| 53 |
+
</div>
|
| 54 |
+
"""
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def merge_intervals(intervals: list[AiInterval], text_len: int) -> list[AiInterval]:
|
| 58 |
+
"""Merge AI intervals into non-overlapping segments keeping the max score.
|
| 59 |
+
|
| 60 |
+
Overlapping or touching predictions are flattened so that every character
|
| 61 |
+
is covered at most once, using the highest score among covering intervals.
|
| 62 |
+
|
| 63 |
+
Args:
|
| 64 |
+
intervals: Raw ``(start, end, score)`` predictions.
|
| 65 |
+
text_len: Length of the source text, used to clip the bounds.
|
| 66 |
+
|
| 67 |
+
Returns:
|
| 68 |
+
Sorted, non-overlapping ``(start, end, score)`` segments.
|
| 69 |
+
"""
|
| 70 |
+
clipped = [
|
| 71 |
+
(max(0, start), min(text_len, end), score)
|
| 72 |
+
for start, end, score in intervals
|
| 73 |
+
if min(text_len, end) > max(0, start)
|
| 74 |
+
]
|
| 75 |
+
if not clipped:
|
| 76 |
+
return []
|
| 77 |
+
|
| 78 |
+
boundaries = sorted({b for start, end, _ in clipped for b in (start, end)})
|
| 79 |
+
segments: list[AiInterval] = []
|
| 80 |
+
for left, right in zip(boundaries, boundaries[1:]):
|
| 81 |
+
covering = [s for st, en, s in clipped if st <= left and en >= right]
|
| 82 |
+
if not covering:
|
| 83 |
+
continue
|
| 84 |
+
score = max(covering)
|
| 85 |
+
if segments and segments[-1][1] == left and segments[-1][2] == score:
|
| 86 |
+
prev_start, _, prev_score = segments[-1]
|
| 87 |
+
segments[-1] = (prev_start, right, prev_score)
|
| 88 |
+
else:
|
| 89 |
+
segments.append((left, right, score))
|
| 90 |
+
return segments
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def score_to_alpha(score: float) -> float:
|
| 94 |
+
"""Map a confidence score to a background opacity.
|
| 95 |
+
|
| 96 |
+
Args:
|
| 97 |
+
score: Confidence score in ``[0, 1]``.
|
| 98 |
+
|
| 99 |
+
Returns:
|
| 100 |
+
An opacity in ``[0.15, 1.0]`` so even low scores stay visible.
|
| 101 |
+
"""
|
| 102 |
+
return round(0.15 + 0.85 * max(0.0, min(1.0, score)), 3)
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def build_highlighted_text(text: str, intervals: list[AiInterval]) -> str:
|
| 106 |
+
"""Render text with AI spans highlighted by score-scaled red backgrounds.
|
| 107 |
+
|
| 108 |
+
Args:
|
| 109 |
+
text: The source text exactly as passed to the detector.
|
| 110 |
+
intervals: Raw ``(start, end, score)`` predictions.
|
| 111 |
+
|
| 112 |
+
Returns:
|
| 113 |
+
An HTML string with AI spans wrapped in colored ``<span>`` elements.
|
| 114 |
+
"""
|
| 115 |
+
segments = merge_intervals(intervals, len(text))
|
| 116 |
+
parts: list[str] = []
|
| 117 |
+
cursor = 0
|
| 118 |
+
for start, end, score in segments:
|
| 119 |
+
if start > cursor:
|
| 120 |
+
parts.append(html.escape(text[cursor:start]))
|
| 121 |
+
alpha = score_to_alpha(score)
|
| 122 |
+
chunk = html.escape(text[start:end])
|
| 123 |
+
parts.append(
|
| 124 |
+
f'<span class="gc-ai-span" '
|
| 125 |
+
f'style="background-color: rgba(229, 83, 60, {alpha})" '
|
| 126 |
+
f'title="AI score: {score:.2f}">{chunk}</span>'
|
| 127 |
+
)
|
| 128 |
+
cursor = end
|
| 129 |
+
if cursor < len(text):
|
| 130 |
+
parts.append(html.escape(text[cursor:]))
|
| 131 |
+
return "".join(parts)
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def build_detector_card(text: str, intervals: list[AiInterval]) -> str:
|
| 135 |
+
"""Render the detector result with a header summary and highlighted text.
|
| 136 |
+
|
| 137 |
+
Args:
|
| 138 |
+
text: The source text exactly as passed to the detector.
|
| 139 |
+
intervals: Raw ``(start, end, score)`` predictions.
|
| 140 |
+
|
| 141 |
+
Returns:
|
| 142 |
+
An HTML string containing a summary line and the highlighted text.
|
| 143 |
+
"""
|
| 144 |
+
segments = merge_intervals(intervals, len(text))
|
| 145 |
+
if segments:
|
| 146 |
+
summary = (
|
| 147 |
+
f"{len(segments)} AI-written fragment(s) detected — "
|
| 148 |
+
"darker red means higher confidence."
|
| 149 |
+
)
|
| 150 |
+
summary_class = "gc-summary gc-summary-ai"
|
| 151 |
+
else:
|
| 152 |
+
summary = "No AI-written fragments detected above the threshold."
|
| 153 |
+
summary_class = "gc-summary gc-summary-clean"
|
| 154 |
+
highlighted = build_highlighted_text(text, intervals)
|
| 155 |
+
return f"""
|
| 156 |
+
<div class="gc-card">
|
| 157 |
+
<div class="{summary_class}">{summary}</div>
|
| 158 |
+
<div class="gc-text">{highlighted}</div>
|
| 159 |
+
</div>
|
| 160 |
+
"""
|
requirements.txt
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
gradio==6.16.0
|
| 2 |
+
transformers==4.50.1
|
| 3 |
+
torch==2.8.0
|
| 4 |
+
accelerate
|
| 5 |
+
sentencepiece
|
| 6 |
+
loguru
|
| 7 |
+
pydantic
|
| 8 |
+
huggingface_hub
|
| 9 |
+
git+https://github.com/ai-forever/gigacheck
|
styles.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Gradio theme and CSS for the GigaCheck demo (pastel orange-red)."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import gradio as gr
|
| 6 |
+
|
| 7 |
+
THEME = gr.themes.Soft(
|
| 8 |
+
primary_hue=gr.themes.colors.red,
|
| 9 |
+
secondary_hue=gr.themes.colors.orange,
|
| 10 |
+
neutral_hue=gr.themes.colors.stone,
|
| 11 |
+
font=(gr.themes.GoogleFont("Inter"), "ui-sans-serif", "system-ui", "sans-serif"),
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
CSS = """
|
| 15 |
+
:root {
|
| 16 |
+
--gc-peach: #FFE3D5;
|
| 17 |
+
--gc-coral: #FF8A65;
|
| 18 |
+
--gc-red: #E5533C;
|
| 19 |
+
--gc-deep: #C5402B;
|
| 20 |
+
--gc-green: #2FA66B;
|
| 21 |
+
--gc-ink: #3A2A24;
|
| 22 |
+
}
|
| 23 |
+
.gradio-container { max-width: 980px !important; }
|
| 24 |
+
|
| 25 |
+
#gc-header { text-align: center; margin: 0.4rem 0 1.2rem; }
|
| 26 |
+
#gc-header h1 {
|
| 27 |
+
margin: 0; font-weight: 800; letter-spacing: -0.03em; font-size: 2.2rem;
|
| 28 |
+
background: linear-gradient(110deg, var(--gc-coral) 0%, var(--gc-red) 55%, var(--gc-deep) 100%);
|
| 29 |
+
-webkit-background-clip: text; background-clip: text; color: transparent;
|
| 30 |
+
}
|
| 31 |
+
#gc-header p { margin: 0.45rem 0 0; color: var(--gc-ink); opacity: 0.7; font-size: 1.02rem; }
|
| 32 |
+
|
| 33 |
+
.gc-lang-note {
|
| 34 |
+
border-radius: 12px;
|
| 35 |
+
padding: 10px 14px;
|
| 36 |
+
margin: 0 0 6px;
|
| 37 |
+
font-size: 0.9rem;
|
| 38 |
+
color: var(--gc-deep);
|
| 39 |
+
background: linear-gradient(135deg, rgba(255,227,213,0.85), rgba(255,138,101,0.22));
|
| 40 |
+
border: 1px solid rgba(229,83,60,0.30);
|
| 41 |
+
}
|
| 42 |
+
.gc-lang-note b { font-weight: 700; }
|
| 43 |
+
|
| 44 |
+
.gc-counter { font-size: 0.82rem; opacity: 0.7; margin: 2px 4px 0; }
|
| 45 |
+
.gc-counter.gc-bad { color: var(--gc-red); opacity: 1; font-weight: 600; }
|
| 46 |
+
|
| 47 |
+
.gc-card {
|
| 48 |
+
padding: 18px 20px;
|
| 49 |
+
border-radius: 16px;
|
| 50 |
+
background: #ffffff;
|
| 51 |
+
border: 1px solid rgba(229,83,60,0.22);
|
| 52 |
+
box-shadow: 0 6px 22px rgba(229,83,60,0.08);
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
.gc-verdict { font-size: 1.25rem; font-weight: 700; margin-bottom: 14px; color: var(--gc-ink); }
|
| 56 |
+
|
| 57 |
+
.gc-bar {
|
| 58 |
+
display: flex; width: 100%; height: 26px;
|
| 59 |
+
border-radius: 999px; overflow: hidden;
|
| 60 |
+
background: #f1ece9;
|
| 61 |
+
}
|
| 62 |
+
.gc-bar-human { background: var(--gc-green); height: 100%; transition: width 0.4s ease; }
|
| 63 |
+
.gc-bar-ai { background: var(--gc-red); height: 100%; transition: width 0.4s ease; }
|
| 64 |
+
.gc-bar-legend {
|
| 65 |
+
display: flex; justify-content: space-between;
|
| 66 |
+
margin-top: 8px; font-size: 0.9rem; font-weight: 600;
|
| 67 |
+
}
|
| 68 |
+
.gc-legend-human { color: var(--gc-green); }
|
| 69 |
+
.gc-legend-ai { color: var(--gc-red); }
|
| 70 |
+
|
| 71 |
+
.gc-summary { font-size: 0.95rem; font-weight: 600; margin-bottom: 12px; }
|
| 72 |
+
.gc-summary-ai { color: var(--gc-deep); }
|
| 73 |
+
.gc-summary-clean { color: var(--gc-green); }
|
| 74 |
+
|
| 75 |
+
.gc-text {
|
| 76 |
+
white-space: pre-wrap; word-wrap: break-word; line-height: 1.65;
|
| 77 |
+
font-size: 1.0rem; color: var(--gc-ink);
|
| 78 |
+
}
|
| 79 |
+
.gc-ai-span { border-radius: 4px; padding: 1px 1px; color: #2a160f; }
|
| 80 |
+
|
| 81 |
+
button.primary, .gradio-container .primary {
|
| 82 |
+
background: linear-gradient(135deg, var(--gc-coral), var(--gc-red)) !important;
|
| 83 |
+
border: none !important;
|
| 84 |
+
color: #ffffff !important;
|
| 85 |
+
font-weight: 700 !important;
|
| 86 |
+
letter-spacing: 0.02em;
|
| 87 |
+
}
|
| 88 |
+
button.primary:hover, .gradio-container .primary:hover {
|
| 89 |
+
filter: brightness(1.05) saturate(1.08);
|
| 90 |
+
}
|
| 91 |
+
"""
|
validation.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared configuration and input validation for the GigaCheck demo."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from pydantic import BaseModel
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class DemoConfig(BaseModel):
|
| 9 |
+
"""Static configuration shared across the demo.
|
| 10 |
+
|
| 11 |
+
Attributes:
|
| 12 |
+
classifier_model_id: Hub id of the human/AI classifier model.
|
| 13 |
+
detector_model_id: Hub id of the AI-span detector model.
|
| 14 |
+
min_words: Minimum number of words an input must contain.
|
| 15 |
+
max_words: Maximum number of words an input may contain.
|
| 16 |
+
human_color: Hex color used for the "human" portion of the bar.
|
| 17 |
+
ai_color: Hex color used for the "AI" portion of the bar and span highlights.
|
| 18 |
+
default_conf_threshold: Default confidence threshold for the detector.
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
classifier_model_id: str = "iitolstykh/GigaCheck-Classifier-Multi"
|
| 22 |
+
detector_model_id: str = "iitolstykh/GigaCheck-Detector-Multi"
|
| 23 |
+
min_words: int = 15
|
| 24 |
+
max_words: int = 512
|
| 25 |
+
human_color: str = "#2FA66B"
|
| 26 |
+
ai_color: str = "#E5533C"
|
| 27 |
+
default_conf_threshold: float = 0.5
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
CONFIG = DemoConfig()
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class ValidationResult(BaseModel):
|
| 34 |
+
"""Outcome of validating a piece of input text.
|
| 35 |
+
|
| 36 |
+
Attributes:
|
| 37 |
+
ok: Whether the text satisfies the word-count bounds.
|
| 38 |
+
message: Human-readable explanation when ``ok`` is ``False``.
|
| 39 |
+
word_count: Number of words found in the text.
|
| 40 |
+
"""
|
| 41 |
+
|
| 42 |
+
ok: bool
|
| 43 |
+
message: str
|
| 44 |
+
word_count: int
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def count_words(text: str) -> int:
|
| 48 |
+
"""Count whitespace-separated words in a string.
|
| 49 |
+
|
| 50 |
+
Args:
|
| 51 |
+
text: Arbitrary user input.
|
| 52 |
+
|
| 53 |
+
Returns:
|
| 54 |
+
The number of whitespace-separated tokens.
|
| 55 |
+
"""
|
| 56 |
+
return len(text.split())
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def validate_text(text: str, config: DemoConfig = CONFIG) -> ValidationResult:
|
| 60 |
+
"""Validate that ``text`` falls within the configured word bounds.
|
| 61 |
+
|
| 62 |
+
Args:
|
| 63 |
+
text: User-supplied text to analyze.
|
| 64 |
+
config: Demo configuration providing the word bounds.
|
| 65 |
+
|
| 66 |
+
Returns:
|
| 67 |
+
A :class:`ValidationResult` describing whether the text is acceptable.
|
| 68 |
+
"""
|
| 69 |
+
word_count = count_words(text)
|
| 70 |
+
if word_count < config.min_words:
|
| 71 |
+
return ValidationResult(
|
| 72 |
+
ok=False,
|
| 73 |
+
message=(
|
| 74 |
+
f"Text is too short: {word_count} word(s). "
|
| 75 |
+
f"Please enter at least {config.min_words} words."
|
| 76 |
+
),
|
| 77 |
+
word_count=word_count,
|
| 78 |
+
)
|
| 79 |
+
if word_count > config.max_words:
|
| 80 |
+
return ValidationResult(
|
| 81 |
+
ok=False,
|
| 82 |
+
message=(
|
| 83 |
+
f"Text is too long: {word_count} words. "
|
| 84 |
+
f"Please keep it under {config.max_words} words."
|
| 85 |
+
),
|
| 86 |
+
word_count=word_count,
|
| 87 |
+
)
|
| 88 |
+
return ValidationResult(ok=True, message="", word_count=word_count)
|