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 +103 -2
- pearmut/assignment.py +59 -25
- pearmut/cli.py +241 -150
- pearmut/constants.py +93 -0
- pearmut/results_export.py +1 -1
- pearmut/static/annotate.bundle.js +1 -0
- pearmut/static/annotate.html +160 -0
- pearmut/static/dashboard.bundle.js +1 -1
- pearmut/static/dashboard.html +6 -1
- pearmut/static/index.html +1 -1
- pearmut/static/style.css +8 -0
- pearmut/utils.py +4 -14
- {pearmut-1.0.0.dist-info → pearmut-1.0.2.dist-info}/METADATA +87 -16
- pearmut-1.0.2.dist-info/RECORD +20 -0
- pearmut/static/basic.bundle.js +0 -1
- pearmut/static/basic.html +0 -97
- pearmut-1.0.0.dist-info/RECORD +0 -19
- {pearmut-1.0.0.dist-info → pearmut-1.0.2.dist-info}/WHEEL +0 -0
- {pearmut-1.0.0.dist-info → pearmut-1.0.2.dist-info}/entry_points.txt +0 -0
- {pearmut-1.0.0.dist-info → pearmut-1.0.2.dist-info}/licenses/LICENSE +0 -0
- {pearmut-1.0.0.dist-info → pearmut-1.0.2.dist-info}/top_level.txt +0 -0
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={
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 == "
|
|
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
|
-
#
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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":
|
|
533
|
+
{"user_id": user_id, "item_i": item_i, "annotation": RESET_MARKER},
|
|
504
534
|
)
|
|
505
|
-
|
|
535
|
+
|
|
536
|
+
# Reset only the touched items in all users' progress (shared pool)
|
|
506
537
|
for uid in progress_data[campaign_id]:
|
|
507
|
-
|
|
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:
|