iitolstykh commited on
Commit
7c51531
·
1 Parent(s): a949557

initial commit

Browse files
Files changed (8) hide show
  1. .gitignore +47 -0
  2. app.py +181 -0
  3. conftest.py +6 -0
  4. models.py +102 -0
  5. rendering.py +160 -0
  6. requirements.txt +9 -0
  7. styles.py +91 -0
  8. 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)