jmcinern commited on
Commit
8ab6a2e
·
verified ·
1 Parent(s): 82be556

allowing resume from left off functionality

Browse files
Files changed (1) hide show
  1. app.py +163 -102
app.py CHANGED
@@ -1,6 +1,9 @@
1
- # app.py
2
  # Two-page Gradio app for open-sourced annotation (Master’s thesis)
3
- # Adds: demo.queue() for concurrency + FileLock for safe CSV appends + ensures outputs/ exists
 
 
 
4
 
5
  import gradio as gr
6
  import pandas as pd
@@ -9,23 +12,17 @@ from itertools import combinations
9
  from pathlib import Path
10
  import json
11
  import hashlib
12
- from filelock import FileLock
13
- from huggingface_hub import HfApi
14
- import os
15
 
16
- HF_DATASET_REPO = "jmcinern/Irish_Prompt_Response_Human_Feedback"
17
- HF_TOKEN = os.environ.get("hf_write")
18
- api = HfApi()
19
-
20
-
21
- PAIRS_CSV = Path("pairs.csv") # columns: run_id, model, source_type, instruction, response, text
22
- OUT_FILE = Path("annotations.csv")
23
- LOCK_FILE = Path("annotations.csv.lock")
24
 
25
  # --- Config ---
26
  K = 4
 
27
  SCHEMA = [
28
- "annotator_type", # Learner | Native
29
  "source_type", # Wiki | Oireachtas
30
  "text",
31
  "model_A",
@@ -36,24 +33,68 @@ SCHEMA = [
36
  "instruction_B",
37
  "response_B",
38
  "timestamp",
 
39
  ]
40
- if not OUT_FILE.exists():
41
- pd.DataFrame(columns=SCHEMA).to_csv(OUT_FILE, index=False)
42
 
43
- # Load pairs (fail clearly if missing)
44
- if not PAIRS_CSV.exists():
45
- raise FileNotFoundError(f"Missing {PAIRS_CSV}. Upload your pairs CSV to outputs/.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
 
47
  pairs_all = pd.read_csv(PAIRS_CSV)
48
 
49
  # --- Helpers for deterministic schedule ---
 
50
  def _shared_texts(df, m1, m2):
51
  t1 = set(df[df["model"] == m1]["text"])
52
  t2 = set(df[df["model"] == m2]["text"])
53
  return list(t1 & t2)
54
 
55
- def _stable_hash(s: str) -> int:
56
- return int(hashlib.sha256(s.encode("utf-8")).hexdigest(), 16)
57
 
58
  def build_comparisons_k(source_type: str, k: int):
59
  df = pairs_all[pairs_all["source_type"] == source_type].copy()
@@ -68,13 +109,13 @@ def build_comparisons_k(source_type: str, k: int):
68
  shared = _shared_texts(df, m1, m2)
69
  if not shared:
70
  continue
71
- keyed = [(_stable_hash(f"{source_type}|{m1}|{m2}|{t}"), t) for t in shared]
72
  keyed.sort(key=lambda x: x[0])
73
  ordered_texts = [t for _, t in keyed]
74
 
75
  chosen = []
76
  idx = 0
77
- while len(chosen) < k and ordered_texts:
78
  chosen.append(ordered_texts[idx % len(ordered_texts)])
79
  idx += 1
80
 
@@ -85,22 +126,23 @@ def build_comparisons_k(source_type: str, k: int):
85
  A, B = (m1, r1), (m2, r2)
86
  else:
87
  A, B = (m2, r2), (m1, r1)
88
- comps.append(
89
- {
90
- "source_type": source_type,
91
- "text": t,
92
- "model_A": A[0],
93
- "instruction_A": A[1]["instruction"],
94
- "response_A": A[1]["response"],
95
- "model_B": B[0],
96
- "instruction_B": B[1]["instruction"],
97
- "response_B": B[1]["response"],
98
- }
99
- )
100
 
101
  comps.sort(key=lambda d: (d["source_type"], d["model_A"], d["model_B"], d["text"]))
102
  return comps
103
 
 
104
  def save_row(annotator_type, item, choice):
105
  row = {
106
  "annotator_type": annotator_type,
@@ -114,45 +156,59 @@ def save_row(annotator_type, item, choice):
114
  "instruction_B": item["instruction_B"],
115
  "response_B": item["response_B"],
116
  "timestamp": time.time(),
 
117
  }
 
 
 
 
118
 
119
- # append safely to local /data/annotations.csv
120
- with FileLock(str(LOCK_FILE)):
121
- df = pd.DataFrame([row])
122
- df.to_csv(OUT_FILE, mode="a", header=False, index=False)
123
 
124
- # prepare filename per role + source
125
- filename = f"annotations_{item['source_type']}_{annotator_type}.csv"
 
 
126
 
127
- print("[DEBUG] HF_TOKEN present?", bool(HF_TOKEN))
128
- print("[DEBUG] OUT_FILE exists?", OUT_FILE.exists(), OUT_FILE)
129
- print("[DEBUG] Uploading as:", filename)
130
 
131
- # push to HF dataset repo
132
- if HF_TOKEN:
 
 
 
 
 
 
 
 
 
 
 
133
  try:
134
- api.upload_file(
135
- path_or_fileobj=str(OUT_FILE),
136
- path_in_repo=filename,
137
- repo_id=HF_DATASET_REPO,
138
- repo_type="dataset",
139
- token=HF_TOKEN,
140
- )
141
- print(f"[HF] Uploaded {filename} to {HF_DATASET_REPO}")
142
-
143
- # List files in the repo to confirm
144
- files = api.list_repo_files(
145
- repo_id=HF_DATASET_REPO,
146
- repo_type="dataset",
147
- token=HF_TOKEN,
148
- )
149
- print("[HF] Current repo files:", files)
150
-
151
- except Exception as e:
152
- print(f"[HF] Upload failed: {e}")
153
-
154
-
155
-
 
 
156
 
157
  QUESTION_MD = (
158
  "**Question:** Which Question–Answer pair exhibits a stronger command of Irish grammar and "
@@ -166,7 +222,7 @@ CONSENT_MD = f"""
166
  You are invited to take part in a study on Large Language Model Irish-language QA quality.
167
  By continuing, you consent to the following:
168
 
169
- - Your annotations will be **anonymised** (we only record whether you are a **Learner** or **Native speaker**).
170
  - The dataset (reference text + model outputs + your choices) will be released **open-source** for both research and commercial purposes.
171
  - No personal data is collected beyond your level of Irish. You may stop at any time before submission.
172
 
@@ -184,8 +240,10 @@ with gr.Blocks() as demo:
184
  with gr.Group(visible=True) as page1:
185
  gr.Markdown(CONSENT_MD)
186
  consent_chk = gr.Checkbox(label="I consent to take part and for my anonymised annotations to be open-sourced.", value=False)
187
- role_dd = gr.Dropdown(["Learner", "Native"], label="Annotator Type (required)", value=None)
188
  source_dd = gr.Dropdown(["Wiki", "Oireachtas"], label="Source (required)", value=None)
 
 
189
  begin_btn = gr.Button("Begin")
190
  gate_msg = gr.Markdown()
191
 
@@ -207,45 +265,54 @@ with gr.Blocks() as demo:
207
  status = gr.Markdown()
208
 
209
  # ---------- State ----------
210
- annotator_type = gr.State("") # Learner | Native
211
- source_state = gr.State(None) # Wiki | Oireachtas
212
- comps_state = gr.State([]) # list of dicts
213
- idx_state = gr.State(0)
214
 
215
  # ---------- Handlers ----------
216
- def begin(consent, role, source):
217
  if not consent:
218
  return ("**Please tick the consent checkbox to proceed.**",
219
  gr.update(visible=True), gr.update(visible=False),
220
- "", "", "", "", "", "", "", "", "", "", "")
221
- if role not in ["Learner", "Native"]:
222
  return ("**Please select your annotator type.**",
223
  gr.update(visible=True), gr.update(visible=False),
224
- "", "", "", "", "", "", "", "", "", "", "")
225
  if source not in ["Wiki", "Oireachtas"]:
226
  return ("**Please select a source (Wikipedia/Oireachtas).**",
227
  gr.update(visible=True), gr.update(visible=False),
228
- "", "", "", "", "", "", "", "", "", "", "")
229
 
230
- comp_list = build_comparisons_k(source, K)
231
- if not comp_list:
232
  return ("**No items found for the selected source.**",
233
  gr.update(visible=True), gr.update(visible=False),
234
- "", "", "", "", "", "", "", "", "", "", "")
 
 
 
 
 
 
 
 
235
 
236
  i = 0
237
- item = comp_list[i]
238
- return ("", # clear gate msg
239
- gr.update(visible=False), gr.update(visible=True), # show page2
240
- f"{i+1} / {len(comp_list)}",
 
241
  item["text"], item["instruction_A"], item["response_A"],
242
  item["instruction_B"], item["response_B"],
243
- role, source, comp_list, i,
244
  gr.update(interactive=True), gr.update(interactive=True))
245
 
246
  begin_btn.click(
247
  begin,
248
- inputs=[consent_chk, role_dd, source_dd],
249
  outputs=[
250
  gate_msg, page1, page2,
251
  counter, ref_text, instA, respA, instB, respB,
@@ -257,30 +324,24 @@ with gr.Blocks() as demo:
257
  def choose(choice, role, source, comp_list, i):
258
  role = (role or "").strip()
259
  if not role or not comp_list:
260
- return ("**No comparisons loaded.**",
261
- gr.skip(), gr.skip(), gr.skip(), gr.skip(),
262
- gr.skip(), gr.skip(),
263
  gr.update(interactive=False), gr.update(interactive=False), i)
264
-
265
  item = comp_list[i]
266
  save_row(role, item, choice)
267
-
268
  i += 1
269
  if i >= len(comp_list):
270
- # Done: still return 10 values
271
  return ("**Done — thank you!**",
272
- f"{len(comp_list)} / {len(comp_list)}",
273
- "", "", "", "", "",
274
  gr.update(interactive=False), gr.update(interactive=False), i)
275
-
276
  nxt = comp_list[i]
277
  return (f"Saved: {choice}",
278
  f"{i+1} / {len(comp_list)}",
279
- nxt["text"], nxt["instruction_A"], nxt["response_A"],
280
- nxt["instruction_B"], nxt["response_B"],
281
  gr.update(interactive=True), gr.update(interactive=True), i)
282
 
283
-
284
  btnA.click(
285
  lambda role, src, comps, i: choose("A", role, src, comps, i),
286
  inputs=[annotator_type, source_state, comps_state, idx_state],
@@ -292,5 +353,5 @@ with gr.Blocks() as demo:
292
  outputs=[status, counter, ref_text, instA, respA, instB, respB, btnA, btnB, idx_state],
293
  )
294
 
295
- # Enable request queueing for concurrent users
296
- demo.queue(max_size=128).launch()
 
1
+ # ab_app_k4_two_page_resume.py
2
  # Two-page Gradio app for open-sourced annotation (Master’s thesis)
3
+ # Adds: resume from where you left off by cross-referencing completed items on HF/local.
4
+ # - Canonical comparison key (A/B-order agnostic)
5
+ # - Loads completed keys from HF annotations.csv (configurable URL) or local OUT_FILE fallback
6
+ # - Skips already-completed items; shows remaining count; supports new role "Tester"
7
 
8
  import gradio as gr
9
  import pandas as pd
 
12
  from pathlib import Path
13
  import json
14
  import hashlib
15
+ import io
16
+ import requests
17
+ import shutil
18
 
19
+ PAIRS_CSV = "./outputs/pairs.csv" # columns: run_id, model, source_type, instruction, response, text
 
 
 
 
 
 
 
20
 
21
  # --- Config ---
22
  K = 4
23
+ OUT_FILE = "./annotations.csv"
24
  SCHEMA = [
25
+ "annotator_type", # Learner | Native | Tester
26
  "source_type", # Wiki | Oireachtas
27
  "text",
28
  "model_A",
 
33
  "instruction_B",
34
  "response_B",
35
  "timestamp",
36
+ "comp_key", # NEW: canonical key for the comparison
37
  ]
 
 
38
 
39
+ # ---------- Utilities ----------
40
+
41
+ def _stable_hash(s: str) -> int:
42
+ return int(hashlib.sha256(s.encode("utf-8")).hexdigest(), 16)
43
+
44
+ def _comp_key(source_type: str, text: str, model_a: str, model_b: str) -> str:
45
+ """Order-agnostic key: source|text|min(model)|max(model) -> sha256 hex."""
46
+ m1, m2 = sorted([str(model_a), str(model_b)])
47
+ raw = f"{source_type}|{text}|{m1}|{m2}"
48
+ return hashlib.sha256(raw.encode("utf-8")).hexdigest()
49
+
50
+ def ensure_outfile_schema():
51
+ """Ensure OUT_FILE exists with SCHEMA; if an older file exists, upgrade it by adding comp_key."""
52
+ if not Path(OUT_FILE).exists():
53
+ pd.DataFrame(columns=SCHEMA).to_csv(OUT_FILE, index=False)
54
+ return
55
+ # If exists, check columns
56
+ try:
57
+ existing = pd.read_csv(OUT_FILE)
58
+ except Exception:
59
+ # Corrupt or empty -> recreate
60
+ pd.DataFrame(columns=SCHEMA).to_csv(OUT_FILE, index=False)
61
+ return
62
+ cols = existing.columns.tolist()
63
+ if cols == SCHEMA:
64
+ return
65
+ # Upgrade: compute comp_key where missing, reorder columns
66
+ # Try to infer comp_key from rows
67
+ if "comp_key" not in existing.columns:
68
+ def infer_key(r):
69
+ try:
70
+ return _comp_key(r.get("source_type", ""), r.get("text", ""), r.get("model_A", ""), r.get("model_B", ""))
71
+ except Exception:
72
+ return ""
73
+ existing["comp_key"] = existing.apply(infer_key, axis=1)
74
+ # Add any missing columns with defaults
75
+ for c in SCHEMA:
76
+ if c not in existing.columns:
77
+ existing[c] = ""
78
+ existing = existing[SCHEMA]
79
+ # Backup and overwrite
80
+ backup = OUT_FILE + ".bak"
81
+ try:
82
+ shutil.copyfile(OUT_FILE, backup)
83
+ except Exception:
84
+ pass
85
+ existing.to_csv(OUT_FILE, index=False)
86
+
87
+ ensure_outfile_schema()
88
 
89
  pairs_all = pd.read_csv(PAIRS_CSV)
90
 
91
  # --- Helpers for deterministic schedule ---
92
+
93
  def _shared_texts(df, m1, m2):
94
  t1 = set(df[df["model"] == m1]["text"])
95
  t2 = set(df[df["model"] == m2]["text"])
96
  return list(t1 & t2)
97
 
 
 
98
 
99
  def build_comparisons_k(source_type: str, k: int):
100
  df = pairs_all[pairs_all["source_type"] == source_type].copy()
 
109
  shared = _shared_texts(df, m1, m2)
110
  if not shared:
111
  continue
112
+ keyed = [( _stable_hash(f"{source_type}|{m1}|{m2}|{t}"), t) for t in shared]
113
  keyed.sort(key=lambda x: x[0])
114
  ordered_texts = [t for _, t in keyed]
115
 
116
  chosen = []
117
  idx = 0
118
+ while len(chosen) < k and len(ordered_texts) > 0:
119
  chosen.append(ordered_texts[idx % len(ordered_texts)])
120
  idx += 1
121
 
 
126
  A, B = (m1, r1), (m2, r2)
127
  else:
128
  A, B = (m2, r2), (m1, r1)
129
+ item = {
130
+ "source_type": source_type,
131
+ "text": t,
132
+ "model_A": A[0],
133
+ "instruction_A": A[1]["instruction"],
134
+ "response_A": A[1]["response"],
135
+ "model_B": B[0],
136
+ "instruction_B": B[1]["instruction"],
137
+ "response_B": B[1]["response"],
138
+ }
139
+ item["comp_key"] = _comp_key(source_type, t, item["model_A"], item["model_B"])
140
+ comps.append(item)
141
 
142
  comps.sort(key=lambda d: (d["source_type"], d["model_A"], d["model_B"], d["text"]))
143
  return comps
144
 
145
+
146
  def save_row(annotator_type, item, choice):
147
  row = {
148
  "annotator_type": annotator_type,
 
156
  "instruction_B": item["instruction_B"],
157
  "response_B": item["response_B"],
158
  "timestamp": time.time(),
159
+ "comp_key": item.get("comp_key", _comp_key(item["source_type"], item["text"], item["model_A"], item["model_B"]))
160
  }
161
+ # Ensure columns order
162
+ df = pd.DataFrame([row])[SCHEMA]
163
+ df.to_csv(OUT_FILE, mode="a", header=False, index=False)
164
+
165
 
166
+ # ---------- Load completed keys from HF or local ----------
 
 
 
167
 
168
+ def _read_csv_from_url(url: str) -> pd.DataFrame:
169
+ resp = requests.get(url, timeout=10)
170
+ resp.raise_for_status()
171
+ return pd.read_csv(io.StringIO(resp.text))
172
 
 
 
 
173
 
174
+ def load_done_keys(annotator_type: str, source_type: str, hf_csv_url: str | None) -> set:
175
+ """
176
+ Return a set of comp_key strings already completed for this annotator_type + source_type.
177
+ Priority: HF CSV URL (if provided) -> local OUT_FILE fallback.
178
+ If comp_key column missing on HF, attempt to reconstruct from row fields.
179
+ """
180
+ df = None
181
+ if hf_csv_url:
182
+ try:
183
+ df = _read_csv_from_url(hf_csv_url)
184
+ except Exception:
185
+ df = None
186
+ if df is None:
187
  try:
188
+ df = pd.read_csv(OUT_FILE)
189
+ except Exception:
190
+ return set()
191
+
192
+ # Filter by role+source
193
+ if "annotator_type" in df.columns:
194
+ df = df[df["annotator_type"].astype(str).str.strip() == annotator_type]
195
+ if "source_type" in df.columns:
196
+ df = df[df["source_type"].astype(str).str.strip() == source_type]
197
+
198
+ # If comp_key exists, use it; else reconstruct
199
+ keys = set()
200
+ if "comp_key" in df.columns:
201
+ keys = set(df["comp_key"].dropna().astype(str).tolist())
202
+ else:
203
+ for _, r in df.iterrows():
204
+ try:
205
+ k = _comp_key(r.get("source_type", ""), r.get("text", ""), r.get("model_A", ""), r.get("model_B", ""))
206
+ if k:
207
+ keys.add(k)
208
+ except Exception:
209
+ pass
210
+ return keys
211
+
212
 
213
  QUESTION_MD = (
214
  "**Question:** Which Question–Answer pair exhibits a stronger command of Irish grammar and "
 
222
  You are invited to take part in a study on Large Language Model Irish-language QA quality.
223
  By continuing, you consent to the following:
224
 
225
+ - Your annotations will be **anonymised** (we only record whether you are a **Learner**, **Native speaker**, or **Tester**).
226
  - The dataset (reference text + model outputs + your choices) will be released **open-source** for both research and commercial purposes.
227
  - No personal data is collected beyond your level of Irish. You may stop at any time before submission.
228
 
 
240
  with gr.Group(visible=True) as page1:
241
  gr.Markdown(CONSENT_MD)
242
  consent_chk = gr.Checkbox(label="I consent to take part and for my anonymised annotations to be open-sourced.", value=False)
243
+ role_dd = gr.Dropdown(["Learner", "Native", "Tester"], label="Annotator Type (required)", value=None)
244
  source_dd = gr.Dropdown(["Wiki", "Oireachtas"], label="Source (required)", value=None)
245
+ with gr.Row():
246
+ hf_csv_url_tb = gr.Textbox(label="(Optional) HF annotations.csv URL for resume", value="", placeholder="https://huggingface.co/datasets/<org>/<repo>/resolve/main/annotations.csv")
247
  begin_btn = gr.Button("Begin")
248
  gate_msg = gr.Markdown()
249
 
 
265
  status = gr.Markdown()
266
 
267
  # ---------- State ----------
268
+ annotator_type = gr.State("") # Learner | Native | Tester
269
+ source_state = gr.State(None) # Wiki | Oireachtas
270
+ comps_state = gr.State([]) # list of dicts (filtered to remaining)
271
+ idx_state = gr.State(0) # index into filtered list
272
 
273
  # ---------- Handlers ----------
274
+ def begin(consent, role, source, hf_csv_url):
275
  if not consent:
276
  return ("**Please tick the consent checkbox to proceed.**",
277
  gr.update(visible=True), gr.update(visible=False),
278
+ "", "", "", "", "", "", "", "", "", "", "", "")
279
+ if role not in ["Learner", "Native", "Tester"]:
280
  return ("**Please select your annotator type.**",
281
  gr.update(visible=True), gr.update(visible=False),
282
+ "", "", "", "", "", "", "", "", "", "", "", "")
283
  if source not in ["Wiki", "Oireachtas"]:
284
  return ("**Please select a source (Wikipedia/Oireachtas).**",
285
  gr.update(visible=True), gr.update(visible=False),
286
+ "", "", "", "", "", "", "", "", "", "", "", "")
287
 
288
+ full_list = build_comparisons_k(source, K)
289
+ if not full_list:
290
  return ("**No items found for the selected source.**",
291
  gr.update(visible=True), gr.update(visible=False),
292
+ "", "", "", "", "", "", "", "", "", "", "", "")
293
+
294
+ done_keys = load_done_keys(role, source, hf_csv_url.strip() or None)
295
+ remaining = [it for it in full_list if it.get("comp_key") not in done_keys]
296
+
297
+ if not remaining:
298
+ return (f"**All done for {role} / {source}.**",
299
+ gr.update(visible=True), gr.update(visible=False),
300
+ "", "", "", "", "", "", "", "", role, source, remaining, 0, gr.update(interactive=False), gr.update(interactive=False))
301
 
302
  i = 0
303
+ item = remaining[i]
304
+ resume_note = f"Resuming from {len(done_keys)} completed; {len(remaining)} remaining."
305
+ return (resume_note,
306
+ gr.update(visible=False), gr.update(visible=True),
307
+ f"{i+1} / {len(remaining)}",
308
  item["text"], item["instruction_A"], item["response_A"],
309
  item["instruction_B"], item["response_B"],
310
+ role, source, remaining, i,
311
  gr.update(interactive=True), gr.update(interactive=True))
312
 
313
  begin_btn.click(
314
  begin,
315
+ inputs=[consent_chk, role_dd, source_dd, hf_csv_url_tb],
316
  outputs=[
317
  gate_msg, page1, page2,
318
  counter, ref_text, instA, respA, instB, respB,
 
324
  def choose(choice, role, source, comp_list, i):
325
  role = (role or "").strip()
326
  if not role or not comp_list:
327
+ return ("**No comparisons loaded.**", gr.skip(), gr.skip(), gr.skip(), gr.skip(),
 
 
328
  gr.update(interactive=False), gr.update(interactive=False), i)
329
+
330
  item = comp_list[i]
331
  save_row(role, item, choice)
332
+
333
  i += 1
334
  if i >= len(comp_list):
 
335
  return ("**Done — thank you!**",
336
+ f"{len(comp_list)} / {len(comp_list)}", "", "", "", "",
 
337
  gr.update(interactive=False), gr.update(interactive=False), i)
338
+
339
  nxt = comp_list[i]
340
  return (f"Saved: {choice}",
341
  f"{i+1} / {len(comp_list)}",
342
+ nxt["text"], nxt["instruction_A"], nxt["response_A"], nxt["instruction_B"], nxt["response_B"],
 
343
  gr.update(interactive=True), gr.update(interactive=True), i)
344
 
 
345
  btnA.click(
346
  lambda role, src, comps, i: choose("A", role, src, comps, i),
347
  inputs=[annotator_type, source_state, comps_state, idx_state],
 
353
  outputs=[status, counter, ref_text, instA, respA, instB, respB, btnA, btnB, idx_state],
354
  )
355
 
356
+ if __name__ == "__main__":
357
+ demo.launch()