""" 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