Jin Zhu commited on
Commit
a71717d
·
1 Parent(s): 0b11848

update code for website

Browse files
Dockerfile CHANGED
@@ -17,10 +17,10 @@ RUN pip3 install --upgrade pip
17
 
18
  RUN pip3 install -r requirements.txt
19
 
20
- EXPOSE 8501
 
 
21
 
22
- HEALTHCHECK CMD curl --fail http://localhost:8501/_stcore/health
23
 
24
- # WORKDIR /app/src
25
- # ENTRYPOINT ["streamlit", "run", "app.py", "--server.port=8501", "--server.address=0.0.0.0"]
26
- ENTRYPOINT ["streamlit", "run", "src/app.py", "--server.port=8501", "--server.address=0.0.0.0"]
 
17
 
18
  RUN pip3 install -r requirements.txt
19
 
20
+ # Note: HF Spaces uses `sdk: gradio` (see README.md) and ignores this Dockerfile —
21
+ # it's kept only for local/dev `docker build && docker run` testing.
22
+ EXPOSE 7860
23
 
24
+ HEALTHCHECK CMD curl --fail http://localhost:7860/ || exit 1
25
 
26
+ ENTRYPOINT ["python3", "src/app.py"]
 
 
README.md CHANGED
@@ -2,10 +2,12 @@
2
  title: StatDetectLLM — Detecting AI-Generated Text with Statistical Guarantees
3
  colorFrom: blue
4
  colorTo: pink
5
- sdk: docker
6
- app_port: 8501
 
7
  tags:
8
- - streamlit
 
9
  pinned: true
10
  license: apache-2.0
11
  emoji: 🚀
 
2
  title: StatDetectLLM — Detecting AI-Generated Text with Statistical Guarantees
3
  colorFrom: blue
4
  colorTo: pink
5
+ sdk: gradio
6
+ sdk_version: 5.31.0
7
+ app_file: src/app.py
8
  tags:
9
+ - gradio
10
+ - zero-gpu
11
  pinned: true
12
  license: apache-2.0
13
  emoji: 🚀
requirements.txt CHANGED
@@ -1,6 +1,6 @@
1
  # requirements.txt
2
- altair
3
- streamlit
4
  pandas==2.3.1
5
  torch==2.8.0
6
  numpy==2.1.3
@@ -8,4 +8,4 @@ transformers==4.55.2
8
  peft==0.17.1
9
  tqdm
10
  scikit-learn
11
- huggingface_hub
 
1
  # requirements.txt
2
+ gradio==5.31.0
3
+ spaces>=0.30.0
4
  pandas==2.3.1
5
  torch==2.8.0
6
  numpy==2.1.3
 
8
  peft==0.17.1
9
  tqdm
10
  scikit-learn
11
+ huggingface_hub
src/FineTune/model.py CHANGED
@@ -11,7 +11,7 @@ def calculate_MMD_loss(human_crit, sample_crit):
11
  mmd_loss = human_crit.mean() - sample_crit.mean()
12
  return mmd_loss
13
 
14
- def from_pretrained(cls, model_name, kwargs, cache_dir):
15
  # use local model if it exists
16
  if "/" in model_name:
17
  local_path = os.path.join(cache_dir, model_name.split("/")[1])
@@ -19,8 +19,18 @@ def from_pretrained(cls, model_name, kwargs, cache_dir):
19
  local_path = os.path.join(cache_dir, model_name)
20
 
21
  if os.path.exists(local_path):
22
- return cls.from_pretrained(local_path, **kwargs)
23
- return cls.from_pretrained(model_name, **kwargs, cache_dir=cache_dir, device_map='auto')
 
 
 
 
 
 
 
 
 
 
24
 
25
  model_fullnames = {
26
  'gemma-1b': 'google/gemma-3-1b-pt',
@@ -76,7 +86,7 @@ class ComputeStat(nn.Module):
76
  model_kwargs.update(dict(torch_dtype=torch.float16))
77
  if torch.__version__ >= '2.0.0' and 'gemma' in model_name:
78
  model_kwargs.update({'attn_implementation': 'sdpa'})
79
- model = from_pretrained(AutoModelForCausalLM, model_fullname, model_kwargs, cache_dir)
80
  print(f'Moving model to {device}...', end='', flush=True)
81
  start = time.time()
82
  model.to(device)
@@ -181,10 +191,14 @@ class ComputeStat(nn.Module):
181
  # 2. 加载 scoring_model
182
  scoring_dir = os.path.join(load_directory, "scoring_model")
183
  model.scoring_model = AutoPeftModelForCausalLM.from_pretrained(
184
- scoring_dir,
185
- device_map="auto",
186
- low_cpu_mem_usage=True,
187
- use_safetensors=True
 
 
 
 
188
  )
189
 
190
  # 3. 加载所有 null_distr
 
11
  mmd_loss = human_crit.mean() - sample_crit.mean()
12
  return mmd_loss
13
 
14
+ def from_pretrained(cls, model_name, kwargs, cache_dir, device=None):
15
  # use local model if it exists
16
  if "/" in model_name:
17
  local_path = os.path.join(cache_dir, model_name.split("/")[1])
 
19
  local_path = os.path.join(cache_dir, model_name)
20
 
21
  if os.path.exists(local_path):
22
+ return cls.from_pretrained(local_path, **kwargs, trust_remote_code=True)
23
+
24
+ remote_kwargs = dict(kwargs, cache_dir=cache_dir, trust_remote_code=True)
25
+ if device is not None:
26
+ # Pin the whole model to a single device instead of device_map='auto'.
27
+ # 'auto' lets accelerate split the model across GPU/CPU/disk when
28
+ # memory is tight at load time, which then makes a later `.to(device)`
29
+ # raise "You can't move a model that has some modules offloaded to
30
+ # cpu or disk." Forcing everything onto one device up front avoids
31
+ # that split entirely.
32
+ remote_kwargs["device_map"] = {"": device}
33
+ return cls.from_pretrained(model_name, **remote_kwargs)
34
 
35
  model_fullnames = {
36
  'gemma-1b': 'google/gemma-3-1b-pt',
 
86
  model_kwargs.update(dict(torch_dtype=torch.float16))
87
  if torch.__version__ >= '2.0.0' and 'gemma' in model_name:
88
  model_kwargs.update({'attn_implementation': 'sdpa'})
89
+ model = from_pretrained(AutoModelForCausalLM, model_fullname, model_kwargs, cache_dir, device=device)
90
  print(f'Moving model to {device}...', end='', flush=True)
91
  start = time.time()
92
  model.to(device)
 
191
  # 2. 加载 scoring_model
192
  scoring_dir = os.path.join(load_directory, "scoring_model")
193
  model.scoring_model = AutoPeftModelForCausalLM.from_pretrained(
194
+ scoring_dir,
195
+ # Same fix as `from_pretrained()` above: pin to model.device
196
+ # instead of 'auto' so this adapter checkpoint can't end up
197
+ # split across devices from the reference model it sits next to.
198
+ device_map={"": model.device},
199
+ low_cpu_mem_usage=True,
200
+ use_safetensors=True,
201
+ trust_remote_code=True,
202
  )
203
 
204
  # 3. 加载所有 null_distr
src/FineTune/model.py.bak ADDED
@@ -0,0 +1,304 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ from torch import nn
3
+ from peft import get_peft_model, LoraConfig, TaskType, AutoPeftModelForCausalLM
4
+ from transformers import AutoModelForCausalLM, AutoTokenizer
5
+ import time
6
+ import json
7
+
8
+ import os
9
+
10
+ def calculate_MMD_loss(human_crit, sample_crit):
11
+ mmd_loss = human_crit.mean() - sample_crit.mean()
12
+ return mmd_loss
13
+
14
+ def from_pretrained(cls, model_name, kwargs, cache_dir):
15
+ # use local model if it exists
16
+ if "/" in model_name:
17
+ local_path = os.path.join(cache_dir, model_name.split("/")[1])
18
+ else:
19
+ local_path = os.path.join(cache_dir, model_name)
20
+
21
+ if os.path.exists(local_path):
22
+ return cls.from_pretrained(local_path, **kwargs)
23
+ return cls.from_pretrained(model_name, **kwargs, cache_dir=cache_dir, device_map='auto')
24
+
25
+ model_fullnames = {
26
+ 'gemma-1b': 'google/gemma-3-1b-pt',
27
+ }
28
+ float16_models = []
29
+
30
+ def get_model_fullname(model_name):
31
+ return model_fullnames[model_name] if model_name in model_fullnames else model_name
32
+
33
+ def load_tokenizer(model_name, for_dataset, cache_dir):
34
+ model_fullname = get_model_fullname(model_name)
35
+ optional_tok_kwargs = {}
36
+ if for_dataset in ['pubmed']:
37
+ optional_tok_kwargs['padding_side'] = 'left'
38
+ else:
39
+ optional_tok_kwargs['padding_side'] = 'right'
40
+ base_tokenizer = from_pretrained(AutoTokenizer, model_fullname, optional_tok_kwargs, cache_dir=cache_dir)
41
+ if base_tokenizer.pad_token_id is None:
42
+ base_tokenizer.pad_token_id = base_tokenizer.eos_token_id
43
+ if '13b' in model_fullname:
44
+ base_tokenizer.pad_token_id = 0
45
+ return base_tokenizer
46
+
47
+ def get_sampling_discrepancy_analytic(logits_ref, logits_score, labels):
48
+ if logits_ref.size(-1) != logits_score.size(-1):
49
+ vocab_size = min(logits_ref.size(-1), logits_score.size(-1))
50
+ logits_ref = logits_ref[:, :, :vocab_size]
51
+ logits_score = logits_score[:, :, :vocab_size]
52
+
53
+ labels = labels.unsqueeze(-1) if labels.ndim == logits_score.ndim - 1 else labels
54
+ lprobs_score = torch.log_softmax(logits_score, dim=-1)
55
+ probs_ref = torch.softmax(logits_ref, dim=-1)
56
+
57
+ log_likelihood = lprobs_score.gather(dim=-1, index=labels).squeeze(-1)
58
+ mean_ref = (probs_ref * lprobs_score).sum(dim=-1)
59
+ var_ref = (probs_ref * torch.square(lprobs_score)).sum(dim=-1) - torch.square(mean_ref)
60
+ discrepancy = (log_likelihood.sum(dim=-1) - mean_ref.sum(dim=-1)) / var_ref.sum(dim=-1).clamp_min(0.0001).sqrt()
61
+
62
+ return discrepancy, log_likelihood.sum(dim=-1)
63
+
64
+ class ComputeStat(nn.Module):
65
+ def __init__(self, model_name, dataset='xsum', device='cuda', cache_dir='./models'):
66
+ super().__init__()
67
+ self.device = device
68
+ self.reference_model_name = get_model_fullname(model_name)
69
+ self.scoring_model_name = get_model_fullname(model_name)
70
+
71
+ def load_model(model_name, device, cache_dir):
72
+ model_fullname = get_model_fullname(model_name)
73
+ print(f'Loading model {model_fullname}...')
74
+ model_kwargs = {}
75
+ if model_name in float16_models:
76
+ model_kwargs.update(dict(torch_dtype=torch.float16))
77
+ if torch.__version__ >= '2.0.0' and 'gemma' in model_name:
78
+ model_kwargs.update({'attn_implementation': 'sdpa'})
79
+ model = from_pretrained(AutoModelForCausalLM, model_fullname, model_kwargs, cache_dir)
80
+ print(f'Moving model to {device}...', end='', flush=True)
81
+ start = time.time()
82
+ model.to(device)
83
+ print(f'DONE ({time.time() - start:.2f}s)')
84
+ return model
85
+
86
+ # load scoring model
87
+ self.scoring_tokenizer = load_tokenizer(model_name, dataset, cache_dir)
88
+ scoring_model = load_model(model_name, device, cache_dir)
89
+ if model_name in ['gemma-1b']:
90
+ self.peft_config = LoraConfig(
91
+ task_type=TaskType.CAUSAL_LM,
92
+ inference_mode=False,
93
+ r=4,
94
+ lora_alpha=16,
95
+ lora_dropout=0.05,
96
+ target_modules=["q_proj", "k_proj", "v_proj", "o_proj"],
97
+ )
98
+ else:
99
+ self.peft_config = LoraConfig(
100
+ task_type=TaskType.CAUSAL_LM,
101
+ inference_mode=False,
102
+ r=8,
103
+ lora_alpha=32,
104
+ lora_dropout=0.1,
105
+ )
106
+ self.scoring_model = get_peft_model(scoring_model, self.peft_config)
107
+
108
+ # load sampling model
109
+ self.reference_tokenizer = load_tokenizer(model_name, dataset, cache_dir)
110
+ reference_model = load_model(model_name, device, cache_dir)
111
+ self.reference_model = reference_model
112
+ self.reference_model.eval()
113
+ for p in self.reference_model.parameters():
114
+ p.requires_grad = False
115
+
116
+ total = sum(p.numel() for p in self.scoring_model.parameters())
117
+ trainable = sum(p.numel() for p in self.scoring_model.parameters() if p.requires_grad)
118
+ print(f"Trainable / total (parameters): {trainable}/{total}={trainable/total}")
119
+
120
+ def set_criterion_fn(self, criterion_fn):
121
+ if criterion_fn == "mean":
122
+ self.criterion = 'mean'
123
+ self.criterion_fn = get_sampling_discrepancy_analytic
124
+ else:
125
+ raise ValueError(f"Unknown criterion function: {criterion_fn}")
126
+
127
+ def print_gradient_requirement(self):
128
+ for name, param in self.named_parameters():
129
+ gradient_requirement = 'Requires Grad' if param.requires_grad else 'Does not require grad'
130
+ color_code = '\033[92m' if param.requires_grad else '\033[91m' # Green for requires grad, red for does not require grad
131
+ reset_color = '\033[0m' # Reset color after printing
132
+ print(f"{name}: {color_code}{gradient_requirement}{reset_color}")
133
+
134
+ def register_no_grad(self, module_names):
135
+ for name, param in self.named_parameters():
136
+ for selected_module in module_names:
137
+ # print(selected_module, name)
138
+ if selected_module in name:
139
+ param.requires_grad = False
140
+
141
+ def save_pretrained(self, save_directory: str, save_null_distr_only=False):
142
+ """
143
+ Save the scoring model (with LoRA adapter) and all null_distr buffers in Hugging Face format.
144
+ """
145
+ os.makedirs(save_directory, exist_ok=True)
146
+
147
+ # 1. 保存 scoring_model (LoRA adapter + 基础模型)
148
+ if not save_null_distr_only:
149
+ scoring_dir = os.path.join(save_directory, "scoring_model")
150
+ self.scoring_model.save_pretrained(scoring_dir, safe_serialization=True)
151
+
152
+ # 2. 保存所有 null_distr_* buffers
153
+ null_distrs = {}
154
+ for buffer_name, buffer_value in self.named_buffers():
155
+ if buffer_name.startswith("null_distr_"):
156
+ domain = buffer_name.replace("null_distr_", "")
157
+ null_distrs[domain] = buffer_value.detach().cpu()
158
+
159
+ if null_distrs:
160
+ torch.save(null_distrs, os.path.join(save_directory, "null_distrs.pt"))
161
+ print(f"✅ Saved {len(null_distrs)} null distributions: {list(null_distrs.keys())}")
162
+
163
+ # 3. 保存配置信息(包括domain列表)
164
+ config = {
165
+ "domains": list(null_distrs.keys()),
166
+ "criterion": getattr(self, "criterion", None),
167
+ }
168
+ with open(os.path.join(save_directory, "config.json"), "w") as f:
169
+ json.dump(config, f)
170
+
171
+ print(f"✅ Model saved to {save_directory}")
172
+
173
+ @classmethod
174
+ def from_pretrained(cls, load_directory: str, *args, **kwargs):
175
+ """
176
+ Load the scoring model, reference model, and all null_distr buffers.
177
+ """
178
+ # 1. 初始化类
179
+ model = cls(*args, **kwargs)
180
+
181
+ # 2. 加载 scoring_model
182
+ scoring_dir = os.path.join(load_directory, "scoring_model")
183
+ model.scoring_model = AutoPeftModelForCausalLM.from_pretrained(
184
+ scoring_dir,
185
+ device_map="auto",
186
+ low_cpu_mem_usage=True,
187
+ use_safetensors=True
188
+ )
189
+
190
+ # 3. 加载所有 null_distr
191
+ null_distrs_path = os.path.join(load_directory, "null_distrs.pt")
192
+ if os.path.exists(null_distrs_path):
193
+ null_distrs = torch.load(null_distrs_path, map_location="cpu")
194
+ for domain, null_distr in null_distrs.items():
195
+ model.set_null_distr(null_distr, domain)
196
+ print(f"✅ Restored {len(null_distrs)} null distributions: {list(null_distrs.keys())}")
197
+
198
+ # 4. 加载配置信息
199
+ config_path = os.path.join(load_directory, "config.json")
200
+ if os.path.exists(config_path):
201
+ with open(config_path, "r") as f:
202
+ config = json.load(f)
203
+ if "criterion" in config and config["criterion"] is not None:
204
+ model.criterion = config["criterion"]
205
+ print(f"✅ Loaded config: {config}")
206
+
207
+ print(f"✅ Model loaded from {load_directory}")
208
+ return model
209
+
210
+ def compute_stats(self, tokenized=None, labels=[""], training_module=False):
211
+ if training_module:
212
+ logits_score = self.scoring_model(tokenized.input_ids, attention_mask=tokenized.attention_mask).logits[:,:-1,:]
213
+ logits_ref = self.reference_model(tokenized.input_ids, attention_mask=tokenized.attention_mask).logits[:,:-1,:]
214
+ crit, SPO_input = self.criterion_fn(logits_ref, logits_score, labels)
215
+ else:
216
+ with torch.no_grad(): # get reference
217
+ logits_score = self.scoring_model(tokenized.input_ids, attention_mask=tokenized.attention_mask).logits[:,:-1,:] # shape: [bsz, sentence_len, dim]
218
+ logits_ref = self.reference_model(tokenized.input_ids, attention_mask=tokenized.attention_mask).logits[:,:-1,:]
219
+ crit, SPO_input = self.criterion_fn(logits_ref, logits_score, labels)
220
+ return crit, SPO_input, logits_score
221
+
222
+ def forward(self, text, training_module=True):
223
+ original_text = text[0]
224
+ sampled_text = text[1]
225
+
226
+ tokenized = self.scoring_tokenizer(original_text, return_tensors="pt", padding=True, return_token_type_ids=False).to(self.device)
227
+ labels = tokenized.input_ids[:, 1:]
228
+ train_original_crit, _, _ = self.compute_stats(tokenized, labels, training_module=training_module)
229
+
230
+ tokenized = self.scoring_tokenizer(sampled_text, return_tensors="pt", padding=True, return_token_type_ids=False).to(self.device)
231
+ labels = tokenized.input_ids[:, 1:]
232
+ train_sampled_crit, _, _ = self.compute_stats(tokenized, labels, training_module=training_module)
233
+
234
+ MMDloss = calculate_MMD_loss(train_original_crit, train_sampled_crit)
235
+ output = dict(crit=[train_original_crit.detach(), train_original_crit, train_sampled_crit.detach(), train_sampled_crit], loss=MMDloss)
236
+ return output
237
+
238
+ def set_null_distr(self, null_distr: torch.Tensor, domain: str):
239
+ """
240
+ Set the null distribution tensor safely.
241
+ """
242
+ distr_name = f"null_distr_{domain}"
243
+ self.register_buffer(distr_name, torch.empty(0))
244
+
245
+ if not isinstance(null_distr, torch.Tensor):
246
+ null_distr = torch.tensor(null_distr)
247
+
248
+ # detach + clone + 移到正确设备
249
+ null_distr = null_distr.detach().clone().to(self.device)
250
+
251
+ # 直接覆盖 buffer,避免 delattr 带来的问题
252
+ self._buffers[distr_name] = null_distr
253
+ print(f"✅ Null distribution on {domain} with shape: {self._buffers[distr_name].shape} with mean {self._buffers[distr_name].mean():.4f} and std {self._buffers[distr_name].std():.4f}")
254
+
255
+ def compute_p_value(self, text, domain: str):
256
+ """
257
+ Compute p-value for given text using the null distribution of specified domain.
258
+
259
+ Args:
260
+ text: Input text to compute score for
261
+ domain: Domain name to use for null distribution
262
+ """
263
+ tokenized = self.scoring_tokenizer(
264
+ text,
265
+ return_tensors="pt",
266
+ padding=True,
267
+ return_token_type_ids=False
268
+ ).to(self.device)
269
+ labels = tokenized.input_ids[:, 1:]
270
+
271
+ with torch.inference_mode():
272
+ crit, _, _ = self.compute_stats(tokenized, labels, training_module=False)
273
+
274
+ # 获取对应domain的null distribution
275
+ distr_name = f"null_distr_{domain}"
276
+ if not hasattr(self, distr_name):
277
+ raise ValueError(
278
+ f"No null distribution found for domain '{domain}'. "
279
+ f"Available domains: {self.get_available_domains()}"
280
+ )
281
+ null_distr = getattr(self, distr_name)
282
+ p_value = self.empirical_p_value(crit, null_distr)
283
+
284
+ return crit, p_value
285
+
286
+ def empirical_p_value(self, crit: torch.Tensor, null_distr: torch.Tensor):
287
+ # Compute p-value: (count + 1) / (total + 1)
288
+ total = null_distr.numel()
289
+ # count = (null_distr >= crit.unsqueeze(-1)).float().sum() # slow computation
290
+ count = total - torch.searchsorted(null_distr, crit, right=False)[0]
291
+ p_value = (count + 1.0) / (total + 1.0)
292
+ # print(f"p_value (slow): {p_value} & p_value (fast): {(count + 1) / (total + 1)}", )
293
+ return p_value
294
+
295
+ def get_available_domains(self):
296
+ """
297
+ Get list of all available domains with null distributions.
298
+ """
299
+ domains = []
300
+ for buffer_name in self._buffers.keys():
301
+ if buffer_name.startswith("null_distr_"):
302
+ domain = buffer_name.replace("null_distr_", "")
303
+ domains.append(domain)
304
+ return domains
src/app.py CHANGED
@@ -1,545 +1,478 @@
 
 
 
 
 
 
 
 
 
 
 
1
  import os
 
2
  from pathlib import Path
3
 
4
- # -----------------
5
- # Get the directory where app.py is located
6
- # -----------------
7
  APP_DIR = Path(__file__).parent.resolve()
8
-
9
- account_name = 'mamba413'
10
-
11
- # -----------------
12
- # Fix Streamlit Permission Issues
13
- # -----------------
14
- # 在 HF Space 中,将 Streamlit 配置目录设置到可写位置
15
- if os.environ.get('SPACE_ID'):
16
- os.environ['STREAMLIT_SERVER_FILE_WATCHER_TYPE'] = 'none'
17
- os.environ['STREAMLIT_BROWSER_GATHER_USAGE_STATS'] = 'false'
18
- os.environ['STREAMLIT_SERVER_ENABLE_CORS'] = 'false'
19
-
20
- # 设置 HuggingFace 缓存到可写目录
21
- CACHE_DIR = '/tmp/huggingface_cache'
22
  os.makedirs(CACHE_DIR, exist_ok=True)
 
 
 
 
23
 
24
- os.environ['HF_HOME'] = CACHE_DIR
25
- os.environ['TRANSFORMERS_CACHE'] = CACHE_DIR
26
- os.environ['HF_DATASETS_CACHE'] = CACHE_DIR
27
- os.environ['HUGGINGFACE_HUB_CACHE'] = CACHE_DIR
28
-
29
- # 设置可写的配置目录
30
- streamlit_dir = Path('/tmp/.streamlit')
31
- streamlit_dir.mkdir(exist_ok=True, parents=True)
32
- # os.environ['STREAMLIT_HOME'] = '/tmp/.streamlit'
33
-
34
 
35
- import streamlit as st
36
  from FineTune.model import ComputeStat
37
- import time
 
38
 
39
- st.markdown(
40
- """
41
- <style>
42
- /* Text area & text input */
43
- textarea, input[type="text"] {
44
- background-color: #f8fafc !important;
45
- border: 1px solid #e5e7eb !important;
46
- color: #111827 !important;
47
- }
48
 
49
- textarea::placeholder {
50
- color: #9ca3af !important;
51
- }
52
 
53
- /* Selectbox */
54
- div[data-testid="stSelectbox"] > div {
55
- background-color: #f8fafc !important;
56
- border: 1px solid #e5e7eb !important;
57
- }
58
- </style>
59
- """,
60
- unsafe_allow_html=True
61
- )
62
 
63
- st.markdown(
64
- """
65
- <style>
66
- /* Detect button */
67
- div.stButton > button[kind="primary"] {
68
- background-color: #fdae6b;
69
- border: white;
70
- color: black;
71
- font-weight: 600;
72
- height: 4.3rem;
73
-
74
- font-size: 1.1rem;
75
-
76
- display: flex;
77
- align-items: center;
78
- justify-content: center;
79
- gap: 0.55rem;
80
- }
81
 
82
- /* Icon inside Detect button */
83
- div.stButton > button[kind="primary"] span {
84
- font-size: 1.25rem;
85
- line-height: 1;
86
- }
87
 
88
- div.stButton > button[kind="primary"]:hover {
89
- background-color: #fd8d3c;
90
- border-color: white;
91
- }
92
 
93
- div.stButton > button[kind="primary"]:active {
94
- background-color: #fd8d3c;
95
- border-color: white;
96
- }
97
- </style>
98
- """,
99
- unsafe_allow_html=True
100
- )
101
 
102
- # -----------------
103
- # Page Configuration
104
- # -----------------
105
- st.set_page_config(
106
- page_title="DetectGPTPro",
107
- page_icon="🕵️",
108
- )
109
-
110
- # -----------------
111
- # Model Loading (Cached)
112
- # -----------------
113
- @st.cache_resource
114
- def load_model(from_pretrained, base_model, cache_dir, device):
115
- """
116
- Load and cache the model to avoid reloading on every user interaction.
117
- This function runs only once when the app starts or when parameters change.
118
  """
119
- # is_hf_space = os.environ.get('SPACE_ID') is not None
120
- is_hf_space = False
121
- if is_hf_space:
122
- cache_dir = '/tmp/huggingface_cache'
123
- os.makedirs(cache_dir, exist_ok=True)
124
-
125
- device = 'cpu'
126
- print("Using **CPU** now!")
127
-
128
- # 获取 HF Token(用于访问 gated 模型)
129
- hf_token = os.environ.get('HF_TOKEN', None)
130
- if hf_token:
131
- # 也可以用 login 方式
132
- try:
133
- from huggingface_hub import login
134
- login(token=hf_token)
135
- print("✅ Successfully authenticated with HF token")
136
- except Exception as e:
137
- print(f"⚠️ HF login warning: {e}")
138
-
139
- # 🔥 新增:从 HF Hub 下载模型
140
- # 检查是否是 HF Hub 路径(格式:username/repo-name)
141
- is_hf_hub = '/' in from_pretrained and not from_pretrained.startswith('.')
142
- if is_hf_hub:
143
- from huggingface_hub import snapshot_download
144
- print(f"📥 Downloading model from HuggingFace Hub: {from_pretrained}")
145
- try:
146
- # 下载整个仓库到本地
147
- local_model_path = snapshot_download(
148
- repo_id=from_pretrained,
149
- cache_dir=cache_dir,
150
- token=hf_token,
151
- repo_type="model"
152
- )
153
- print(f"✅ Model downloaded to: {local_model_path}")
154
- # 使用下载后的本地路径
155
- from_pretrained = local_model_path
156
- except Exception as e:
157
- print(f"❌ Failed to download model: {e}")
158
- raise
159
- else:
160
- cache_dir = cache_dir
161
-
162
- with st.spinner("🔄 Loading model... This may take a moment on first launch."):
163
- model = ComputeStat.from_pretrained(
164
- from_pretrained,
165
- base_model,
166
- device=device,
167
- cache_dir=cache_dir
168
- )
169
- model.set_criterion_fn('mean')
 
 
 
 
 
170
  return model
171
 
172
- # -----------------
173
- # Result Feedback Module Import
174
- # -----------------
175
- from feedback import FeedbackManager
176
- from stats import StatsManager
177
 
178
- # Initialize Feedback Manager with HF dataset
179
- # make sure HF_TOKEN is set to visit private repository
180
- FEEDBACK_DATASET_ID = os.environ.get('FEEDBACK_DATASET_ID', f'{account_name}/user-feedback')
 
 
 
 
181
  feedback_manager = FeedbackManager(
182
  dataset_repo_id=FEEDBACK_DATASET_ID,
183
- hf_token=os.environ.get('HF_TOKEN'),
184
- local_backup=False if os.environ.get('SPACE_ID') else True # 保留本地备份
 
 
 
 
 
 
185
  )
186
 
187
- @st.cache_resource
188
- def get_stats_manager():
189
- return StatsManager(
190
- dataset_repo_id=FEEDBACK_DATASET_ID,
191
- hf_token=os.environ.get('HF_TOKEN'),
192
- local_backup=False if os.environ.get('SPACE_ID') else True,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
193
  )
194
 
195
- stats_manager = get_stats_manager()
196
 
197
- # -----------------
198
- # Configuration
199
- # -----------------
200
- MODEL_CONFIG = {
201
- 'from_pretrained': './src/FineTune/ckpt/',
202
- 'base_model': 'gemma-1b',
203
- 'cache_dir': '../cache',
204
- 'device': 'cpu' if os.environ.get('SPACE_ID') else 'mps',
205
- # 'device': 'cuda',
 
 
 
 
 
 
 
 
 
 
 
 
 
206
  }
207
 
208
- DOMAINS = [
209
- "General",
210
- "Academia",
211
- "Finance",
212
- "Government",
213
- "Knowledge",
214
- "Legislation",
215
- "Medicine",
216
- "News",
217
- "UserReview"
218
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
219
 
220
- # Load model once at startup
221
- try:
222
- model = load_model(
223
- MODEL_CONFIG['from_pretrained'],
224
- MODEL_CONFIG['base_model'],
225
- MODEL_CONFIG['cache_dir'],
226
- MODEL_CONFIG['device']
227
  )
228
- model_loaded = True
229
- except Exception as e:
230
- model_loaded = False
231
- error_message = str(e)
232
-
233
- # =========== 🆕 session_state ===========
234
- if 'last_detection' not in st.session_state:
235
- st.session_state.last_detection = None
236
- if 'feedback_given' not in st.session_state:
237
- st.session_state.feedback_given = False
238
- if 'pending_toast' not in st.session_state:
239
- st.session_state.pending_toast = None
240
- # ========================================
241
-
242
- # Show any pending toast (set by feedback buttons before st.rerun())
243
- if st.session_state.pending_toast:
244
- _msg, _icon = st.session_state.pending_toast
245
- st.toast(_msg, icon=_icon)
246
- st.session_state.pending_toast = None
247
-
248
- # ----- Visit Counter -----
249
- # session_state resets on F5 / new tab, so this runs exactly once per browser session
250
- if 'visit_counted' not in st.session_state:
251
- st.session_state.visit_counted = True
 
 
 
 
 
 
252
  stats_manager.increment_visit()
253
- # -------------------------
254
-
255
- # -----------------
256
- # Streamlit Layout
257
- # -----------------
258
- st.markdown(
259
- "<h1 style='text-align: center;'> Detect AI-Generated Texts 🕵️ </h1>",
260
- unsafe_allow_html=True,
261
- )
262
 
263
- # st.markdown(
264
- # """Pasted the text to be detected below and click the 'Detect' button to get the p-value. Use a better option may improve detection."""
265
- # )
266
-
267
- # Display model loading status
268
- if not model_loaded:
269
- st.error(f"❌ Failed to load model: {error_message}")
270
- st.stop()
271
-
272
- # -----------------
273
- # Main Interface
274
- # -----------------
275
- # --- Two columns: Input text & button | Result displays ---
276
- text_input = st.text_area(
277
- label="📝 Input Text to be Detected",
278
- placeholder="Paste your text here",
279
- height=240,
280
- label_visibility="hidden",
281
- )
282
 
283
- subcol11, subcol12, subcol13 = st.columns((1, 1, 1))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
284
 
285
- selected_domain = subcol11.selectbox(
286
- label="💡 Domain that matches your text",
287
- options=DOMAINS,
288
- index=0, # Default to General
289
- # label_visibility="collapsed",
290
- # label_visibility="hidden",
291
- )
292
 
293
- detect_clicked = subcol12.button("🔍 Detect", type="primary", use_container_width=True)
294
 
295
- selected_level = subcol13.slider(
296
- label="Significance level (α)",
297
- min_value=0.01,
298
- max_value=0.2,
299
- value=0.05,
300
- step=0.005,
301
- # label_visibility="collapsed",
302
- )
303
 
304
- # -----------------
305
- # Detection Logic
306
- # -----------------
307
- if detect_clicked:
308
- if not text_input.strip():
309
- st.warning("⚠️ Please enter some text before detecting.")
310
- else:
311
- # ========== Reset feedback state ==========
312
- st.session_state.feedback_given = False
313
- # ==========================================
314
-
315
- # Start timing to decide whether to show progress bar
316
- start_time = time.time()
317
-
318
- # Use a placeholder for dynamic updates
319
- status_placeholder = st.empty()
320
- result_placeholder = st.empty()
321
-
322
- try:
323
- # Show spinner for quick operations (< 2 seconds expected)
324
- with status_placeholder:
325
- with st.spinner(f"🔍 Analyzing text in {selected_domain} domain..."):
326
- # Perform inference
327
- crit, p_value = model.compute_p_value(text_input, selected_domain)
328
- elapsed_time = time.time() - start_time
329
-
330
- # Convert tensors to Python scalars if needed
331
- if hasattr(crit, 'item'):
332
- crit = crit.item()
333
- if hasattr(p_value, 'item'):
334
- p_value = p_value.item()
335
-
336
- # Clear status and show results
337
- status_placeholder.empty()
338
-
339
- # ========== 🆕 保存检测结果到 session_state ==========
340
- st.session_state.last_detection = {
341
- 'text': text_input,
342
- 'domain': selected_domain,
343
- 'statistics': crit,
344
- 'p_value': p_value,
345
- 'elapsed_time': elapsed_time
346
- }
347
-
348
- # Count detection once per unique detect action
349
- _det_key = f'det_counted_{hash(text_input[:80])}'
350
- if _det_key not in st.session_state:
351
- st.session_state[_det_key] = True
352
- stats_manager.increment_detection()
353
-
354
- st.info(
355
- f"""
356
- **Conclusion**:
357
-
358
- {'Text is likely LLM-generated.' if p_value < selected_level else 'Fail to reject hypothesis that text is human-written.'}
359
-
360
- based on the observation that $p$-value {p_value:.3f} is {'less' if p_value < selected_level else 'greater'} than significance level {selected_level:.2f} 📊
361
- """,
362
- icon="💡"
363
  )
364
- st.markdown(
365
- """
366
- <style>
367
- /* Tighten spacing inside Clarification / Citation expanders */
368
- div[data-testid="stExpander"] {
369
- margin-top: -1.3rem;
370
- }
371
- div[data-testid="stExpander"] p,
372
- div[data-testid="stExpander"] li {
373
- line-height: 1.35;
374
- margin-bottom: 0.1rem;
375
- }
376
-
377
- div[data-testid="stExpander"] ul {
378
- margin-top: 0.1rem;
379
- }
380
- </style>
381
- """,
382
- unsafe_allow_html=True
383
  )
384
- with st.expander("📋 Interpretation and Suggestions"):
385
- st.markdown(
386
- """
387
- + Interpretation:
388
- - $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**.
389
- - 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.
390
-
391
- + Suggestions for better detection:
392
- - Provide longer text inputs for more reliable detection results.
393
- - Select the domain that best matches the content of your text to improve detection accuracy.
394
- """
395
- )
396
-
397
-
398
- # Show detailed results
399
- with result_placeholder:
400
- st.caption(f"⏱️ Processing time: {elapsed_time:.2f} seconds")
401
-
402
- except Exception as e:
403
- status_placeholder.empty()
404
- st.error(f"❌ Error during detection: {str(e)}")
405
- st.exception(e)
406
-
407
- # -----------------
408
- # Feedback UI (outside if detect_clicked — persists across all reruns via session_state)
409
- # -----------------
410
- if st.session_state.last_detection is not None and not st.session_state.feedback_given:
411
- _ld = st.session_state.last_detection
412
- st.markdown(
413
- """
414
- <style>
415
- .fb-header { display: flex; align-items: center; gap: 0.4rem; margin-bottom: 0.3rem; }
416
- .privacy-tip {
417
- position: relative; display: inline-block;
418
- cursor: help; color: #9ca3af; font-size: 0.9rem;
419
- }
420
- .privacy-tip .tip-text {
421
- visibility: hidden; opacity: 0;
422
- width: 240px; background-color: #374151; color: #f9fafb;
423
- text-align: left; border-radius: 6px;
424
- padding: 0.5rem 0.7rem; font-size: 0.78rem; line-height: 1.4;
425
- position: absolute; z-index: 100;
426
- bottom: 130%; left: 50%; transform: translateX(-50%);
427
- transition: opacity 0.25s ease; pointer-events: none;
428
- }
429
- .privacy-tip:hover .tip-text { visibility: visible; opacity: 1; }
430
- </style>
431
- <div class="fb-header">
432
- <strong>📝 Result Feedback</strong>: Does this detection result meet your expectations?
433
- <span class="privacy-tip">🔒
434
- <span class="tip-text">🔒 Your feedback is stored privately and will never be shared with third parties. It is used solely to improve detection accuracy.</span>
435
- </span>
436
- </div>
437
- """,
438
- unsafe_allow_html=True
439
- )
440
- feedback_col1, feedback_col2 = st.columns(2)
441
- with feedback_col1:
442
- if st.button("✅ Expected", use_container_width=True, type="secondary",
443
- key="expected_btn"):
444
- try:
445
- fb_success, fb_message = feedback_manager.save_feedback(
446
- _ld['text'], _ld['domain'], _ld['statistics'], _ld['p_value'], 'expected'
447
- )
448
- if fb_success:
449
- st.session_state.feedback_given = True
450
- st.session_state.pending_toast = ("Thank you for your feedback!", "✅")
451
- st.rerun()
452
- else:
453
- st.error(f"Failed to save feedback: {fb_message}")
454
- except Exception as e:
455
- st.error(f"Failed to save feedback: {str(e)}")
456
- with feedback_col2:
457
- if st.button("❌ Unexpected", use_container_width=True, type="secondary",
458
- key="unexpected_btn"):
459
- try:
460
- fb_success, fb_message = feedback_manager.save_feedback(
461
- _ld['text'], _ld['domain'], _ld['statistics'], _ld['p_value'], 'unexpected'
462
- )
463
- if fb_success:
464
- st.session_state.feedback_given = True
465
- st.session_state.pending_toast = ("Feedback recorded! This will help us improve.", "📝")
466
- st.rerun()
467
- else:
468
- st.error(f"Failed to save feedback: {fb_message}")
469
- except Exception as e:
470
- st.error(f"Failed to save feedback: {str(e)}")
471
-
472
- # with st.expander("📋 Citation"):
473
- # st.markdown(
474
- # """
475
- # If you find this tool useful for you, please cite our paper: **[AdaDetectGPT: Adaptive Detection of LLM-Generated Text with Statistical Guarantees](https://arxiv.org/abs/2510.01268)**
476
- # """
477
- # )
478
- # st.code(
479
- # """
480
- # @inproceedings{zhou2024adadetectgpt,
481
- # title={AdaDetectGPT: Adaptive Detection of LLM-Generated Text with Statistical Guarantees},
482
- # 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},
483
- # booktitle={The Thirty-Ninth Annual Conference on Neural Information Processing Systems},
484
- # year={2025},
485
- # }
486
- # """,
487
- # language="bibtex"
488
- # )
489
-
490
- # -----------------
491
- # Statistics Chip (fixed top-right)
492
- # -----------------
493
- st.markdown(
494
- f"""
495
- <style>
496
- .stats-chip {{
497
- position: fixed;
498
- top: 3.6rem;
499
- right: 1rem;
500
- display: flex;
501
- align-items: center;
502
- gap: 0.35rem;
503
- font-size: 0.78rem;
504
- color: #9ca3af;
505
- z-index: 999;
506
- pointer-events: none;
507
- }}
508
- </style>
509
- <div class="stats-chip">
510
- <span>{stats_manager.visit_count:,} visits</span>
511
- </div>
512
- """,
513
- unsafe_allow_html=True
514
- )
515
 
516
- # -----------------
517
- # Footer
518
- # -----------------
519
- st.markdown(
520
- """
521
- <style>
522
- .footer {
523
- position: fixed;
524
- left: 0;
525
- bottom: 0;
526
- width: 100%;
527
- background-color: white;
528
- color: gray;
529
- text-align: center;
530
- padding: 1px;
531
- border-top: 1px solid #e0e0e0;
532
- z-index: 999;
533
- }
534
-
535
- /* Add padding to main content to prevent overlap with fixed footer */
536
- .main .block-container {
537
- padding-bottom: 1px;
538
- }
539
- </style>
540
- <div class='footer'>
541
- <small> 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. </small>
542
- </div>
543
- """,
544
- unsafe_allow_html=True
545
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ DetectGPTPro — Gradio front end for AdaDetectGPT.
3
+
4
+ Migrated from Streamlit so the Space can run on Hugging Face's ZeroGPU
5
+ (dynamic, pay-per-call GPU allocation, which is only available to Gradio SDK
6
+ Spaces). All detection logic still lives in FineTune/model.py, feedback.py,
7
+ and stats.py, unchanged — this file only rebuilds the UI layer.
8
+
9
+ See streamlit_backup/ (repo root) for the original Streamlit app.
10
+ """
11
+
12
  import os
13
+ import time
14
  from pathlib import Path
15
 
 
 
 
16
  APP_DIR = Path(__file__).parent.resolve()
17
+ ACCOUNT_NAME = "mamba413"
18
+
19
+ # -----------------------------------------------------------------------
20
+ # HF Space environment setup — point HF caches at a writable directory.
21
+ # (Carried over as-is from the Streamlit app.)
22
+ # -----------------------------------------------------------------------
23
+ if os.environ.get("SPACE_ID"):
24
+ CACHE_DIR = "/tmp/huggingface_cache"
 
 
 
 
 
 
25
  os.makedirs(CACHE_DIR, exist_ok=True)
26
+ os.environ["HF_HOME"] = CACHE_DIR
27
+ os.environ["TRANSFORMERS_CACHE"] = CACHE_DIR
28
+ os.environ["HF_DATASETS_CACHE"] = CACHE_DIR
29
+ os.environ["HUGGINGFACE_HUB_CACHE"] = CACHE_DIR
30
 
31
+ import gradio as gr
 
 
 
 
 
 
 
 
 
32
 
 
33
  from FineTune.model import ComputeStat
34
+ from feedback import FeedbackManager
35
+ from stats import StatsManager
36
 
37
+ # -----------------------------------------------------------------------
38
+ # ZeroGPU support
39
+ # -----------------------------------------------------------------------
40
+ # `spaces` is preinstalled on every Gradio-SDK HF Space and is what lets a
41
+ # Space request/release a GPU per call. It's a no-op outside ZeroGPU
42
+ # hardware, but it isn't installed at all when running locally without the
43
+ # `spaces` package so fall back to a plain no-op decorator in that case.
44
+ try:
45
+ import spaces
46
 
47
+ ZERO_GPU_AVAILABLE = True
48
+ except ImportError:
49
+ ZERO_GPU_AVAILABLE = False
50
 
51
+ class _SpacesShim:
52
+ """Stand-in for the `spaces` module when developing outside HF Spaces."""
 
 
 
 
 
 
 
53
 
54
+ @staticmethod
55
+ def GPU(func=None, **_kwargs):
56
+ if func is not None:
57
+ return func
58
+ return lambda f: f
 
 
 
 
 
 
 
 
 
 
 
 
 
59
 
60
+ spaces = _SpacesShim()
 
 
 
 
61
 
 
 
 
 
62
 
63
+ def resolve_device() -> str:
64
+ """Pick an inference device, in priority order:
 
 
 
 
 
 
65
 
66
+ 1. `MODEL_DEVICE` env var, if the user wants to force one.
67
+ 2. 'cuda' on a ZeroGPU Space (the `spaces` package manages the virtual device).
68
+ 3. 'cpu' on any other HF Space (e.g. a plain CPU-tier deployment).
69
+ 4. 'mps' / 'cpu' for local development on Apple Silicon / everything else.
 
 
 
 
 
 
 
 
 
 
 
 
70
  """
71
+ explicit = os.environ.get("MODEL_DEVICE")
72
+ if explicit:
73
+ return explicit
74
+ if ZERO_GPU_AVAILABLE and os.environ.get("SPACE_ID"):
75
+ return "cuda"
76
+ if os.environ.get("SPACE_ID"):
77
+ return "cpu"
78
+ try:
79
+ import torch
80
+
81
+ if torch.backends.mps.is_available():
82
+ return "mps"
83
+ except Exception:
84
+ pass
85
+ return "cpu"
86
+
87
+
88
+ # -----------------------------------------------------------------------
89
+ # Configuration
90
+ # -----------------------------------------------------------------------
91
+ MODEL_CONFIG = {
92
+ "from_pretrained": "./src/FineTune/ckpt/",
93
+ "base_model": "gemma-1b",
94
+ "cache_dir": "../cache",
95
+ "device": resolve_device(),
96
+ }
97
+
98
+ DOMAINS = [
99
+ "General",
100
+ "Academia",
101
+ "Finance",
102
+ "Government",
103
+ "Knowledge",
104
+ "Legislation",
105
+ "Medicine",
106
+ "News",
107
+ "UserReview",
108
+ ]
109
+
110
+ FEEDBACK_DATASET_ID = os.environ.get("FEEDBACK_DATASET_ID", f"{ACCOUNT_NAME}/user-feedback")
111
+
112
+
113
+ # -----------------------------------------------------------------------
114
+ # Model / manager loading (module-level singletons loaded once at
115
+ # process startup, same lifetime as st.cache_resource gave us before).
116
+ # -----------------------------------------------------------------------
117
+ def load_model():
118
+ print(f"🔄 Loading model on device='{MODEL_CONFIG['device']}' "
119
+ f"(ZeroGPU {'enabled' if ZERO_GPU_AVAILABLE else 'unavailable'})...")
120
+ model = ComputeStat.from_pretrained(
121
+ MODEL_CONFIG["from_pretrained"],
122
+ MODEL_CONFIG["base_model"],
123
+ device=MODEL_CONFIG["device"],
124
+ cache_dir=MODEL_CONFIG["cache_dir"],
125
+ )
126
+ model.set_criterion_fn("mean")
127
  return model
128
 
 
 
 
 
 
129
 
130
+ try:
131
+ model = load_model()
132
+ model_load_error = None
133
+ except Exception as e: # noqa: BLE001 — surfaced in the UI below
134
+ model = None
135
+ model_load_error = str(e)
136
+
137
  feedback_manager = FeedbackManager(
138
  dataset_repo_id=FEEDBACK_DATASET_ID,
139
+ hf_token=os.environ.get("HF_TOKEN"),
140
+ local_backup=not os.environ.get("SPACE_ID"), # keep local backups off-Space
141
+ )
142
+
143
+ stats_manager = StatsManager(
144
+ dataset_repo_id=FEEDBACK_DATASET_ID,
145
+ hf_token=os.environ.get("HF_TOKEN"),
146
+ local_backup=not os.environ.get("SPACE_ID"),
147
  )
148
 
149
+
150
+ # -----------------------------------------------------------------------
151
+ # Inference — isolated in its own function and GPU-decorated so ZeroGPU
152
+ # can allocate a GPU just for the duration of this call and release it
153
+ # right after.
154
+ #
155
+ # `duration` reserves that many seconds of ZeroGPU quota *up front* for
156
+ # every call, regardless of how long the call actually takes — so it
157
+ # should track real measured inference time, not just be left generous.
158
+ # Override with the ZERO_GPU_DURATION env var once you've profiled a
159
+ # typical request (Settings on the Space, or locally via `time.time()`
160
+ # around `_run_inference`).
161
+ # -----------------------------------------------------------------------
162
+ ZERO_GPU_DURATION = int(os.environ.get("ZERO_GPU_DURATION", "60"))
163
+
164
+
165
+ @spaces.GPU(duration=ZERO_GPU_DURATION)
166
+ def _run_inference(text: str, domain: str):
167
+ crit, p_value = model.compute_p_value(text, domain)
168
+ if hasattr(crit, "item"):
169
+ crit = crit.item()
170
+ if hasattr(p_value, "item"):
171
+ p_value = p_value.item()
172
+ return crit, p_value
173
+
174
+
175
+ def _is_zero_gpu_quota_error(exc: Exception) -> bool:
176
+ message = str(exc).lower()
177
+ return "quota" in message and "gpu" in message
178
+
179
+
180
+ def format_conclusion(p_value: float, alpha: float) -> str:
181
+ """Build the conclusion as a framed HTML card (rendered inside gr.Markdown,
182
+ which passes raw HTML through) so the verdict stands out instead of
183
+ blending into a single paragraph."""
184
+ is_flagged = p_value < alpha
185
+ verdict = "Text is likely LLM-generated." if is_flagged else \
186
+ "Fail to reject hypothesis that text is human-written."
187
+ comparison = "less" if is_flagged else "greater"
188
+ tone = "conclusion-card--flag" if is_flagged else "conclusion-card--clear"
189
+ icon = "🚨" if is_flagged else "✅"
190
+ return (
191
+ f'<div class="conclusion-card {tone}">'
192
+ f'<div class="conclusion-verdict">{icon} {verdict}</div>'
193
+ f'<div class="conclusion-detail">based on the observation that '
194
+ f'$p$-value {p_value:.3f} is {comparison} than significance level '
195
+ f'{alpha:.2f} 📊</div>'
196
+ f'</div>'
197
  )
198
 
 
199
 
200
+ INTERPRETATION_TEXT = """
201
+ - **Interpretation**
202
+ - $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**.
203
+ - 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.
204
+ - **Suggestions for better detection**
205
+ - Provide longer text inputs for more reliable detection results.
206
+ - Select the domain that best matches the content of your text to improve detection accuracy.
207
+ """
208
+
209
+ FOOTER_TEXT = (
210
+ "This tool is developed for research purposes only. The detection results are not "
211
+ "100% accurate and should not be used as the sole basis for any critical decisions. "
212
+ "Users are advised to use this tool responsibly and ethically."
213
+ )
214
+
215
+ REFERENCES_INTRO = "If you find this tool useful, please cite:"
216
+
217
+ REFERENCES_BIBTEX = """@article{zhou2026detecting,
218
+ title={Detecting LLM-Generated Text with Performance Guarantees},
219
+ author={Zhou, Hongyi and Zhu, Jin and Yang, Ying and Shi, Chengchun},
220
+ journal={arXiv preprint arXiv:2601.06586},
221
+ year={2026}
222
  }
223
 
224
+ @inproceedings{zhou2025adadetect,
225
+ title={AdaDetectGPT: Adaptive Detection of LLM-Generated Text with Statistical Guarantees},
226
+ 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},
227
+ booktitle={The Thirty-Ninth Annual Conference on Neural Information Processing Systems},
228
+ year={2025}
229
+ }"""
230
+
231
+
232
+ # -----------------------------------------------------------------------
233
+ # Event handlers
234
+ # -----------------------------------------------------------------------
235
+ def run_detection(text: str, domain: str, alpha: float):
236
+ """Detect button handler: runs inference and refreshes all result widgets."""
237
+ if not text or not text.strip():
238
+ raise gr.Error("⚠️ Please enter some text before detecting.")
239
+
240
+ start_time = time.time()
241
+ try:
242
+ crit, p_value = _run_inference(text, domain)
243
+ except gr.Error:
244
+ raise
245
+ except Exception as e: # noqa: BLE001 — surfaced to the user via gr.Error
246
+ if _is_zero_gpu_quota_error(e):
247
+ raise gr.Error(
248
+ "⏳ This Space's free GPU quota is used up for now — it resets "
249
+ "on a rolling basis, so please try again shortly."
250
+ )
251
+ raise gr.Error(f"Detection failed: {e}")
252
+ elapsed_time = time.time() - start_time
253
+
254
+ stats_manager.increment_detection()
255
+
256
+ detection_state = {
257
+ "text": text,
258
+ "domain": domain,
259
+ "statistics": crit,
260
+ "p_value": p_value,
261
+ "elapsed_time": elapsed_time,
262
+ "feedback_given": False,
263
+ }
264
 
265
+ return (
266
+ gr.update(value=format_conclusion(p_value, alpha), visible=True),
267
+ gr.update(visible=True), # interpretation accordion
268
+ gr.update(value=f"⏱️ Processing time: {elapsed_time:.2f} seconds", visible=True),
269
+ gr.update(visible=True), # feedback row
270
+ gr.update(visible=False), # feedback thanks message
271
+ detection_state,
272
  )
273
+
274
+
275
+ def submit_feedback(feedback_type: str, detection_state: dict | None):
276
+ """Shared handler for the Expected / Unexpected feedback buttons."""
277
+ if not detection_state or detection_state.get("feedback_given"):
278
+ return gr.update(), gr.update(), detection_state
279
+
280
+ try:
281
+ success, message = feedback_manager.save_feedback(
282
+ detection_state["text"],
283
+ detection_state["domain"],
284
+ detection_state["statistics"],
285
+ detection_state["p_value"],
286
+ feedback_type,
287
+ )
288
+ except Exception as e: # noqa: BLE001
289
+ raise gr.Error(f"Failed to save feedback: {e}")
290
+
291
+ if not success:
292
+ raise gr.Error(f"Failed to save feedback: {message}")
293
+
294
+ detection_state["feedback_given"] = True
295
+ thanks = "✅ Thank you for your feedback!" if feedback_type == "expected" \
296
+ else "📝 Feedback recorded! This will help us improve."
297
+ gr.Info(thanks)
298
+
299
+ return gr.update(visible=False), gr.update(value=thanks, visible=True), detection_state
300
+
301
+
302
+ def on_load():
303
  stats_manager.increment_visit()
304
+ return f"{stats_manager.visit_count:,} visits"
 
 
 
 
 
 
 
 
305
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
306
 
307
+ # -----------------------------------------------------------------------
308
+ # Styling — ported from the Streamlit app's injected CSS.
309
+ # -----------------------------------------------------------------------
310
+ CUSTOM_CSS = """
311
+ #input-text textarea {
312
+ background-color: #f8fafc !important;
313
+ border: 1px solid #e5e7eb !important;
314
+ color: #111827 !important;
315
+ }
316
+ #detect-btn {
317
+ background-color: #fdae6b !important;
318
+ border-color: white !important;
319
+ color: black !important;
320
+ font-weight: 600 !important;
321
+ height: 4.3rem !important;
322
+ font-size: 1.1rem !important;
323
+ }
324
+ #detect-btn:hover {
325
+ background-color: #fd8d3c !important;
326
+ }
327
+ .conclusion-card {
328
+ background-color: #f8fafc;
329
+ border: 1px solid #e5e7eb;
330
+ border-left: 4px solid #94a3b8;
331
+ border-radius: 10px;
332
+ padding: 1rem 1.25rem;
333
+ margin-top: 0.5rem;
334
+ }
335
+ .conclusion-card.conclusion-card--flag {
336
+ border-left-color: #ef4444;
337
+ background-color: #fef2f2;
338
+ }
339
+ .conclusion-card.conclusion-card--clear {
340
+ border-left-color: #22c55e;
341
+ background-color: #f0fdf4;
342
+ }
343
+ .conclusion-verdict {
344
+ font-size: 1.05rem;
345
+ font-weight: 600;
346
+ color: #111827;
347
+ margin-bottom: 0.4rem;
348
+ }
349
+ .conclusion-detail {
350
+ color: #475569;
351
+ font-size: 0.9rem;
352
+ }
353
+ #stats-chip {
354
+ position: fixed;
355
+ top: 3.6rem;
356
+ right: 1rem;
357
+ font-size: 0.78rem;
358
+ color: #9ca3af;
359
+ z-index: 999;
360
+ text-align: right;
361
+ }
362
+ #app-footer {
363
+ position: fixed;
364
+ left: 0;
365
+ bottom: 0;
366
+ width: 100%;
367
+ background-color: white;
368
+ color: gray;
369
+ text-align: center;
370
+ padding: 4px 0;
371
+ border-top: 1px solid #e0e0e0;
372
+ z-index: 999;
373
+ font-size: 0.8rem;
374
+ }
375
+ /* Hide Gradio's own bottom bar ("Use via API · Built with Gradio · Settings")
376
+ so it doesn't overlap with our fixed disclaimer footer above. */
377
+ footer, .footer {
378
+ display: none !important;
379
+ }
380
+ /* Leave room at the bottom of the page so content isn't hidden behind
381
+ the fixed footer. */
382
+ .gradio-container {
383
+ padding-bottom: 2.5rem !important;
384
+ }
385
+ #references-box, #references-box * {
386
+ font-size: 0.78rem !important;
387
+ }
388
+ """
389
 
390
+ LATEX_DELIMITERS = [{"left": "$", "right": "$", "display": False}]
 
 
 
 
 
 
391
 
 
392
 
393
+ def build_interface() -> gr.Blocks:
394
+ if model is None:
395
+ with gr.Blocks(title="DetectGPTPro") as error_demo:
396
+ gr.Markdown(f"### ❌ Failed to load model\n\n```\n{model_load_error}\n```")
397
+ return error_demo
 
 
 
398
 
399
+ with gr.Blocks(title="DetectGPTPro", css=CUSTOM_CSS) as demo:
400
+ detection_state = gr.State(None)
401
+
402
+ gr.Markdown("<h1 style='text-align: center;'>Detect AI-Generated Texts 🕵️</h1>")
403
+
404
+ text_input = gr.Textbox(
405
+ label="📝 Input Text to be Detected",
406
+ placeholder="Paste your text here",
407
+ lines=10,
408
+ elem_id="input-text",
409
+ show_label=False,
410
+ )
411
+
412
+ with gr.Row():
413
+ domain_dropdown = gr.Dropdown(
414
+ choices=DOMAINS, value="General",
415
+ label="💡 Domain that matches your text",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
416
  )
417
+ detect_btn = gr.Button("🔍 Detect", variant="primary", elem_id="detect-btn")
418
+ alpha_slider = gr.Slider(
419
+ minimum=0.01, maximum=0.2, value=0.05, step=0.005,
420
+ label="Significance level (α)",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
421
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
422
 
423
+ conclusion_md = gr.Markdown(visible=False, latex_delimiters=LATEX_DELIMITERS)
424
+
425
+ with gr.Accordion("📋 Interpretation and Suggestions", open=False, visible=False) as interpretation_box:
426
+ gr.Markdown(INTERPRETATION_TEXT, latex_delimiters=LATEX_DELIMITERS)
427
+
428
+ elapsed_caption = gr.Markdown(visible=False)
429
+
430
+ gr.HTML(
431
+ '<div style="margin-top: 0.6rem;"><strong>📝 Result Feedback</strong>: '
432
+ 'Does this detection result meet your expectations? '
433
+ '<span title="🔒 Your feedback is stored privately and will never be shared with '
434
+ 'third parties. It is used solely to improve detection accuracy.">🔒</span></div>'
435
+ )
436
+ with gr.Row(visible=False) as feedback_row:
437
+ expected_btn = gr.Button("✅ Expected")
438
+ unexpected_btn = gr.Button("❌ Unexpected")
439
+ feedback_thanks = gr.Markdown(visible=False)
440
+
441
+ stats_chip = gr.Markdown(elem_id="stats-chip")
442
+
443
+ with gr.Accordion("📚 References", open=False, elem_id="references-box"):
444
+ gr.Markdown(REFERENCES_INTRO)
445
+ gr.Code(value=REFERENCES_BIBTEX, language=None, interactive=False, show_label=False)
446
+
447
+ detect_btn.click(
448
+ fn=run_detection,
449
+ inputs=[text_input, domain_dropdown, alpha_slider],
450
+ outputs=[conclusion_md, interpretation_box, elapsed_caption, feedback_row, feedback_thanks, detection_state],
451
+ )
452
+
453
+ expected_btn.click(
454
+ fn=lambda state: submit_feedback("expected", state),
455
+ inputs=[detection_state],
456
+ outputs=[feedback_row, feedback_thanks, detection_state],
457
+ )
458
+ unexpected_btn.click(
459
+ fn=lambda state: submit_feedback("unexpected", state),
460
+ inputs=[detection_state],
461
+ outputs=[feedback_row, feedback_thanks, detection_state],
462
+ )
463
+
464
+ gr.HTML(f'<div id="app-footer"><small>{FOOTER_TEXT}</small></div>')
465
+
466
+ # `demo.load` fires once per browser session (page load) — the closest
467
+ # Gradio equivalent to the Streamlit "count once per session" trick.
468
+ demo.load(fn=on_load, outputs=[stats_chip])
469
+
470
+ return demo
471
+
472
+
473
+ if __name__ == "__main__":
474
+ demo = build_interface()
475
+ demo.queue().launch(
476
+ server_name="0.0.0.0",
477
+ server_port=int(os.environ.get("PORT", 7860)),
478
+ )
streamlit_backup/Dockerfile ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.10.8
2
+
3
+ # CMD python download_private_model.py
4
+
5
+ WORKDIR /app
6
+
7
+ RUN apt-get update && apt-get install -y \
8
+ build-essential \
9
+ curl \
10
+ git \
11
+ && rm -rf /var/lib/apt/lists/*
12
+
13
+ COPY requirements.txt ./
14
+ COPY src/ ./src/
15
+
16
+ RUN pip3 install --upgrade pip
17
+
18
+ RUN pip3 install -r requirements.txt
19
+
20
+ EXPOSE 8501
21
+
22
+ HEALTHCHECK CMD curl --fail http://localhost:8501/_stcore/health
23
+
24
+ # WORKDIR /app/src
25
+ # ENTRYPOINT ["streamlit", "run", "app.py", "--server.port=8501", "--server.address=0.0.0.0"]
26
+ ENTRYPOINT ["streamlit", "run", "src/app.py", "--server.port=8501", "--server.address=0.0.0.0"]
streamlit_backup/README.md ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: StatDetectLLM — Detecting AI-Generated Text with Statistical Guarantees
3
+ colorFrom: blue
4
+ colorTo: pink
5
+ sdk: docker
6
+ app_port: 8501
7
+ tags:
8
+ - streamlit
9
+ pinned: true
10
+ license: apache-2.0
11
+ emoji: 🚀
12
+ short_description: A cheap yet powerful detector for LLM-generated text
13
+ ---
14
+
15
+ ## Advantages of StatDetectLLM
16
+
17
+ - ⚡ **Lightweight and cost-efficient**: Runs entirely on CPU and produces results within seconds, making it suitable for large-scale or resource-constrained deployments.
18
+ - 💪 **High detection performance**: Achieves an AUC above 0.99 when detecting text generated by a wide range of state-of-the-art LLMs, including Grok, GPT, and Gemini.
19
+ - 🔒 **Statistical guarantees**: Provides rigorous hypothesis-testing guarantees by controlling the test size at a user-specified nominal significance level, while maintaining detection power exceeding 90%.
streamlit_backup/README_BACKUP.md ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Streamlit version — backup
2
+
3
+ Snapshot of the CPU-only Streamlit app, taken 2026-08-11 before migrating to Gradio
4
+ (for HF Spaces ZeroGPU support). Files here are exact copies of what shipped at that time:
5
+
6
+ - `app.py` → was `src/app.py`
7
+ - `requirements.txt` → repo root
8
+ - `Dockerfile` → repo root
9
+ - `README.md` → repo root (Space metadata: `sdk: docker`, port 8501)
10
+ - `keep_alive.py` → was `keep-alive/keep_alive.py`
11
+
12
+ ## Restoring the Streamlit version
13
+
14
+ ```bash
15
+ cp streamlit_backup/app.py src/app.py
16
+ cp streamlit_backup/requirements.txt requirements.txt
17
+ cp streamlit_backup/Dockerfile Dockerfile
18
+ cp streamlit_backup/README.md README.md
19
+ cp streamlit_backup/keep_alive.py keep-alive/keep_alive.py
20
+ ```
21
+
22
+ `src/FineTune/model.py`, `src/feedback.py`, and `src/stats.py` were not touched by the
23
+ Gradio migration and don't need restoring.
24
+
25
+ Full history is also in git (`git log` on this repo) if you need anything beyond these
26
+ five files.
streamlit_backup/app.py ADDED
@@ -0,0 +1,545 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from pathlib import Path
3
+
4
+ # -----------------
5
+ # Get the directory where app.py is located
6
+ # -----------------
7
+ APP_DIR = Path(__file__).parent.resolve()
8
+
9
+ account_name = 'mamba413'
10
+
11
+ # -----------------
12
+ # Fix Streamlit Permission Issues
13
+ # -----------------
14
+ # 在 HF Space 中,将 Streamlit 配置目录设置到可写位置
15
+ if os.environ.get('SPACE_ID'):
16
+ os.environ['STREAMLIT_SERVER_FILE_WATCHER_TYPE'] = 'none'
17
+ os.environ['STREAMLIT_BROWSER_GATHER_USAGE_STATS'] = 'false'
18
+ os.environ['STREAMLIT_SERVER_ENABLE_CORS'] = 'false'
19
+
20
+ # 设置 HuggingFace 缓存到可写目录
21
+ CACHE_DIR = '/tmp/huggingface_cache'
22
+ os.makedirs(CACHE_DIR, exist_ok=True)
23
+
24
+ os.environ['HF_HOME'] = CACHE_DIR
25
+ os.environ['TRANSFORMERS_CACHE'] = CACHE_DIR
26
+ os.environ['HF_DATASETS_CACHE'] = CACHE_DIR
27
+ os.environ['HUGGINGFACE_HUB_CACHE'] = CACHE_DIR
28
+
29
+ # 设置可写的配置目录
30
+ streamlit_dir = Path('/tmp/.streamlit')
31
+ streamlit_dir.mkdir(exist_ok=True, parents=True)
32
+ # os.environ['STREAMLIT_HOME'] = '/tmp/.streamlit'
33
+
34
+
35
+ import streamlit as st
36
+ from FineTune.model import ComputeStat
37
+ import time
38
+
39
+ st.markdown(
40
+ """
41
+ <style>
42
+ /* Text area & text input */
43
+ textarea, input[type="text"] {
44
+ background-color: #f8fafc !important;
45
+ border: 1px solid #e5e7eb !important;
46
+ color: #111827 !important;
47
+ }
48
+
49
+ textarea::placeholder {
50
+ color: #9ca3af !important;
51
+ }
52
+
53
+ /* Selectbox */
54
+ div[data-testid="stSelectbox"] > div {
55
+ background-color: #f8fafc !important;
56
+ border: 1px solid #e5e7eb !important;
57
+ }
58
+ </style>
59
+ """,
60
+ unsafe_allow_html=True
61
+ )
62
+
63
+ st.markdown(
64
+ """
65
+ <style>
66
+ /* Detect button */
67
+ div.stButton > button[kind="primary"] {
68
+ background-color: #fdae6b;
69
+ border: white;
70
+ color: black;
71
+ font-weight: 600;
72
+ height: 4.3rem;
73
+
74
+ font-size: 1.1rem;
75
+
76
+ display: flex;
77
+ align-items: center;
78
+ justify-content: center;
79
+ gap: 0.55rem;
80
+ }
81
+
82
+ /* Icon inside Detect button */
83
+ div.stButton > button[kind="primary"] span {
84
+ font-size: 1.25rem;
85
+ line-height: 1;
86
+ }
87
+
88
+ div.stButton > button[kind="primary"]:hover {
89
+ background-color: #fd8d3c;
90
+ border-color: white;
91
+ }
92
+
93
+ div.stButton > button[kind="primary"]:active {
94
+ background-color: #fd8d3c;
95
+ border-color: white;
96
+ }
97
+ </style>
98
+ """,
99
+ unsafe_allow_html=True
100
+ )
101
+
102
+ # -----------------
103
+ # Page Configuration
104
+ # -----------------
105
+ st.set_page_config(
106
+ page_title="DetectGPTPro",
107
+ page_icon="🕵️",
108
+ )
109
+
110
+ # -----------------
111
+ # Model Loading (Cached)
112
+ # -----------------
113
+ @st.cache_resource
114
+ def load_model(from_pretrained, base_model, cache_dir, device):
115
+ """
116
+ Load and cache the model to avoid reloading on every user interaction.
117
+ This function runs only once when the app starts or when parameters change.
118
+ """
119
+ # is_hf_space = os.environ.get('SPACE_ID') is not None
120
+ is_hf_space = False
121
+ if is_hf_space:
122
+ cache_dir = '/tmp/huggingface_cache'
123
+ os.makedirs(cache_dir, exist_ok=True)
124
+
125
+ device = 'cpu'
126
+ print("Using **CPU** now!")
127
+
128
+ # 获取 HF Token(用于访问 gated 模型)
129
+ hf_token = os.environ.get('HF_TOKEN', None)
130
+ if hf_token:
131
+ # 也可以用 login 方式
132
+ try:
133
+ from huggingface_hub import login
134
+ login(token=hf_token)
135
+ print("✅ Successfully authenticated with HF token")
136
+ except Exception as e:
137
+ print(f"⚠️ HF login warning: {e}")
138
+
139
+ # 🔥 新增:从 HF Hub 下载模型
140
+ # 检查是否是 HF Hub 路径(格式:username/repo-name)
141
+ is_hf_hub = '/' in from_pretrained and not from_pretrained.startswith('.')
142
+ if is_hf_hub:
143
+ from huggingface_hub import snapshot_download
144
+ print(f"📥 Downloading model from HuggingFace Hub: {from_pretrained}")
145
+ try:
146
+ # 下载整个仓库到本地
147
+ local_model_path = snapshot_download(
148
+ repo_id=from_pretrained,
149
+ cache_dir=cache_dir,
150
+ token=hf_token,
151
+ repo_type="model"
152
+ )
153
+ print(f"✅ Model downloaded to: {local_model_path}")
154
+ # 使用下载后的本地路径
155
+ from_pretrained = local_model_path
156
+ except Exception as e:
157
+ print(f"❌ Failed to download model: {e}")
158
+ raise
159
+ else:
160
+ cache_dir = cache_dir
161
+
162
+ with st.spinner("🔄 Loading model... This may take a moment on first launch."):
163
+ model = ComputeStat.from_pretrained(
164
+ from_pretrained,
165
+ base_model,
166
+ device=device,
167
+ cache_dir=cache_dir
168
+ )
169
+ model.set_criterion_fn('mean')
170
+ return model
171
+
172
+ # -----------------
173
+ # Result Feedback Module Import
174
+ # -----------------
175
+ from feedback import FeedbackManager
176
+ from stats import StatsManager
177
+
178
+ # Initialize Feedback Manager with HF dataset
179
+ # make sure HF_TOKEN is set to visit private repository
180
+ FEEDBACK_DATASET_ID = os.environ.get('FEEDBACK_DATASET_ID', f'{account_name}/user-feedback')
181
+ feedback_manager = FeedbackManager(
182
+ dataset_repo_id=FEEDBACK_DATASET_ID,
183
+ hf_token=os.environ.get('HF_TOKEN'),
184
+ local_backup=False if os.environ.get('SPACE_ID') else True # 保留本地备份
185
+ )
186
+
187
+ @st.cache_resource
188
+ def get_stats_manager():
189
+ return StatsManager(
190
+ dataset_repo_id=FEEDBACK_DATASET_ID,
191
+ hf_token=os.environ.get('HF_TOKEN'),
192
+ local_backup=False if os.environ.get('SPACE_ID') else True,
193
+ )
194
+
195
+ stats_manager = get_stats_manager()
196
+
197
+ # -----------------
198
+ # Configuration
199
+ # -----------------
200
+ MODEL_CONFIG = {
201
+ 'from_pretrained': './src/FineTune/ckpt/',
202
+ 'base_model': 'gemma-1b',
203
+ 'cache_dir': '../cache',
204
+ 'device': 'cpu' if os.environ.get('SPACE_ID') else 'mps',
205
+ # 'device': 'cuda',
206
+ }
207
+
208
+ DOMAINS = [
209
+ "General",
210
+ "Academia",
211
+ "Finance",
212
+ "Government",
213
+ "Knowledge",
214
+ "Legislation",
215
+ "Medicine",
216
+ "News",
217
+ "UserReview"
218
+ ]
219
+
220
+ # Load model once at startup
221
+ try:
222
+ model = load_model(
223
+ MODEL_CONFIG['from_pretrained'],
224
+ MODEL_CONFIG['base_model'],
225
+ MODEL_CONFIG['cache_dir'],
226
+ MODEL_CONFIG['device']
227
+ )
228
+ model_loaded = True
229
+ except Exception as e:
230
+ model_loaded = False
231
+ error_message = str(e)
232
+
233
+ # =========== 🆕 session_state ===========
234
+ if 'last_detection' not in st.session_state:
235
+ st.session_state.last_detection = None
236
+ if 'feedback_given' not in st.session_state:
237
+ st.session_state.feedback_given = False
238
+ if 'pending_toast' not in st.session_state:
239
+ st.session_state.pending_toast = None
240
+ # ========================================
241
+
242
+ # Show any pending toast (set by feedback buttons before st.rerun())
243
+ if st.session_state.pending_toast:
244
+ _msg, _icon = st.session_state.pending_toast
245
+ st.toast(_msg, icon=_icon)
246
+ st.session_state.pending_toast = None
247
+
248
+ # ----- Visit Counter -----
249
+ # session_state resets on F5 / new tab, so this runs exactly once per browser session
250
+ if 'visit_counted' not in st.session_state:
251
+ st.session_state.visit_counted = True
252
+ stats_manager.increment_visit()
253
+ # -------------------------
254
+
255
+ # -----------------
256
+ # Streamlit Layout
257
+ # -----------------
258
+ st.markdown(
259
+ "<h1 style='text-align: center;'> Detect AI-Generated Texts 🕵️ </h1>",
260
+ unsafe_allow_html=True,
261
+ )
262
+
263
+ # st.markdown(
264
+ # """Pasted the text to be detected below and click the 'Detect' button to get the p-value. Use a better option may improve detection."""
265
+ # )
266
+
267
+ # Display model loading status
268
+ if not model_loaded:
269
+ st.error(f"❌ Failed to load model: {error_message}")
270
+ st.stop()
271
+
272
+ # -----------------
273
+ # Main Interface
274
+ # -----------------
275
+ # --- Two columns: Input text & button | Result displays ---
276
+ text_input = st.text_area(
277
+ label="📝 Input Text to be Detected",
278
+ placeholder="Paste your text here",
279
+ height=240,
280
+ label_visibility="hidden",
281
+ )
282
+
283
+ subcol11, subcol12, subcol13 = st.columns((1, 1, 1))
284
+
285
+ selected_domain = subcol11.selectbox(
286
+ label="💡 Domain that matches your text",
287
+ options=DOMAINS,
288
+ index=0, # Default to General
289
+ # label_visibility="collapsed",
290
+ # label_visibility="hidden",
291
+ )
292
+
293
+ detect_clicked = subcol12.button("🔍 Detect", type="primary", use_container_width=True)
294
+
295
+ selected_level = subcol13.slider(
296
+ label="Significance level (α)",
297
+ min_value=0.01,
298
+ max_value=0.2,
299
+ value=0.05,
300
+ step=0.005,
301
+ # label_visibility="collapsed",
302
+ )
303
+
304
+ # -----------------
305
+ # Detection Logic
306
+ # -----------------
307
+ if detect_clicked:
308
+ if not text_input.strip():
309
+ st.warning("⚠️ Please enter some text before detecting.")
310
+ else:
311
+ # ========== Reset feedback state ==========
312
+ st.session_state.feedback_given = False
313
+ # ==========================================
314
+
315
+ # Start timing to decide whether to show progress bar
316
+ start_time = time.time()
317
+
318
+ # Use a placeholder for dynamic updates
319
+ status_placeholder = st.empty()
320
+ result_placeholder = st.empty()
321
+
322
+ try:
323
+ # Show spinner for quick operations (< 2 seconds expected)
324
+ with status_placeholder:
325
+ with st.spinner(f"🔍 Analyzing text in {selected_domain} domain..."):
326
+ # Perform inference
327
+ crit, p_value = model.compute_p_value(text_input, selected_domain)
328
+ elapsed_time = time.time() - start_time
329
+
330
+ # Convert tensors to Python scalars if needed
331
+ if hasattr(crit, 'item'):
332
+ crit = crit.item()
333
+ if hasattr(p_value, 'item'):
334
+ p_value = p_value.item()
335
+
336
+ # Clear status and show results
337
+ status_placeholder.empty()
338
+
339
+ # ========== 🆕 保存检测结果到 session_state ==========
340
+ st.session_state.last_detection = {
341
+ 'text': text_input,
342
+ 'domain': selected_domain,
343
+ 'statistics': crit,
344
+ 'p_value': p_value,
345
+ 'elapsed_time': elapsed_time
346
+ }
347
+
348
+ # Count detection once per unique detect action
349
+ _det_key = f'det_counted_{hash(text_input[:80])}'
350
+ if _det_key not in st.session_state:
351
+ st.session_state[_det_key] = True
352
+ stats_manager.increment_detection()
353
+
354
+ st.info(
355
+ f"""
356
+ **Conclusion**:
357
+
358
+ {'Text is likely LLM-generated.' if p_value < selected_level else 'Fail to reject hypothesis that text is human-written.'}
359
+
360
+ based on the observation that $p$-value {p_value:.3f} is {'less' if p_value < selected_level else 'greater'} than significance level {selected_level:.2f} 📊
361
+ """,
362
+ icon="💡"
363
+ )
364
+ st.markdown(
365
+ """
366
+ <style>
367
+ /* Tighten spacing inside Clarification / Citation expanders */
368
+ div[data-testid="stExpander"] {
369
+ margin-top: -1.3rem;
370
+ }
371
+ div[data-testid="stExpander"] p,
372
+ div[data-testid="stExpander"] li {
373
+ line-height: 1.35;
374
+ margin-bottom: 0.1rem;
375
+ }
376
+
377
+ div[data-testid="stExpander"] ul {
378
+ margin-top: 0.1rem;
379
+ }
380
+ </style>
381
+ """,
382
+ unsafe_allow_html=True
383
+ )
384
+ with st.expander("📋 Interpretation and Suggestions"):
385
+ st.markdown(
386
+ """
387
+ + Interpretation:
388
+ - $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**.
389
+ - 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.
390
+
391
+ + Suggestions for better detection:
392
+ - Provide longer text inputs for more reliable detection results.
393
+ - Select the domain that best matches the content of your text to improve detection accuracy.
394
+ """
395
+ )
396
+
397
+
398
+ # Show detailed results
399
+ with result_placeholder:
400
+ st.caption(f"⏱️ Processing time: {elapsed_time:.2f} seconds")
401
+
402
+ except Exception as e:
403
+ status_placeholder.empty()
404
+ st.error(f"❌ Error during detection: {str(e)}")
405
+ st.exception(e)
406
+
407
+ # -----------------
408
+ # Feedback UI (outside if detect_clicked — persists across all reruns via session_state)
409
+ # -----------------
410
+ if st.session_state.last_detection is not None and not st.session_state.feedback_given:
411
+ _ld = st.session_state.last_detection
412
+ st.markdown(
413
+ """
414
+ <style>
415
+ .fb-header { display: flex; align-items: center; gap: 0.4rem; margin-bottom: 0.3rem; }
416
+ .privacy-tip {
417
+ position: relative; display: inline-block;
418
+ cursor: help; color: #9ca3af; font-size: 0.9rem;
419
+ }
420
+ .privacy-tip .tip-text {
421
+ visibility: hidden; opacity: 0;
422
+ width: 240px; background-color: #374151; color: #f9fafb;
423
+ text-align: left; border-radius: 6px;
424
+ padding: 0.5rem 0.7rem; font-size: 0.78rem; line-height: 1.4;
425
+ position: absolute; z-index: 100;
426
+ bottom: 130%; left: 50%; transform: translateX(-50%);
427
+ transition: opacity 0.25s ease; pointer-events: none;
428
+ }
429
+ .privacy-tip:hover .tip-text { visibility: visible; opacity: 1; }
430
+ </style>
431
+ <div class="fb-header">
432
+ <strong>📝 Result Feedback</strong>: Does this detection result meet your expectations?
433
+ <span class="privacy-tip">🔒
434
+ <span class="tip-text">🔒 Your feedback is stored privately and will never be shared with third parties. It is used solely to improve detection accuracy.</span>
435
+ </span>
436
+ </div>
437
+ """,
438
+ unsafe_allow_html=True
439
+ )
440
+ feedback_col1, feedback_col2 = st.columns(2)
441
+ with feedback_col1:
442
+ if st.button("✅ Expected", use_container_width=True, type="secondary",
443
+ key="expected_btn"):
444
+ try:
445
+ fb_success, fb_message = feedback_manager.save_feedback(
446
+ _ld['text'], _ld['domain'], _ld['statistics'], _ld['p_value'], 'expected'
447
+ )
448
+ if fb_success:
449
+ st.session_state.feedback_given = True
450
+ st.session_state.pending_toast = ("Thank you for your feedback!", "✅")
451
+ st.rerun()
452
+ else:
453
+ st.error(f"Failed to save feedback: {fb_message}")
454
+ except Exception as e:
455
+ st.error(f"Failed to save feedback: {str(e)}")
456
+ with feedback_col2:
457
+ if st.button("❌ Unexpected", use_container_width=True, type="secondary",
458
+ key="unexpected_btn"):
459
+ try:
460
+ fb_success, fb_message = feedback_manager.save_feedback(
461
+ _ld['text'], _ld['domain'], _ld['statistics'], _ld['p_value'], 'unexpected'
462
+ )
463
+ if fb_success:
464
+ st.session_state.feedback_given = True
465
+ st.session_state.pending_toast = ("Feedback recorded! This will help us improve.", "📝")
466
+ st.rerun()
467
+ else:
468
+ st.error(f"Failed to save feedback: {fb_message}")
469
+ except Exception as e:
470
+ st.error(f"Failed to save feedback: {str(e)}")
471
+
472
+ # with st.expander("📋 Citation"):
473
+ # st.markdown(
474
+ # """
475
+ # If you find this tool useful for you, please cite our paper: **[AdaDetectGPT: Adaptive Detection of LLM-Generated Text with Statistical Guarantees](https://arxiv.org/abs/2510.01268)**
476
+ # """
477
+ # )
478
+ # st.code(
479
+ # """
480
+ # @inproceedings{zhou2024adadetectgpt,
481
+ # title={AdaDetectGPT: Adaptive Detection of LLM-Generated Text with Statistical Guarantees},
482
+ # 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},
483
+ # booktitle={The Thirty-Ninth Annual Conference on Neural Information Processing Systems},
484
+ # year={2025},
485
+ # }
486
+ # """,
487
+ # language="bibtex"
488
+ # )
489
+
490
+ # -----------------
491
+ # Statistics Chip (fixed top-right)
492
+ # -----------------
493
+ st.markdown(
494
+ f"""
495
+ <style>
496
+ .stats-chip {{
497
+ position: fixed;
498
+ top: 3.6rem;
499
+ right: 1rem;
500
+ display: flex;
501
+ align-items: center;
502
+ gap: 0.35rem;
503
+ font-size: 0.78rem;
504
+ color: #9ca3af;
505
+ z-index: 999;
506
+ pointer-events: none;
507
+ }}
508
+ </style>
509
+ <div class="stats-chip">
510
+ <span>{stats_manager.visit_count:,} visits</span>
511
+ </div>
512
+ """,
513
+ unsafe_allow_html=True
514
+ )
515
+
516
+ # -----------------
517
+ # Footer
518
+ # -----------------
519
+ st.markdown(
520
+ """
521
+ <style>
522
+ .footer {
523
+ position: fixed;
524
+ left: 0;
525
+ bottom: 0;
526
+ width: 100%;
527
+ background-color: white;
528
+ color: gray;
529
+ text-align: center;
530
+ padding: 1px;
531
+ border-top: 1px solid #e0e0e0;
532
+ z-index: 999;
533
+ }
534
+
535
+ /* Add padding to main content to prevent overlap with fixed footer */
536
+ .main .block-container {
537
+ padding-bottom: 1px;
538
+ }
539
+ </style>
540
+ <div class='footer'>
541
+ <small> 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. </small>
542
+ </div>
543
+ """,
544
+ unsafe_allow_html=True
545
+ )
streamlit_backup/keep_alive.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ keep_alive.py — Pings the HF Space to prevent sleep after 48h inactivity.
3
+
4
+ The script:
5
+ 1. Hits the Streamlit health endpoint to verify the Space is alive
6
+ 2. Hits the main app page to simulate a user visit (counts as activity)
7
+
8
+ Usage:
9
+ python keep_alive.py
10
+
11
+ Schedule via GitHub Actions (.github/workflows/keep_alive.yml) — runs every 23 hours.
12
+ """
13
+ import urllib.request
14
+ import urllib.error
15
+ from datetime import datetime, timezone
16
+
17
+ SPACE_APP_URL = "https://stats-powered-ai-statdetectllm.hf.space"
18
+ HEALTH_URL = f"{SPACE_APP_URL}/_stcore/health"
19
+
20
+
21
+ def ping(url: str, label: str) -> bool:
22
+ """Send a GET request to url and print the result. Returns True on HTTP 2xx."""
23
+ ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
24
+ try:
25
+ req = urllib.request.Request(
26
+ url,
27
+ headers={"User-Agent": "keep-alive-bot/1.0"},
28
+ )
29
+ with urllib.request.urlopen(req, timeout=30) as resp:
30
+ if 200 <= resp.status < 300:
31
+ print(f"[{ts}] OK {label}: HTTP {resp.status}")
32
+ return True
33
+ else:
34
+ print(f"[{ts}] WARN {label}: unexpected HTTP {resp.status}")
35
+ return False
36
+ except urllib.error.URLError as e:
37
+ print(f"[{ts}] FAIL {label}: {e}")
38
+ return False
39
+ except Exception as e:
40
+ print(f"[{ts}] FAIL {label}: unexpected error: {e}")
41
+ return False
42
+
43
+
44
+ if __name__ == "__main__":
45
+ ok1 = ping(HEALTH_URL, "Health check ")
46
+ ok2 = ping(SPACE_APP_URL, "App page visit")
47
+ raise SystemExit(0 if (ok1 and ok2) else 1)
streamlit_backup/requirements.txt ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # requirements.txt
2
+ altair
3
+ streamlit
4
+ pandas==2.3.1
5
+ torch==2.8.0
6
+ numpy==2.1.3
7
+ transformers==4.55.2
8
+ peft==0.17.1
9
+ tqdm
10
+ scikit-learn
11
+ huggingface_hub