pearmut 1.0.0__py3-none-any.whl → 1.0.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pearmut/app.py CHANGED
@@ -4,7 +4,7 @@ from typing import Any
4
4
 
5
5
  from fastapi import FastAPI, Query
6
6
  from fastapi.middleware.cors import CORSMiddleware
7
- from fastapi.responses import JSONResponse, Response
7
+ from fastapi.responses import FileResponse, JSONResponse, Response
8
8
  from fastapi.staticfiles import StaticFiles
9
9
  from pydantic import BaseModel
10
10
 
@@ -17,6 +17,7 @@ from .results_export import (
17
17
  )
18
18
  from .utils import (
19
19
  ROOT,
20
+ TOKEN_MAIN,
20
21
  check_validation_threshold,
21
22
  load_progress_data,
22
23
  save_db_payload,
@@ -192,7 +193,11 @@ async def _dashboard_data(request: DashboardDataRequest):
192
193
  progress_new[user_id] = entry
193
194
 
194
195
  return JSONResponse(
195
- content={"data": progress_new, "validation_threshold": validation_threshold},
196
+ content={
197
+ "data": progress_new,
198
+ "validation_threshold": validation_threshold,
199
+ "assignment": assignment,
200
+ },
196
201
  status_code=200,
197
202
  )
198
203
 
@@ -280,6 +285,91 @@ async def _reset_task(request: ResetTaskRequest):
280
285
  return response
281
286
 
282
287
 
288
+ class PurgeCampaignRequest(BaseModel):
289
+ campaign_id: str
290
+ token: str
291
+
292
+
293
+ @app.post("/purge-campaign")
294
+ async def _purge_campaign(request: PurgeCampaignRequest):
295
+ global progress_data, tasks_data
296
+
297
+ campaign_id = request.campaign_id
298
+ token = request.token
299
+
300
+ if campaign_id not in progress_data:
301
+ return JSONResponse(content="Unknown campaign ID", status_code=400)
302
+ if token != tasks_data[campaign_id]["token"]:
303
+ return JSONResponse(content="Invalid token", status_code=400)
304
+
305
+ # Unlink assets if they exist
306
+ destination = (
307
+ tasks_data[campaign_id].get("info", {}).get("assets", {}).get("destination")
308
+ )
309
+ if destination:
310
+ symlink_path = f"{ROOT}/data/{destination}".rstrip("/")
311
+ if os.path.islink(symlink_path):
312
+ os.remove(symlink_path)
313
+
314
+ # Remove task file
315
+ task_file = f"{ROOT}/data/tasks/{campaign_id}.json"
316
+ if os.path.exists(task_file):
317
+ os.remove(task_file)
318
+
319
+ # Remove output file
320
+ output_file = f"{ROOT}/data/outputs/{campaign_id}.jsonl"
321
+ if os.path.exists(output_file):
322
+ os.remove(output_file)
323
+
324
+ # Remove from in-memory data structures
325
+ del tasks_data[campaign_id]
326
+ del progress_data[campaign_id]
327
+
328
+ # Save updated progress data
329
+ save_progress_data(progress_data)
330
+
331
+ return JSONResponse(content="ok", status_code=200)
332
+
333
+
334
+ class AddCampaignRequest(BaseModel):
335
+ campaign_data: dict[str, Any]
336
+ token_main: str
337
+
338
+
339
+ @app.post("/add-campaign")
340
+ async def _add_campaign(request: AddCampaignRequest):
341
+ global progress_data, tasks_data
342
+
343
+ from .cli import _add_single_campaign
344
+
345
+ if request.token_main != TOKEN_MAIN:
346
+ return JSONResponse(
347
+ content={"error": "Invalid main token. Use the latest one."},
348
+ status_code=400,
349
+ )
350
+
351
+ try:
352
+ server = f"{os.environ.get('PEARMUT_SERVER_URL', 'http://localhost:8001')}"
353
+ _add_single_campaign(request.campaign_data, overwrite=False, server=server)
354
+
355
+ campaign_id = request.campaign_data["campaign_id"]
356
+ with open(f"{ROOT}/data/tasks/{campaign_id}.json", "r") as f:
357
+ tasks_data[campaign_id] = json.load(f)
358
+
359
+ progress_data = load_progress_data(warn=None)
360
+
361
+ return JSONResponse(
362
+ content={
363
+ "status": "ok",
364
+ "campaign_id": campaign_id,
365
+ "token": tasks_data[campaign_id]["token"],
366
+ },
367
+ status_code=200,
368
+ )
369
+ except Exception as e:
370
+ return JSONResponse(content={"error": str(e)}, status_code=400)
371
+
372
+
283
373
  @app.get("/download-annotations")
284
374
  async def _download_annotations(
285
375
  campaign_id: list[str] = Query(),
@@ -345,6 +435,17 @@ if not os.path.exists(static_dir + "index.html"):
345
435
  "Static directory not found. Please build the frontend first."
346
436
  )
347
437
 
438
+ # Serve HTML files directly without redirect
439
+ @app.get("/annotate")
440
+ async def serve_annotate():
441
+ return FileResponse(static_dir + "annotate.html")
442
+
443
+
444
+ @app.get("/dashboard")
445
+ async def serve_dashboard():
446
+ return FileResponse(static_dir + "dashboard.html")
447
+
448
+
348
449
  # Mount user assets from data/assets/
349
450
  assets_dir = f"{ROOT}/data/assets"
350
451
  os.makedirs(assets_dir, exist_ok=True)
pearmut/assignment.py CHANGED
@@ -5,6 +5,7 @@ from typing import Any
5
5
 
6
6
  from fastapi.responses import JSONResponse
7
7
 
8
+ from .constants import PROTOCOL_INSTRUCTIONS
8
9
  from .utils import (
9
10
  RESET_MARKER,
10
11
  check_validation_threshold,
@@ -14,6 +15,15 @@ from .utils import (
14
15
  )
15
16
 
16
17
 
18
+ def _get_instructions(tasks_data: dict, campaign_id: str) -> str:
19
+ """Get instructions: custom if provided, else protocol default, else empty."""
20
+ campaign_info = tasks_data[campaign_id]["info"]
21
+ if "instructions" in campaign_info:
22
+ return campaign_info["instructions"]
23
+ return PROTOCOL_INSTRUCTIONS.get(campaign_info.get("protocol", ""), "")
24
+
25
+
26
+
17
27
  def _completed_response(
18
28
  tasks_data: dict,
19
29
  progress_data: dict,
@@ -132,11 +142,12 @@ def get_i_item_taskbased(
132
142
  "time": user_progress["time"],
133
143
  "info": {
134
144
  "item_i": item_i,
145
+ "instructions": _get_instructions(data_all, campaign_id),
135
146
  }
136
147
  | {
137
148
  k: v
138
149
  for k, v in data_all[campaign_id]["info"].items()
139
- if k.startswith("protocol")
150
+ if k in {"protocol", "sliders", "textfield", "show_model_names"}
140
151
  },
141
152
  "payload": data_all[campaign_id]["data"][user_id][item_i],
142
153
  }
@@ -178,11 +189,12 @@ def get_i_item_singlestream(
178
189
  "time": user_progress["time"],
179
190
  "info": {
180
191
  "item_i": item_i,
192
+ "instructions": _get_instructions(data_all, campaign_id),
181
193
  }
182
194
  | {
183
195
  k: v
184
196
  for k, v in data_all[campaign_id]["info"].items()
185
- if k.startswith("protocol")
197
+ if k in {"protocol", "sliders", "textfield", "show_model_names"}
186
198
  },
187
199
  "payload": data_all[campaign_id]["data"][item_i],
188
200
  }
@@ -224,11 +236,12 @@ def get_next_item_taskbased(
224
236
  "time": user_progress["time"],
225
237
  "info": {
226
238
  "item_i": item_i,
239
+ "instructions": _get_instructions(data_all, campaign_id),
227
240
  }
228
241
  | {
229
242
  k: v
230
243
  for k, v in data_all[campaign_id]["info"].items()
231
- if k.startswith("protocol")
244
+ if k in {"protocol", "sliders", "textfield", "show_model_names"}
232
245
  },
233
246
  "payload": data_all[campaign_id]["data"][user_id][item_i],
234
247
  }
@@ -279,11 +292,12 @@ def get_next_item_singlestream(
279
292
  "progress": progress,
280
293
  "info": {
281
294
  "item_i": item_i,
295
+ "instructions": _get_instructions(data_all, campaign_id),
282
296
  }
283
297
  | {
284
298
  k: v
285
299
  for k, v in data_all[campaign_id]["info"].items()
286
- if k.startswith("protocol")
300
+ if k in {"protocol", "sliders", "textfield", "show_model_names"}
287
301
  },
288
302
  "payload": data_all[campaign_id]["data"][item_i],
289
303
  }
@@ -439,11 +453,12 @@ def get_next_item_dynamic(
439
453
  "progress": user_progress["progress"],
440
454
  "info": {
441
455
  "item_i": item_i,
456
+ "instructions": _get_instructions(tasks_data, campaign_id),
442
457
  }
443
458
  | {
444
459
  k: v
445
460
  for k, v in campaign_data["info"].items()
446
- if k.startswith("protocol")
461
+ if k in {"protocol", "sliders", "textfield", "show_model_names"}
447
462
  },
448
463
  "payload": pruned_item,
449
464
  },
@@ -459,6 +474,26 @@ def _reset_user_time(progress_data: dict, campaign_id: str, user_id: str) -> Non
459
474
  progress_data[campaign_id][user_id]["validations"] = {}
460
475
 
461
476
 
477
+ def _get_user_annotated_items(campaign_id: str, user_id: str) -> set[int]:
478
+ """
479
+ Get the set of item indices that a specific user has annotated.
480
+
481
+ Args:
482
+ campaign_id: The campaign identifier
483
+ user_id: The user identifier
484
+
485
+ Returns:
486
+ Set of item indices (item_i) that the user has annotated
487
+ """
488
+ log = get_db_log(campaign_id)
489
+ user_items = set()
490
+ for entry in log:
491
+ if entry.get("user_id") == user_id and entry.get("annotation") != RESET_MARKER:
492
+ if (item_i := entry.get("item_i")) is not None:
493
+ user_items.add(item_i)
494
+ return user_items
495
+
496
+
462
497
  def reset_task(
463
498
  campaign_id: str,
464
499
  user_id: str,
@@ -468,9 +503,15 @@ def reset_task(
468
503
  """
469
504
  Reset the task progress for the user in the specified campaign.
470
505
  Saves a reset marker to mask existing annotations.
506
+
507
+ Note: Dynamic assignment does not support user-level deletion.
471
508
  """
472
509
  assignment = tasks_data[campaign_id]["info"]["assignment"]
473
- if assignment == "task-based":
510
+ if assignment == "dynamic":
511
+ return JSONResponse(
512
+ content="User-level deletion is not supported for dynamic assignments", status_code=400
513
+ )
514
+ elif assignment == "task-based":
474
515
  # Save reset marker for this user to mask existing annotations
475
516
  num_items = len(tasks_data[campaign_id]["data"][user_id])
476
517
  for item_i in range(num_items):
@@ -482,29 +523,22 @@ def reset_task(
482
523
  _reset_user_time(progress_data, campaign_id, user_id)
483
524
  return JSONResponse(content="ok", status_code=200)
484
525
  elif assignment == "single-stream":
485
- # Save reset markers for all items (shared pool)
486
- num_items = len(tasks_data[campaign_id]["data"])
487
- for item_i in range(num_items):
488
- save_db_payload(
489
- campaign_id,
490
- {"user_id": None, "item_i": item_i, "annotation": RESET_MARKER},
491
- )
492
- # for single-stream reset all progress
493
- for uid in progress_data[campaign_id]:
494
- progress_data[campaign_id][uid]["progress"] = [False] * num_items
495
- _reset_user_time(progress_data, campaign_id, user_id)
496
- return JSONResponse(content="ok", status_code=200)
497
- elif assignment == "dynamic":
498
- # Save reset markers for all items (shared pool like single-stream)
499
- num_items = len(tasks_data[campaign_id]["data"])
500
- for item_i in range(num_items):
526
+ # Find all items that this user has annotated
527
+ user_items = _get_user_annotated_items(campaign_id, user_id)
528
+
529
+ # Save reset markers only for items this user has touched
530
+ for item_i in user_items:
501
531
  save_db_payload(
502
532
  campaign_id,
503
- {"user_id": None, "item_i": item_i, "annotation": RESET_MARKER},
533
+ {"user_id": user_id, "item_i": item_i, "annotation": RESET_MARKER},
504
534
  )
505
- # for dynamic reset all progress (use sets to track models)
535
+
536
+ # Reset only the touched items in all users' progress (shared pool)
506
537
  for uid in progress_data[campaign_id]:
507
- progress_data[campaign_id][uid]["progress"] = [[] for _ in range(num_items)]
538
+ for item_i in user_items:
539
+ progress_data[campaign_id][uid]["progress"][item_i] = False
540
+
541
+ # Reset only the specified user's time
508
542
  _reset_user_time(progress_data, campaign_id, user_id)
509
543
  return JSONResponse(content="ok", status_code=200)
510
544
  else: