"""
DetectGPTPro — Gradio front end for AdaDetectGPT.
Runs on plain CPU (HF Spaces "CPU basic" tier — ZeroGPU requires a PRO
account). All detection logic still lives in FineTune/model.py,
feedback.py, and stats.py, unchanged — this file only builds the UI layer.
See streamlit_backup/ (repo root) for the original Streamlit app.
"""
import os
import time
from pathlib import Path
APP_DIR = Path(__file__).parent.resolve()
ACCOUNT_NAME = "mamba413"
# -----------------------------------------------------------------------
# HF Space environment setup — point HF caches at a writable directory.
# (Carried over as-is from the Streamlit app.)
# -----------------------------------------------------------------------
if os.environ.get("SPACE_ID"):
CACHE_DIR = "/tmp/huggingface_cache"
os.makedirs(CACHE_DIR, exist_ok=True)
os.environ["HF_HOME"] = CACHE_DIR
os.environ["TRANSFORMERS_CACHE"] = CACHE_DIR
os.environ["HF_DATASETS_CACHE"] = CACHE_DIR
os.environ["HUGGINGFACE_HUB_CACHE"] = CACHE_DIR
import gradio as gr
from FineTune.model import ComputeStat, ESTIMATE_DOMAIN, SOFTEST_DOMAIN
from feedback import FeedbackManager
from stats import StatsManager
def resolve_device() -> str:
"""Pick an inference device, in priority order:
1. `MODEL_DEVICE` env var, if the user wants to force one.
2. 'cpu' on the Space (this deployment targets HF's free "CPU basic"
tier — ZeroGPU needs a PRO account, so we don't attempt it).
3. 'mps' / 'cpu' for local development on Apple Silicon / everything else.
"""
explicit = os.environ.get("MODEL_DEVICE")
if explicit:
return explicit
if os.environ.get("SPACE_ID"):
return "cpu"
try:
import torch
if torch.backends.mps.is_available():
return "mps"
except Exception:
pass
return "cpu"
# -----------------------------------------------------------------------
# Configuration
# -----------------------------------------------------------------------
MODEL_CONFIG = {
"from_pretrained": "./src/FineTune/ckpt/",
"base_model": "gemma-1b",
"cache_dir": "../cache",
"device": resolve_device(),
}
DOMAINS = [
"General",
"Academia",
"Finance",
"Government",
"Knowledge",
"Legislation",
"Medicine",
"News",
"UserReview",
]
# "Domain of the text" is a two-level hierarchical selector built from two
# cascading gr.Dropdowns, grouped together in one row:
# - Level 1 (domain_mode_dropdown): Manual / Soft Estimate / Estimate.
# - Level 2 (manual_domain_dropdown): the 9 entries in DOMAINS -- only
# meaningful (and only shown) when level 1 is "Manual".
# "Soft Estimate" calibrates against a probability-weighted blend of every
# domain's null distribution (FineTune.model.SOFTEST_DOMAIN); "Estimate"
# calibrates against the single most likely domain (FineTune.model.
# ESTIMATE_DOMAIN). Both route through the pretrained transformer-based
# domain classifier bundled in the checkpoint (FineTune/model.py:
# DomainClassifier).
DOMAIN_MODES = ["Manual", "Soft Estimate", "Estimate"]
DEFAULT_DOMAIN_MODE = "Manual"
DEFAULT_MANUAL_DOMAIN = "General"
DOMAIN_MODE_INFO = {
"Manual": (
"Pick the domain that best matches your text."
),
"Soft Estimate": (
"🤖 The estimation is conducted by a pretrained transformer-based classifier, which "
"predicts a probability over all supported domains; detection is then calibrated "
"against a probability-weighted blend of their null distributions. Useful when the "
"text may span more than one domain."
),
"Estimate": (
"🤖 The estimation is conducted by a pretrained transformer-based classifier, which "
"predicts the single most likely domain for your text automatically."
),
}
FEEDBACK_DATASET_ID = os.environ.get("FEEDBACK_DATASET_ID", f"{ACCOUNT_NAME}/user-feedback")
# -----------------------------------------------------------------------
# Model / manager loading (module-level singletons — loaded once at
# process startup, same lifetime as st.cache_resource gave us before).
# -----------------------------------------------------------------------
def load_model():
print(f"🔄 Loading model on device='{MODEL_CONFIG['device']}'...")
model = ComputeStat.from_pretrained(
MODEL_CONFIG["from_pretrained"],
MODEL_CONFIG["base_model"],
device=MODEL_CONFIG["device"],
cache_dir=MODEL_CONFIG["cache_dir"],
)
model.set_criterion_fn("mean")
return model
try:
model = load_model()
model_load_error = None
except Exception as e: # noqa: BLE001 — surfaced in the UI below
model = None
model_load_error = str(e)
# `ComputeStat.from_pretrained` degrades gracefully if the bundled domain
# classifier fails to load (e.g. an installed `transformers` version too old
# to map the base model to a SequenceClassification head) -- `domain_estimator`
# is simply left as None. Mirror that here: only offer "Soft Estimate" /
# "Estimate" in the UI when there's actually a classifier behind them.
DOMAIN_ESTIMATOR_AVAILABLE = bool(model is not None and model.domain_estimator is not None)
AVAILABLE_DOMAIN_MODES = (
DOMAIN_MODES if DOMAIN_ESTIMATOR_AVAILABLE else [m for m in DOMAIN_MODES if m == "Manual"]
)
feedback_manager = FeedbackManager(
dataset_repo_id=FEEDBACK_DATASET_ID,
hf_token=os.environ.get("HF_TOKEN"),
local_backup=not os.environ.get("SPACE_ID"), # keep local backups off-Space
)
stats_manager = StatsManager(
dataset_repo_id=FEEDBACK_DATASET_ID,
hf_token=os.environ.get("HF_TOKEN"),
local_backup=not os.environ.get("SPACE_ID"),
)
# -----------------------------------------------------------------------
# Inference — isolated in its own function so it's easy to time and to
# swap back to a GPU-decorated version later if this ever moves off CPU
# basic hardware.
# -----------------------------------------------------------------------
def _run_inference(text: str, domain: str):
crit, p_value = model.compute_p_value(text, domain)
if hasattr(crit, "item"):
crit = crit.item()
if hasattr(p_value, "item"):
p_value = p_value.item()
return crit, p_value
def format_conclusion(p_value: float, alpha: float) -> str:
"""Build the conclusion as a framed HTML card (rendered inside gr.Markdown,
which passes raw HTML through) so the verdict stands out instead of
blending into a single paragraph."""
is_flagged = p_value < alpha
verdict = "Text is likely LLM-generated." if is_flagged else \
"Fail to reject hypothesis that text is human-written."
comparison = "less" if is_flagged else "greater"
tone = "conclusion-card--flag" if is_flagged else "conclusion-card--clear"
icon = "🚨" if is_flagged else "✅"
return (
f'
'
f'
{icon} {verdict}
'
f'
based on the observation that '
f'$p$-value {p_value:.3f} is {comparison} than significance level '
f'{alpha:.2f} 📊
'
f'
'
)
def build_interpretation_text(domain_mode: str) -> str:
"""Content of the "📋 Interpretation and Suggestions" accordion.
Includes the guidance for whichever "Domain of the text" mode is
currently selected (DOMAIN_MODE_INFO) — this used to live next to the
dropdown itself as a separate line/tooltip, but that crowded the control
row, so it now lives here instead. Rebuilt on every domain-mode change
(see on_domain_mode_change) so it never goes stale.
"""
domain_note = DOMAIN_MODE_INFO.get(domain_mode, "")
return f"""
- **Interpretation**
- $p$-value: Lower $p$-value (closer to 0) indicates text is **more likely AI-generated**; Higher $p$-value (closer to 1) indicates text is **more likely human-written**.
- Significance Level (α): a threshold set by the user to determine the sensitivity of the detection. Lower α means stricter criteria for claiming the text is AI-generated.
- **Domain of the text** — currently *{domain_mode}*
- {domain_note}
- **Suggestions for better detection**
- Provide longer text inputs for more reliable detection results.
- Select the domain that best matches the content of your text to improve detection accuracy.
- Not sure of the domain? Switch **Domain of the text** to *Soft Estimate* or *Estimate* — the estimation is conducted by a pretrained transformer-based classifier that infers it automatically.
"""
DETECTING_HTML = (
''
'Detecting… this can take a few seconds.'
)
FOOTER_TEXT = (
"This tool is developed for research purposes only. The detection results are not "
"100% accurate and should not be used as the sole basis for any critical decisions. "
"Users are advised to use this tool responsibly and ethically."
)
REFERENCES_INTRO = "If you find this tool useful, please cite:"
REFERENCES_BIBTEX = """@article{zhou2026detecting,
title={Detecting LLM-Generated Text with Performance Guarantees},
author={Zhou, Hongyi and Zhu, Jin and Yang, Ying and Shi, Chengchun},
journal={arXiv preprint arXiv:2601.06586},
year={2026}
}
@inproceedings{zhou2025adadetect,
title={AdaDetectGPT: Adaptive Detection of LLM-Generated Text with Statistical Guarantees},
author={Hongyi Zhou and Jin Zhu and Pingfan Su and Kai Ye and Ying Yang and Shakeel A O B Gavioli-Akilagun and Chengchun Shi},
booktitle={The Thirty-Ninth Annual Conference on Neural Information Processing Systems},
year={2025}
}"""
# -----------------------------------------------------------------------
# Event handlers
# -----------------------------------------------------------------------
def resolve_domain_selection(domain_mode: str, manual_domain: str) -> str:
"""Translate the level-1 mode + (level-2, Manual-only) domain pick into
the concrete domain string `ComputeStat.compute_p_value` expects."""
if domain_mode == "Soft Estimate":
return SOFTEST_DOMAIN
if domain_mode == "Estimate":
return ESTIMATE_DOMAIN
return manual_domain
def on_domain_mode_change(domain_mode: str):
"""Level-1 dropdown change handler: show the level-2 domain dropdown only
in Manual mode, and refresh the "Interpretation and Suggestions"
accordion so its domain-mode guidance matches whichever mode is now
active."""
return (
gr.update(visible=(domain_mode == "Manual")),
build_interpretation_text(domain_mode),
)
DETECT_BTN_IDLE = gr.update(value="🔍 Detect", interactive=True)
DETECT_BTN_BUSY = gr.update(value="⏳ Detecting…", interactive=False)
def run_detection(text: str, domain_mode: str, manual_domain: str, alpha: float):
"""Detect button handler: runs inference and refreshes all result widgets.
Written as a generator (`yield` instead of `return`) so it can push the
UI to the frontend in two steps instead of one:
1. The instant the button is pressed — flip on the spinner + "Detecting…"
button state, before any inference has run.
2. Once `_run_inference` returns (or raises) — turn the spinner back off
and show the result (or let the error surface via `gr.Error`).
That before/after pair is what makes it unambiguous whether detection is
still running, unlike the old staged `gr.Progress()` bar, which jumped
between a few fixed percentages and didn't clearly resolve to "done".
"""
# Everything except the spinner + button is left untouched on this first
# yield — any previous result stays on screen until the new one is ready.
unchanged_results = (gr.update(),) * 7
yield unchanged_results + (gr.update(visible=True), DETECT_BTN_BUSY)
if not text or not text.strip():
yield unchanged_results + (gr.update(visible=False), DETECT_BTN_IDLE)
raise gr.Error("⚠️ Please enter some text before detecting.")
domain = resolve_domain_selection(domain_mode, manual_domain)
start_time = time.time()
try:
crit, p_value = _run_inference(text, domain)
except gr.Error:
yield unchanged_results + (gr.update(visible=False), DETECT_BTN_IDLE)
raise
except Exception as e: # noqa: BLE001 — surfaced to the user via gr.Error
yield unchanged_results + (gr.update(visible=False), DETECT_BTN_IDLE)
raise gr.Error(f"Detection failed: {e}")
elapsed_time = time.time() - start_time
stats_manager.increment_detection()
detection_state = {
"text": text,
"domain": domain,
"statistics": crit,
"p_value": p_value,
"elapsed_time": elapsed_time,
"feedback_given": False,
}
yield (
gr.update(value=format_conclusion(p_value, alpha), visible=True),
gr.update(visible=True), # interpretation accordion
gr.update(value=f"⏱️ Processing time: {elapsed_time:.2f} seconds", visible=True),
gr.update(visible=True), # feedback section (label + buttons) — first reveal happens here
gr.update(visible=True), # feedback buttons row (re-shown in case a prior run hid it)
gr.update(visible=False), # feedback thanks message
detection_state,
gr.update(visible=False), # detecting indicator — inference is done
DETECT_BTN_IDLE,
)
def submit_feedback(feedback_type: str, detection_state: dict | None):
"""Shared handler for the Expected / Unexpected feedback buttons."""
if not detection_state or detection_state.get("feedback_given"):
return gr.update(), gr.update(), detection_state
try:
success, message = feedback_manager.save_feedback(
detection_state["text"],
detection_state["domain"],
detection_state["statistics"],
detection_state["p_value"],
feedback_type,
)
except Exception as e: # noqa: BLE001
raise gr.Error(f"Failed to save feedback: {e}")
if not success:
raise gr.Error(f"Failed to save feedback: {message}")
detection_state["feedback_given"] = True
thanks = "✅ Thank you for your feedback!" if feedback_type == "expected" \
else "📝 Feedback recorded! This will help us improve."
gr.Info(thanks)
return gr.update(visible=False), gr.update(value=thanks, visible=True), detection_state
def on_load():
# Visit count is still tracked (StatsManager persists it alongside the
# detection count) but is no longer surfaced in the UI, so this handler
# has no outputs.
stats_manager.increment_visit()
# -----------------------------------------------------------------------
# Styling — ported from the Streamlit app's injected CSS.
# -----------------------------------------------------------------------
CUSTOM_CSS = """
#input-text textarea {
background-color: #f8fafc !important;
border: 1px solid #e5e7eb !important;
color: #111827 !important;
}
/* Tighter horizontal spacing between the three boxes in the configuration
row (domain group / alpha slider / Detect button) — Gradio's default Row
gap reads as loose once all three are stretched to the same height. */
#controls-row {
gap: 0.6rem !important;
}
/* #detect-btn's height is set to match the rendered height of its sibling
boxes (the domain group and the alpha slider — each a label line plus a
control) in this exact row. Gradio's Row(equal_height=True) (set in
build_interface) stretches the *wrapper* around the button to match the
tallest sibling, but the