yl1913 commited on
Commit
c7bf956
·
verified ·
1 Parent(s): 549098a

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +1022 -0
app.py ADDED
@@ -0,0 +1,1022 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app.py – DivPol Creativity Study (Hugging Face Space)
2
+ # ---------------------------------------------------------
3
+ # UI: Original two-panel layout (GPT chat left, scoring right)
4
+ # Scoring: 3 embedding models × pool distance → z-score → Φ(z) → average × 100
5
+ # - New responses are chunked (~300 chars each)
6
+ # - Each chunk is scored against the AI reference pool (pre-parsed chunks)
7
+ # - Chunk percentiles are averaged within each model, then averaged across models
8
+ # Flow: Prolific ID → 3 tasks (randomised) × 5 submissions → Qualtrics redirect
9
+ # ---------------------------------------------------------
10
+ #
11
+ # CHANGELOG (v4 – Feb 2026)
12
+ # --------------------------
13
+ # [INSTR] New welcome + instruction text per participant script
14
+ # [CONT] "Continue" button between tasks (manual advance)
15
+ # [TERM] "Prompt X/3" → "Task X/3"; "Round/Attempt" → "Submission"
16
+ # [LABEL] Score bar: "divergence" → "distinctiveness"
17
+ # [CHAR] Submission blocked outside 300–600 chars; live counter in status box
18
+ # [TABLE] mpnet/noinstruct/gist columns removed; "DivPol" → "Distinctiveness Score"
19
+ # [REDIR] Qualtrics redirect on study completion (placeholder URL)
20
+ # ---------------------------------------------------------
21
+
22
+ import os, json, random, hashlib, threading, csv as _csv, re
23
+ from pathlib import Path
24
+ from typing import List, Dict, Any
25
+ from datetime import datetime, timezone
26
+
27
+ import numpy as np
28
+ import pandas as pd
29
+ import gradio as gr
30
+ from scipy.stats import norm
31
+
32
+ import torch
33
+ from transformers import AutoTokenizer, AutoModel
34
+
35
+ try:
36
+ from openai import OpenAI
37
+ _HAS_OPENAI = True
38
+ except Exception:
39
+ _HAS_OPENAI = False
40
+
41
+
42
+ # ============================================================
43
+ # Config
44
+ # ============================================================
45
+ QUALTRICS_REDIRECT_URL = "https://YOUR_QUALTRICS_SURVEY_URL_HERE"
46
+
47
+ CHAR_MIN = 300
48
+ CHAR_MAX = 600
49
+
50
+ SUBMISSIONS_PER_TASK = 5
51
+
52
+ # Column list for the history table
53
+ # TODO: Remove mpnet/noinstruct/gist before going live
54
+ HIST_COLUMNS = ["Submission", "Response Preview",
55
+ "mpnet", "noinstruct", "gist", "Distinctiveness Score"]
56
+
57
+ OPENAI_MODEL = os.getenv("OPENAI_MODEL", "gpt-4o-mini")
58
+ EMB_MODELS = [
59
+ "sentence-transformers/all-mpnet-base-v2",
60
+ "avsolatorio/NoInstruct-small-Embedding-v0",
61
+ "avsolatorio/GIST-Embedding-v0",
62
+ ]
63
+ DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
64
+
65
+
66
+ # ============================================================
67
+ # Paths
68
+ # ============================================================
69
+ BASE_DIR = Path(__file__).resolve().parent
70
+ PERSISTENT_DIR = Path("/data")
71
+ if PERSISTENT_DIR.exists():
72
+ CACHE_DIR = PERSISTENT_DIR / "cache"
73
+ DATA_DIR = PERSISTENT_DIR / "responses"
74
+ os.environ["HF_HOME"] = str(PERSISTENT_DIR / ".huggingface")
75
+ else:
76
+ CACHE_DIR = BASE_DIR / "cache"
77
+ DATA_DIR = BASE_DIR / "data"
78
+ CACHE_DIR.mkdir(exist_ok=True)
79
+ DATA_DIR.mkdir(exist_ok=True)
80
+
81
+
82
+ # ============================================================
83
+ # Tasks
84
+ # ============================================================
85
+ PROMPTS = {
86
+ "car": {
87
+ "name": "Car Safety Feature",
88
+ "text": (
89
+ "Create a new feature for a car that would help keep "
90
+ "drivers and pedestrians safe."
91
+ ),
92
+ "ref_file": "car_xgb_reference_plus_responses.xlsx",
93
+ },
94
+ "teambuilding": {
95
+ "name": "Team Building Activity",
96
+ "text": (
97
+ "What are some ways to do teambuilding on video conferencing, "
98
+ "with each person only needing a piece of paper and a rubber band?"
99
+ ),
100
+ "ref_file": "teambuilding_xgb_reference_plus_responses.xlsx",
101
+ },
102
+ "routine": {
103
+ "name": "Morning Routine",
104
+ "text": (
105
+ "Design a 20-minute morning routine that helps someone who "
106
+ "wants to start their day in a better mindset."
107
+ ),
108
+ "ref_file": "routine_xgb_reference_plus_responses.xlsx",
109
+ },
110
+ }
111
+
112
+ # ============================================================
113
+ # Instruction text [INSTR]
114
+ # ============================================================
115
+ WELCOME_TEXT = """\
116
+ <div style="padding: 14px 20px; border: 1px solid #444; border-radius: 10px;
117
+ background: #1e1e2e; margin-bottom: 12px; line-height: 1.65;">
118
+ <div style="font-size: 1.25em; font-weight: bold; margin-bottom: 10px;">
119
+ 👋 Welcome! To start, please enter your Prolific ID and click the "Start Study" button.
120
+ </div>
121
+ </div>
122
+ """
123
+
124
+ INSTRUCTION_TEXT = """\
125
+ <b>On the left panel,</b> you will work with an AI chatbot. You may send messages \
126
+ to the chatbot by typing and clicking the "Send" button, and you will receive \
127
+ interactive responses. You may interact with it as many or as few times as you like \
128
+ (but please use it at least once).<br><br>\
129
+ <b>On the right panel,</b> the "Sketchpad" allows you to draft and organize your response \
130
+ (please aim for <b>300–600 characters</b>. Your submission will be blocked otherwise). \
131
+ When you're ready, click <b>"Copy to Submission Box"</b> to move your response to the \
132
+ submission box, then click <b>"Submit and Score."</b><br><br>\
133
+ You will complete <b>five submissions</b> per task, for a total of <b>three tasks</b>. \
134
+ After each submission, you will receive a Distinctiveness Score out of 100:<br><br>\
135
+ • A score closer to <b>0</b> means your response is <b>very similar</b> \
136
+ to a typical AI-generated response.<br>\
137
+ • A score of <b>50</b> indicates your response is <b>moderately similar</b> \
138
+ to a typical AI-generated response.<br>\
139
+ • A score closer to <b>100</b> means your response is <b>very different</b> \
140
+ from a typical AI-generated response.<br><br>\
141
+ Your goal is to refine and develop your ideas so your submissions become increasingly \
142
+ distinct from the AI response. You will be able to see your response history and scores \
143
+ in a table after each submission, for a total of five submissions per task.<br><br>\
144
+ Once you finish one task, click the <b>"Continue"</b> button to move on to the next.\
145
+ """
146
+
147
+
148
+ def make_prompt_html(prompt_cfg):
149
+ return (
150
+ f'<div style="margin: 8px 0;">'
151
+ f'<div style="font-size: 1.56em; font-weight: bold;">📝 {prompt_cfg["name"]}</div>'
152
+ f'<div style="font-size: 1.3em; margin-top: 8px; padding: 10px 16px; '
153
+ f'border-left: 4px solid #666; color: #ddd;">{prompt_cfg["text"]}</div>'
154
+ f'<div style="font-size: 1.08em; color: #ddd; margin-top: 12px; '
155
+ f'line-height: 1.7; padding: 12px 16px; background: rgba(255,255,255,0.04); '
156
+ f'border-radius: 8px;">{INSTRUCTION_TEXT}</div>'
157
+ f'</div>'
158
+ )
159
+
160
+
161
+ # ============================================================
162
+ # Text cleaning & chunking (from parser_utils.py)
163
+ # ============================================================
164
+ def clean_fun(html_string):
165
+ html_string = re.sub(r'&\(\d\)', ' ', html_string)
166
+ html_string = re.sub(r'&\d+;', ' ', html_string)
167
+ html_string = re.sub(r'\\', '', html_string)
168
+ _remove_patterns = [
169
+ b'\xc3\x83\xc6\x92\xc3\x82\xc2\xa2',
170
+ b'\xc3\x83\xc6\x92',
171
+ b'\xc3\x83\xc2\xa2\xc3\x82\xc2\xac',
172
+ b'\xc3\x83\xe2\x80\xa6',
173
+ b'\xc3\x83\xe2\x80\x9a\xc3\x82\xc2\xa6',
174
+ b'\xc3\x83\xc6\x92\xc3\x82\xe2\x80\x9a',
175
+ b'\xc3\x83\xc2\xa2\xc3\x82\xe2\x80\x9a\xc3\x82\xc2\xac',
176
+ b'\xc3\x83\xc6\x92\xc3\x82\xc2\xa2\xc3\x83\xc2\xa2\xc3\x82\xe2\x80\x9a\xc3\x82\xc2\xac\xc3\x83\xc2\xa2\xc3\x82\xe2\x80\x9e\xc3\x82\xc2\xa2',
177
+ b'\xc3\x83\xe2\x80\xa6\xc3\x82\xe2\x80\x9c',
178
+ ]
179
+ for pat in _remove_patterns:
180
+ html_string = html_string.replace(pat.decode('utf-8', errors='replace'), '')
181
+ _apostrophe_patterns = [
182
+ b'\xc3\x83\xc6\x92\xc3\x82\xc2\xa2\xc3\x83\xc2\xa2\xc3\x82\xc2\xac\xc3\x83\xc2\xa2\xc3\x82\xc2\xa2',
183
+ b'\xc3\x83\xe2\x80\x9a\xc3\x82\xc2\xb4',
184
+ ]
185
+ for pat in _apostrophe_patterns:
186
+ html_string = html_string.replace(pat.decode('utf-8', errors='replace'), "'")
187
+ html_string = html_string.replace('`', "'")
188
+ html_string = re.sub(r'\u009d', '', html_string)
189
+ html_string = re.sub(r'<.*?>', '', html_string)
190
+ return html_string
191
+
192
+
193
+ class CustomChunkTokenizer:
194
+ def __init__(self, chunk_size=300, direction='forward', clean_text=True, min_chunks=2):
195
+ self.chunk_size = chunk_size
196
+ self.direction = direction.lower()
197
+ self.clean_text = clean_text
198
+ self.min_chunks = min_chunks
199
+
200
+ def _remove_emojis_and_symbols(self, text):
201
+ emoji_pattern = re.compile(
202
+ "["
203
+ "\U0001F600-\U0001F64F"
204
+ "\U0001F300-\U0001F5FF"
205
+ "\U0001F680-\U0001F6FF"
206
+ "\U0001F1E0-\U0001F1FF"
207
+ "\U00002702-\U000027B0"
208
+ "\U000024C2-\U0001F251"
209
+ "\U0001F900-\U0001F9FF"
210
+ "\U0001FA70-\U0001FAFF"
211
+ "]+", flags=re.UNICODE
212
+ )
213
+ text = emoji_pattern.sub('', text)
214
+ text = re.sub(r'[*#@$%^&+=<>|~`]', '', text)
215
+ return text
216
+
217
+ def _clean_markdown(self, text):
218
+ text = re.sub(r'^#{1,6}\s+', '', text, flags=re.MULTILINE)
219
+ text = re.sub(r'\*\*(.+?)\*\*', r'\1', text)
220
+ text = re.sub(r'__(.+?)__', r'\1', text)
221
+ text = re.sub(r'\*(.+?)\*', r'\1', text)
222
+ text = re.sub(r'_(.+?)_', r'\1', text)
223
+ text = re.sub(r'^\s*[\*\-\+]\s+', '', text, flags=re.MULTILINE)
224
+ text = re.sub(r'^\s*\d+\.\s+', '', text, flags=re.MULTILINE)
225
+ text = re.sub(r'```.*?```', '', text, flags=re.DOTALL)
226
+ text = re.sub(r'`(.+?)`', r'\1', text)
227
+ text = re.sub(r'[•◦▪▸‣⃁▫▹►‣◁○■□▢▣▤▥▦▧▨▩◘◙◉◎]', '', text)
228
+ text = re.sub(r'^[\s\-\*_]{3,}\s*$', '', text, flags=re.MULTILINE)
229
+ text = re.sub(r'\s+---+\s+', ' ', text)
230
+ return text
231
+
232
+ def _preprocess_text(self, text):
233
+ if not self.clean_text:
234
+ return text
235
+ text = self._clean_markdown(text)
236
+ text = self._remove_emojis_and_symbols(text)
237
+ text = re.sub(r'\s+', ' ', text)
238
+ text = text.strip()
239
+ return text
240
+
241
+ def tokenize(self, text):
242
+ processed_text = self._preprocess_text(text)
243
+ chunks = self._create_chunks(text)
244
+ if len(chunks) == 0:
245
+ return [processed_text]
246
+ return chunks if len(chunks) >= self.min_chunks else [processed_text]
247
+
248
+ def _create_chunks(self, text):
249
+ text = self._preprocess_text(text)
250
+ words = text.split()
251
+ if self.direction == 'backward':
252
+ words = words[::-1]
253
+ chunks = []
254
+ current_chunk = ""
255
+ for word in words:
256
+ test_chunk = current_chunk + (" " if current_chunk else "") + word
257
+ if len(test_chunk) <= self.chunk_size:
258
+ current_chunk = test_chunk
259
+ else:
260
+ if not current_chunk:
261
+ current_chunk = word
262
+ else:
263
+ chunks.append(current_chunk)
264
+ current_chunk = word
265
+ if current_chunk:
266
+ if len(current_chunk) < 300 and len(chunks) > 0:
267
+ chunks[-1] = chunks[-1] + " " + current_chunk
268
+ else:
269
+ chunks.append(current_chunk)
270
+ if self.direction == 'backward':
271
+ chunks = [' '.join(chunk.split()[::-1]) for chunk in chunks[::-1]]
272
+ return chunks
273
+
274
+
275
+ def clean_punctuation(sentence):
276
+ return re.sub(r'[.?!*]', ' ', sentence)
277
+
278
+
279
+ def clean_new_response(essay):
280
+ sent_tokenizer = CustomChunkTokenizer(
281
+ chunk_size=300, direction='forward', clean_text=True, min_chunks=2
282
+ )
283
+ essay = clean_fun(essay)
284
+ sents = sent_tokenizer.tokenize(essay)
285
+ sents = [clean_punctuation(s) for s in sents]
286
+ return sents
287
+
288
+
289
+ # ============================================================
290
+ # Embedding models (lazy singletons)
291
+ # ============================================================
292
+ _models: Dict[str, Any] = {}
293
+ _model_lock = threading.Lock()
294
+
295
+
296
+ def _load_model(model_name: str):
297
+ if model_name not in _models:
298
+ with _model_lock:
299
+ if model_name not in _models:
300
+ print(f"[model] Loading {model_name} …", flush=True)
301
+ tok = AutoTokenizer.from_pretrained(model_name)
302
+ mdl = AutoModel.from_pretrained(
303
+ model_name, output_hidden_states=True
304
+ ).to(DEVICE)
305
+ mdl.eval()
306
+ _models[model_name] = (tok, mdl)
307
+ return _models[model_name]
308
+
309
+
310
+ def embed_texts(texts: List[str], model_name: str,
311
+ batch_size: int = 64) -> np.ndarray:
312
+ tok, mdl = _load_model(model_name)
313
+ parts = []
314
+ total_batches = (len(texts) + batch_size - 1) // batch_size
315
+ for batch_idx, i in enumerate(range(0, len(texts), batch_size)):
316
+ batch = texts[i: i + batch_size]
317
+ if total_batches > 1:
318
+ print(f" [embed] batch {batch_idx+1}/{total_batches} "
319
+ f"({i+len(batch)}/{len(texts)} texts)", flush=True)
320
+ enc = tok(batch, padding="max_length", truncation=True, return_tensors="pt")
321
+ enc = {k: v.to(DEVICE) for k, v in enc.items()}
322
+ with torch.no_grad():
323
+ out = mdl(**enc)
324
+ h = out.hidden_states[-1][:, 0, :]
325
+ parts.append(h.cpu().numpy().astype(np.float32))
326
+ return np.vstack(parts)
327
+
328
+
329
+ def embed_single(text: str, model_name: str) -> np.ndarray:
330
+ return embed_texts([text], model_name)[0]
331
+
332
+
333
+ # ============================================================
334
+ # Baseline
335
+ # ============================================================
336
+ _baselines: Dict[str, Dict[str, Any]] = {}
337
+
338
+
339
+ def _baseline_key(prompt_key: str, model_name: str) -> str:
340
+ return f"{prompt_key}__{model_name.replace('/', '__')}"
341
+
342
+
343
+ def _compute_baseline(prompt_key: str, model_name: str) -> Dict[str, Any]:
344
+ bkey = _baseline_key(prompt_key, model_name)
345
+ npz = CACHE_DIR / f"{bkey}_pool_embs.npz"
346
+ jsn = CACHE_DIR / f"{bkey}_zparams.json"
347
+
348
+ if npz.exists() and jsn.exists():
349
+ data = np.load(npz)
350
+ stats = json.loads(jsn.read_text())
351
+ print(f"[{prompt_key}|{model_name}] Loaded cached baseline "
352
+ f"(N={stats['n_pool']}, M={stats['z_mean']:.6f}, SD={stats['z_sd']:.6f})")
353
+ return {"pool_embs": data["pool_embs"], "z_mean": stats["z_mean"],
354
+ "z_sd": stats["z_sd"], "n_pool": stats["n_pool"]}
355
+
356
+ ref_path = BASE_DIR / PROMPTS[prompt_key]["ref_file"]
357
+ df = pd.read_excel(ref_path)
358
+ ai_df = df[~df['respondent'].str.contains('human')]
359
+ pool_sentences = ai_df['sentence'].astype(str).tolist()
360
+
361
+ print(f"[{prompt_key}|{model_name}] Embedding {len(pool_sentences)} AI pool chunks …",
362
+ flush=True)
363
+ pool_embs = embed_texts(pool_sentences, model_name)
364
+
365
+ print(f"[{prompt_key}|{model_name}] Computing pairwise distances …", flush=True)
366
+ from sklearn.metrics.pairwise import cosine_similarity
367
+ sims = cosine_similarity(pool_embs)
368
+ dists = 1.0 - sims
369
+ lower = np.tril(dists, k=-1)
370
+ vals = lower[lower != 0]
371
+ z_mean = float(np.mean(vals))
372
+ z_sd = float(np.std(vals))
373
+
374
+ np.savez_compressed(str(npz), pool_embs=pool_embs.astype(np.float32))
375
+ jsn.write_text(json.dumps({"z_mean": z_mean, "z_sd": z_sd,
376
+ "n_pool": len(pool_sentences)}))
377
+ print(f"[{prompt_key}|{model_name}] Baseline: N={len(pool_sentences)}, "
378
+ f"M={z_mean:.6f}, SD={z_sd:.6f}")
379
+ return {"pool_embs": pool_embs.astype(np.float32), "z_mean": z_mean,
380
+ "z_sd": z_sd, "n_pool": len(pool_sentences)}
381
+
382
+
383
+ def get_baseline(prompt_key: str, model_name: str) -> Dict[str, Any]:
384
+ bkey = _baseline_key(prompt_key, model_name)
385
+ if bkey not in _baselines:
386
+ _baselines[bkey] = _compute_baseline(prompt_key, model_name)
387
+ return _baselines[bkey]
388
+
389
+
390
+ # ============================================================
391
+ # Scoring
392
+ # ============================================================
393
+ def _score_chunk_one_model(chunk_embedding, prompt_key, model_name):
394
+ from sklearn.metrics.pairwise import cosine_similarity
395
+ bl = get_baseline(prompt_key, model_name)
396
+ sims = cosine_similarity(chunk_embedding.reshape(1, -1), bl["pool_embs"])[0]
397
+ dists = 1.0 - sims
398
+ mean_dist = float(np.mean(dists))
399
+ z = (mean_dist - bl["z_mean"]) / bl["z_sd"] if bl["z_sd"] > 0 else 0.0
400
+ return float(norm.cdf(z)) * 100
401
+
402
+
403
+ def score_text(text: str, prompt_key: str) -> Dict[str, float]:
404
+ text = (text or "").strip()
405
+ if not text:
406
+ return {"mpnet": 0.0, "noinstruct": 0.0, "gist": 0.0, "final": 0.0}
407
+ chunks = clean_new_response(text)
408
+ result = {}
409
+ model_averages = []
410
+ for model_name, short in zip(EMB_MODELS, ["mpnet", "noinstruct", "gist"]):
411
+ scores = [_score_chunk_one_model(embed_single(c, model_name), prompt_key, model_name)
412
+ for c in chunks]
413
+ avg = float(np.mean(scores))
414
+ result[short] = round(avg, 1)
415
+ model_averages.append(avg)
416
+ result["final"] = round(float(np.mean(model_averages)), 1)
417
+ return result
418
+
419
+
420
+ # ============================================================
421
+ # Data persistence (dual-write: primary + backup)
422
+ # ============================================================
423
+ _csv_lock = threading.Lock()
424
+
425
+ # Backup always writes to app directory as second copy
426
+ BACKUP_DIR = BASE_DIR / "data_backup"
427
+ BACKUP_DIR.mkdir(exist_ok=True)
428
+
429
+
430
+ def _write_csv(csv_path, row):
431
+ """Append a row to a CSV file, creating header if needed."""
432
+ write_header = not csv_path.exists()
433
+ with open(csv_path, "a", newline="", encoding="utf-8") as f:
434
+ w = _csv.DictWriter(f, fieldnames=list(row.keys()))
435
+ if write_header:
436
+ w.writeheader()
437
+ w.writerow(row)
438
+
439
+
440
+ def _write_json(json_path, row, chat_history, task_order):
441
+ """Append a response to a per-participant JSON file."""
442
+ data = json.loads(json_path.read_text()) if json_path.exists() else {
443
+ "prolific_id": row["prolific_id"], "started": row["timestamp"],
444
+ "task_order": task_order, "responses": []
445
+ }
446
+ json_row = dict(row)
447
+ json_row["chat_history"] = chat_history # native list, not JSON string
448
+ data["responses"].append(json_row)
449
+ json_path.write_text(json.dumps(data, indent=2))
450
+
451
+
452
+ def save_response(prolific_id, prompt_key, submission_num, task_index,
453
+ response_text, scores, task_order,
454
+ sketchpad_text="", chat_history=None):
455
+ chat_history = chat_history or []
456
+ chat_json = json.dumps(chat_history, ensure_ascii=False)
457
+ row = {
458
+ "timestamp": datetime.now(timezone.utc).isoformat(),
459
+ "prolific_id": prolific_id,
460
+ "task_key": prompt_key,
461
+ "task_index": task_index + 1,
462
+ "submission": submission_num,
463
+ "sketchpad_text": sketchpad_text,
464
+ "response_text": response_text,
465
+ "mpnet": scores["mpnet"],
466
+ "noinstruct": scores["noinstruct"],
467
+ "gist": scores["gist"],
468
+ "divpol_score": scores["final"],
469
+ "task_order": json.dumps(task_order),
470
+ "chat_history": chat_json,
471
+ }
472
+ with _csv_lock:
473
+ # Primary write (persistent storage)
474
+ try:
475
+ _write_csv(DATA_DIR / "responses.csv", row)
476
+ _write_json(DATA_DIR / f"{prolific_id}.json",
477
+ row, chat_history, task_order)
478
+ except Exception as e:
479
+ print(f"[WARN] Primary save failed: {e}", flush=True)
480
+
481
+ # Backup write (app directory — separate copy)
482
+ try:
483
+ _write_csv(BACKUP_DIR / "responses.csv", row)
484
+ _write_json(BACKUP_DIR / f"{prolific_id}.json",
485
+ row, chat_history, task_order)
486
+ except Exception as e:
487
+ print(f"[WARN] Backup save failed: {e}", flush=True)
488
+
489
+
490
+ # ============================================================
491
+ # OpenAI chat
492
+ # ============================================================
493
+ def chat_reply(history, user_msg, system_prompt):
494
+ history = history or []
495
+ user_msg = (user_msg or "").strip()
496
+ if not user_msg:
497
+ return history, ""
498
+
499
+ if not _HAS_OPENAI:
500
+ history.append({"role": "user", "content": user_msg})
501
+ history.append({"role": "assistant",
502
+ "content": f"(OpenAI not installed) {user_msg}"})
503
+ return history, ""
504
+
505
+ api_key = os.getenv("OPENAI_API_KEY", "").strip()
506
+ if not api_key:
507
+ history.append({"role": "user", "content": user_msg})
508
+ history.append({"role": "assistant",
509
+ "content": "OPENAI_API_KEY is missing in Space Secrets."})
510
+ return history, ""
511
+
512
+ client = OpenAI(api_key=api_key)
513
+ messages = []
514
+ sys_p = (system_prompt or "").strip()
515
+ if sys_p:
516
+ messages.append({"role": "system", "content": sys_p})
517
+ messages.extend(history)
518
+ messages.append({"role": "user", "content": user_msg})
519
+
520
+ try:
521
+ resp = client.responses.create(model=OPENAI_MODEL, input=messages, temperature=0.7)
522
+ answer = (resp.output_text or "").strip()
523
+ except Exception:
524
+ resp = client.chat.completions.create(model=OPENAI_MODEL, messages=messages,
525
+ temperature=0.7)
526
+ answer = resp.choices[0].message.content.strip()
527
+
528
+ return history + [{"role": "user", "content": user_msg},
529
+ {"role": "assistant", "content": answer}], ""
530
+
531
+
532
+ def chat_clear():
533
+ return []
534
+
535
+
536
+ # ============================================================
537
+ # Score visual
538
+ # ============================================================
539
+ def make_score_visual(score):
540
+ pct = max(0, min(100, score))
541
+ if pct < 25:
542
+ color, label = "#e74c3c", "Low distinctiveness"
543
+ elif pct < 45:
544
+ color, label = "#e67e22", "Below average distinctiveness"
545
+ elif pct < 55:
546
+ color, label = "#f1c40f", "Average distinctiveness"
547
+ elif pct < 75:
548
+ color, label = "#2ecc71", "Above average distinctiveness"
549
+ else:
550
+ color, label = "#27ae60", "High distinctiveness"
551
+
552
+ return f"""
553
+ <div style="margin: 12px 0;">
554
+ <div style="display: flex; justify-content: space-between;
555
+ font-size: 13px; color: #aaa; margin-bottom: 2px;">
556
+ <span>0 – Very similar to AI</span>
557
+ <span>100 – Very different from AI</span>
558
+ </div>
559
+ <div style="position: relative; width: 100%; height: 28px;
560
+ background: linear-gradient(to right, #e74c3c, #e67e22, #f1c40f, #2ecc71, #27ae60);
561
+ border-radius: 6px; overflow: visible;">
562
+ <div style="position: absolute; left: {pct}%;
563
+ top: -2px; transform: translateX(-50%);
564
+ width: 4px; height: 32px;
565
+ background: white; border-radius: 2px;
566
+ box-shadow: 0 0 4px rgba(0,0,0,0.5);"></div>
567
+ </div>
568
+ <div style="text-align: center; margin-top: 6px;">
569
+ <span style="font-size: 22px; font-weight: bold; color: {color};">{pct:.1f}</span>
570
+ <span style="font-size: 14px; color: #ccc; margin-left: 8px;">{label}</span>
571
+ </div>
572
+ </div>
573
+ """
574
+
575
+
576
+ # ============================================================
577
+ # Qualtrics redirect HTML
578
+ # ============================================================
579
+ def make_redirect_html(prolific_id):
580
+ url = f"{QUALTRICS_REDIRECT_URL}?PROLIFIC_PID={prolific_id}"
581
+ return (
582
+ f'<div style="text-align:center; margin-top: 24px;">'
583
+ f'<p style="font-size:1.1em; color:#ccc;">Study complete — thank you!</p>'
584
+ f'<p style="font-size:0.95em; color:#aaa;">You will be redirected to the survey '
585
+ f'shortly. If not, '
586
+ f'<a href="{url}" target="_blank" style="color:#4ea6dc;">click here</a>.</p>'
587
+ f'<script>setTimeout(function(){{window.location.href="{url}";}}, 3000);</script>'
588
+ f'</div>'
589
+ )
590
+
591
+
592
+ # ============================================================
593
+ # Character count helper
594
+ # ============================================================
595
+ def char_count_status(text):
596
+ n = len((text or "").strip())
597
+ if n == 0:
598
+ return "Character count: 0 / 600 (minimum 300)"
599
+ elif n < CHAR_MIN:
600
+ return f"⚠️ Too short: {n} / {CHAR_MAX} characters (minimum {CHAR_MIN})"
601
+ elif n > CHAR_MAX:
602
+ return f"⚠️ Too long: {n} / {CHAR_MAX} characters (maximum {CHAR_MAX})"
603
+ else:
604
+ return f"✅ {n} / {CHAR_MAX} characters (within 300–600 limit)"
605
+
606
+
607
+ # ============================================================
608
+ # UI
609
+ # ============================================================
610
+ with gr.Blocks(theme=gr.themes.Soft(), analytics_enabled=False) as demo:
611
+
612
+ # ── State ──
613
+ st_prolific = gr.State("")
614
+ st_order = gr.State([])
615
+ st_pidx = gr.State(0)
616
+ st_submission = gr.State(1)
617
+ st_responses = gr.State([])
618
+ st_current_prompt = gr.State("car")
619
+ st_history = gr.State([])
620
+ st_task_complete = gr.State(False) # [CONT] tracks whether current task is done
621
+ sys_prompt = gr.State("You are a helpful assistant.")
622
+
623
+ # ── Welcome message ── [INSTR]
624
+ welcome_html = gr.HTML(value=WELCOME_TEXT)
625
+
626
+ # ── Top bar: Prolific ID + Start ──
627
+ with gr.Row():
628
+ with gr.Column(scale=2):
629
+ tb_prolific = gr.Textbox(
630
+ label="Prolific ID",
631
+ placeholder="Enter your Prolific ID to begin…",
632
+ max_lines=1, interactive=True,
633
+ )
634
+ with gr.Column(scale=1):
635
+ btn_start = gr.Button("Start Study", variant="primary")
636
+
637
+ md_status_bar = gr.Markdown("")
638
+ md_prompt_display = gr.HTML(value="")
639
+
640
+ with gr.Row():
641
+ # LEFT: Chat
642
+ with gr.Column(scale=1):
643
+ gr.Markdown("## Chat with AI")
644
+ chatbot = gr.Chatbot(label="Chat", type="messages", height=520)
645
+ chat_input = gr.Textbox(
646
+ label="Message", placeholder="Ask anything…", lines=2)
647
+ with gr.Row():
648
+ send_btn = gr.Button("Send", variant="primary")
649
+ clear_btn = gr.Button("Clear")
650
+
651
+ # RIGHT: Response + scoring
652
+ with gr.Column(scale=1):
653
+ gr.Markdown("## Your Response")
654
+ sketchpad = gr.Textbox(
655
+ label="📝 Sketchpad (draft your response here)",
656
+ lines=8, placeholder="Draft your ideas here…",
657
+ )
658
+ copy_btn = gr.Button("⬇ Copy to Submission Box", size="sm")
659
+ submission_box = gr.Textbox(
660
+ label="📨 Final Submission",
661
+ lines=5,
662
+ placeholder="Your final response goes here (300–600 characters).",
663
+ )
664
+ score_btn = gr.Button("Submit and Score", variant="primary")
665
+
666
+ # [CONT] Continue button — hidden until a task's 5 submissions are done
667
+ continue_btn = gr.Button(
668
+ "➡️ Continue to Next Task", variant="primary", visible=False
669
+ )
670
+
671
+ score_status = gr.Textbox(
672
+ label="Status",
673
+ value="Character count: 0 / 600 (minimum 300)",
674
+ interactive=False,
675
+ )
676
+
677
+ score_visual = gr.HTML(value="")
678
+ redirect_html = gr.HTML(value="")
679
+
680
+ history_df = gr.Dataframe(
681
+ label="Submission History",
682
+ headers=HIST_COLUMNS,
683
+ datatype=["number", "str", "number", "number", "number", "number"],
684
+ interactive=False,
685
+ wrap=True,
686
+ )
687
+
688
+ # ----------------------------
689
+ # CALLBACKS
690
+ # ----------------------------
691
+ def copy_to_submission(sketch_text):
692
+ return sketch_text or ""
693
+
694
+ copy_btn.click(fn=copy_to_submission, inputs=[sketchpad], outputs=[submission_box])
695
+
696
+ submission_box.change(
697
+ fn=char_count_status,
698
+ inputs=[submission_box],
699
+ outputs=[score_status],
700
+ )
701
+
702
+ def start_study(prolific_id):
703
+ pid = (prolific_id or "").strip()
704
+ empty_hist = pd.DataFrame(
705
+ columns=HIST_COLUMNS)
706
+ if len(pid) < 3:
707
+ return (pid, [], 0, 1, [], "car", [], False,
708
+ "⚠️ **Please enter a valid Prolific ID (at least 3 characters).**",
709
+ "", empty_hist, "", "",
710
+ gr.update(visible=True), # score_btn visible
711
+ gr.update(visible=False), # continue_btn hidden
712
+ "", # welcome hidden after start
713
+ gr.update(), # btn_start stays enabled
714
+ gr.update()) # tb_prolific stays editable
715
+ rng = random.Random(hashlib.md5(pid.encode()).hexdigest())
716
+ order = list(PROMPTS.keys())
717
+ rng.shuffle(order)
718
+ pk = order[0]
719
+ status = (f"**Task 1 / 3 · Submission 1 / {SUBMISSIONS_PER_TASK}** · "
720
+ f"Participant: `{pid}`")
721
+ return (pid, order, 0, 1, [], pk, [], False,
722
+ status, make_prompt_html(PROMPTS[pk]), empty_hist, "", "",
723
+ gr.update(visible=True), # score_btn
724
+ gr.update(visible=False), # continue_btn
725
+ "", # welcome hidden
726
+ gr.update(interactive=False, variant="secondary"), # disable start btn
727
+ gr.update(interactive=False)) # lock prolific ID
728
+
729
+ btn_start.click(
730
+ fn=start_study,
731
+ inputs=[tb_prolific],
732
+ outputs=[
733
+ st_prolific, st_order, st_pidx, st_submission, st_responses,
734
+ st_current_prompt, st_history, st_task_complete,
735
+ md_status_bar, md_prompt_display, history_df,
736
+ score_visual, redirect_html,
737
+ score_btn, continue_btn,
738
+ welcome_html,
739
+ btn_start, tb_prolific,
740
+ ],
741
+ )
742
+
743
+ def do_score(text, sketchpad_text, chat_history,
744
+ prompt_key, prolific_id, order, pidx, submission,
745
+ responses, history, task_complete):
746
+ text = (text or "").strip()
747
+ sketchpad_text = (sketchpad_text or "").strip()
748
+ empty_hist = pd.DataFrame(
749
+ columns=HIST_COLUMNS)
750
+
751
+ if not prolific_id:
752
+ cur_hist = pd.DataFrame(history) if history else empty_hist
753
+ return (cur_hist, "⚠️ Enter Prolific ID and click Start Study first.",
754
+ responses, submission, pidx, prompt_key, history, False,
755
+ "", "", "", "",
756
+ gr.update(visible=True), gr.update(visible=False),
757
+ gr.update(), gr.update())
758
+
759
+ # Block if task already complete (waiting for Continue click)
760
+ if task_complete:
761
+ cur_hist = pd.DataFrame(history) if history else empty_hist
762
+ return (cur_hist,
763
+ "✅ Task complete! Click **Continue to Next Task** to proceed.",
764
+ responses, submission, pidx, prompt_key, history, True,
765
+ "", "", "", "",
766
+ gr.update(visible=False), gr.update(visible=True),
767
+ gr.update(), gr.update())
768
+
769
+ if not text:
770
+ cur_hist = pd.DataFrame(history) if history else empty_hist
771
+ return (cur_hist, char_count_status(text),
772
+ responses, submission, pidx, prompt_key, history, False,
773
+ "", "", "", "",
774
+ gr.update(visible=True), gr.update(visible=False),
775
+ gr.update(), gr.update())
776
+
777
+ # Enforce character limits
778
+ n = len(text)
779
+ if n < CHAR_MIN:
780
+ cur_hist = pd.DataFrame(history) if history else empty_hist
781
+ return (cur_hist,
782
+ f"⚠️ Too short: {n} characters. Please write at least {CHAR_MIN}.",
783
+ responses, submission, pidx, prompt_key, history, False,
784
+ "", "", "", "",
785
+ gr.update(visible=True), gr.update(visible=False),
786
+ gr.update(), gr.update())
787
+ if n > CHAR_MAX:
788
+ cur_hist = pd.DataFrame(history) if history else empty_hist
789
+ return (cur_hist,
790
+ f"⚠️ Too long: {n} characters. Please keep to {CHAR_MAX} or fewer.",
791
+ responses, submission, pidx, prompt_key, history, False,
792
+ "", "", "", "",
793
+ gr.update(visible=True), gr.update(visible=False),
794
+ gr.update(), gr.update())
795
+
796
+ # Score
797
+ scores = score_text(text, prompt_key)
798
+ sc = scores["final"]
799
+ save_response(prolific_id, prompt_key, submission, pidx, text, scores, order,
800
+ sketchpad_text=sketchpad_text, chat_history=chat_history)
801
+
802
+ # History row
803
+ preview = text[:80] + "…" if len(text) > 80 else text
804
+ row = {"Submission": submission, "Response Preview": preview,
805
+ "mpnet": scores["mpnet"], "noinstruct": scores["noinstruct"],
806
+ "gist": scores["gist"], "Distinctiveness Score": sc}
807
+ new_history = history + [row]
808
+ new_responses = responses + [{"task_key": prompt_key, "submission": submission,
809
+ "response_text": text, "score": sc}]
810
+ visual_html = make_score_visual(sc)
811
+ hist_df = pd.DataFrame(new_history) if new_history else empty_hist
812
+
813
+ status_msg = f"✅ Scored {n} chars → Distinctiveness Score = {sc:.1f} / 100"
814
+
815
+ # Check if this was the last submission for this task
816
+ if submission >= SUBMISSIONS_PER_TASK:
817
+ # Task is done — show Continue button, hide Submit button
818
+ new_task_complete = True
819
+ new_pidx = pidx
820
+ new_pk = prompt_key
821
+ new_submission = submission # keep at 5
822
+
823
+ # Check if this was the LAST task entirely
824
+ if pidx + 1 >= len(order):
825
+ bar = (f"✅ **Study complete!** You submitted {len(new_responses)} "
826
+ f"responses. Redirecting to survey…")
827
+ return (hist_df, "Study complete — redirecting to survey.",
828
+ new_responses, new_submission, new_pidx, new_pk,
829
+ new_history, False,
830
+ bar, gr.update(), visual_html, make_redirect_html(prolific_id),
831
+ gr.update(visible=False), gr.update(visible=False),
832
+ "", "")
833
+
834
+ bar = (f"**Task {pidx + 1} / 3 · "
835
+ f"Submission {submission} / {SUBMISSIONS_PER_TASK}** · "
836
+ f"Participant: `{prolific_id}` — "
837
+ f"✅ **Task complete!** Click Continue when ready.")
838
+ return (hist_df, status_msg,
839
+ new_responses, new_submission, new_pidx, new_pk,
840
+ new_history, new_task_complete,
841
+ bar, gr.update(), visual_html, "",
842
+ gr.update(visible=False), gr.update(visible=True),
843
+ "", "")
844
+
845
+ else:
846
+ # More submissions remain in this task
847
+ new_submission = submission + 1
848
+ bar = (f"**Task {pidx + 1} / 3 · "
849
+ f"Submission {new_submission} / {SUBMISSIONS_PER_TASK}** · "
850
+ f"Participant: `{prolific_id}`")
851
+ return (hist_df, status_msg,
852
+ new_responses, new_submission, pidx, prompt_key,
853
+ new_history, False,
854
+ bar, "", visual_html, "",
855
+ gr.update(visible=True), gr.update(visible=False),
856
+ "", "")
857
+
858
+ score_outputs = [
859
+ history_df, score_status,
860
+ st_responses, st_submission, st_pidx, st_current_prompt,
861
+ st_history, st_task_complete,
862
+ md_status_bar, md_prompt_display, score_visual, redirect_html,
863
+ score_btn, continue_btn,
864
+ sketchpad, submission_box,
865
+ ]
866
+
867
+ score_btn.click(
868
+ fn=do_score,
869
+ inputs=[submission_box, sketchpad, chatbot,
870
+ st_current_prompt, st_prolific,
871
+ st_order, st_pidx, st_submission, st_responses,
872
+ st_history, st_task_complete],
873
+ outputs=score_outputs,
874
+ )
875
+ submission_box.submit(
876
+ fn=do_score,
877
+ inputs=[submission_box, sketchpad, chatbot,
878
+ st_current_prompt, st_prolific,
879
+ st_order, st_pidx, st_submission, st_responses,
880
+ st_history, st_task_complete],
881
+ outputs=score_outputs,
882
+ )
883
+
884
+ # ── [CONT] Continue button: advance to next task ──
885
+ def do_continue(prolific_id, order, pidx, responses):
886
+ new_pidx = pidx + 1
887
+ empty_hist = pd.DataFrame(
888
+ columns=HIST_COLUMNS)
889
+
890
+ if new_pidx >= len(order):
891
+ # Shouldn't happen (button hidden on final task), but handle gracefully
892
+ bar = (f"✅ **Study complete!** You submitted {len(responses)} "
893
+ f"responses. Redirecting to survey…")
894
+ return (1, new_pidx, order[pidx], [], False,
895
+ bar, "", empty_hist, "", "",
896
+ make_redirect_html(prolific_id),
897
+ gr.update(visible=False), gr.update(visible=False),
898
+ [], "")
899
+
900
+ new_pk = order[new_pidx]
901
+ bar = (f"**Task {new_pidx + 1} / 3 · "
902
+ f"Submission 1 / {SUBMISSIONS_PER_TASK}** · "
903
+ f"Participant: `{prolific_id}`")
904
+ return (1, new_pidx, new_pk, [], False,
905
+ bar, make_prompt_html(PROMPTS[new_pk]),
906
+ empty_hist, "", "",
907
+ "",
908
+ gr.update(visible=True), gr.update(visible=False),
909
+ [], "")
910
+
911
+ continue_btn.click(
912
+ fn=do_continue,
913
+ inputs=[st_prolific, st_order, st_pidx, st_responses],
914
+ outputs=[
915
+ st_submission, st_pidx, st_current_prompt, st_history, st_task_complete,
916
+ md_status_bar, md_prompt_display,
917
+ history_df, score_visual, score_status,
918
+ redirect_html,
919
+ score_btn, continue_btn,
920
+ chatbot, submission_box,
921
+ ],
922
+ )
923
+
924
+ # Chat controls
925
+ send_btn.click(fn=chat_reply, inputs=[chatbot, chat_input, sys_prompt],
926
+ outputs=[chatbot, chat_input])
927
+ clear_btn.click(fn=chat_clear, inputs=None, outputs=[chatbot])
928
+
929
+ # ── Admin data download panel ──
930
+ # Set ADMIN_PASSWORD in Space Secrets to enable
931
+ with gr.Accordion("📥 Admin: Download Data", open=False):
932
+ gr.Markdown(
933
+ "*Enter the admin password (set via `ADMIN_PASSWORD` in Space Secrets) "
934
+ "to download collected response data.*"
935
+ )
936
+ with gr.Row():
937
+ admin_pw = gr.Textbox(
938
+ label="Admin Password", type="password",
939
+ placeholder="Enter admin password…", scale=2,
940
+ )
941
+ admin_btn = gr.Button("Authenticate & Download", variant="primary", scale=1)
942
+ admin_status = gr.Markdown("")
943
+ with gr.Row():
944
+ csv_download = gr.File(label="📄 responses.csv", visible=False)
945
+ json_download = gr.File(label="📦 All JSON (zip)", visible=False)
946
+
947
+ def admin_download(password):
948
+ import zipfile, io, tempfile
949
+ expected = os.getenv("ADMIN_PASSWORD", "").strip()
950
+ if not expected:
951
+ return ("⚠️ `ADMIN_PASSWORD` not set in Space Secrets.",
952
+ gr.update(visible=False), gr.update(visible=False))
953
+ if password.strip() != expected:
954
+ return ("❌ Incorrect password.",
955
+ gr.update(visible=False), gr.update(visible=False))
956
+
957
+ outputs = []
958
+
959
+ # Find CSV — try primary, then backup
960
+ csv_path = DATA_DIR / "responses.csv"
961
+ if not csv_path.exists():
962
+ csv_path = BACKUP_DIR / "responses.csv"
963
+ if csv_path.exists():
964
+ outputs.append(("csv", csv_path))
965
+
966
+ # Zip all JSON files from primary or backup
967
+ json_dir = DATA_DIR if any(DATA_DIR.glob("*.json")) else BACKUP_DIR
968
+ json_files = sorted(json_dir.glob("*.json"))
969
+
970
+ csv_out = gr.update(visible=False)
971
+ json_out = gr.update(visible=False)
972
+
973
+ if not outputs and not json_files:
974
+ return ("⚠️ No response data found yet.",
975
+ csv_out, json_out)
976
+
977
+ if outputs:
978
+ csv_out = gr.update(value=str(outputs[0][1]), visible=True)
979
+
980
+ if json_files:
981
+ tmp = tempfile.NamedTemporaryFile(
982
+ suffix=".zip", delete=False, dir=str(BACKUP_DIR))
983
+ with zipfile.ZipFile(tmp.name, "w", zipfile.ZIP_DEFLATED) as zf:
984
+ for jf in json_files:
985
+ zf.write(jf, jf.name)
986
+ json_out = gr.update(value=tmp.name, visible=True)
987
+
988
+ n_csv = sum(1 for _ in open(csv_path)) - 1 if csv_path.exists() else 0
989
+ return (f"✅ Authenticated. **{n_csv} submissions** in CSV, "
990
+ f"**{len(json_files)} participant files** in JSON.",
991
+ csv_out, json_out)
992
+
993
+ admin_btn.click(
994
+ fn=admin_download,
995
+ inputs=[admin_pw],
996
+ outputs=[admin_status, csv_download, json_download],
997
+ )
998
+
999
+
1000
+ # ============================================================
1001
+ # Preload baselines (background)
1002
+ # ============================================================
1003
+ def _preload():
1004
+ import time
1005
+ total_start = time.time()
1006
+ for pk in ["car", "teambuilding", "routine"]:
1007
+ for mi, model_name in enumerate(EMB_MODELS):
1008
+ try:
1009
+ print(f"\n{'='*60}", flush=True)
1010
+ print(f"[preload] {pk} | model {mi+1}/3: {model_name.split('/')[-1]}",
1011
+ flush=True)
1012
+ t0 = time.time()
1013
+ get_baseline(pk, model_name)
1014
+ print(f"[preload] Done in {time.time()-t0:.1f}s", flush=True)
1015
+ except Exception as e:
1016
+ print(f"[WARN] Failed to preload {pk}|{model_name}: {e}", flush=True)
1017
+ print(f"\n[preload] All baselines ready in {time.time()-total_start:.1f}s", flush=True)
1018
+
1019
+ threading.Thread(target=_preload, daemon=True).start()
1020
+
1021
+ if __name__ == "__main__":
1022
+ demo.launch(show_error=True, show_api=False)