pearmut 0.1.2__py3-none-any.whl → 0.2.0__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
@@ -8,8 +8,8 @@ from fastapi.responses import JSONResponse
8
8
  from fastapi.staticfiles import StaticFiles
9
9
  from pydantic import BaseModel
10
10
 
11
- from .assignment import get_next_item, reset_task, update_progress
12
- from .utils import ROOT, load_progress_data, save_progress_data
11
+ from .assignment import get_i_item, get_next_item, reset_task, update_progress
12
+ from .utils import ROOT, load_progress_data, save_db_payload, save_progress_data
13
13
 
14
14
  os.makedirs(f"{ROOT}/data/outputs", exist_ok=True)
15
15
 
@@ -36,7 +36,7 @@ class LogResponseRequest(BaseModel):
36
36
  campaign_id: str
37
37
  user_id: str
38
38
  item_i: int
39
- payload: Any
39
+ payload: dict[str, Any]
40
40
 
41
41
 
42
42
  @app.post("/log-response")
@@ -45,6 +45,7 @@ async def _log_response(request: LogResponseRequest):
45
45
 
46
46
  campaign_id = request.campaign_id
47
47
  user_id = request.user_id
48
+ item_i = request.item_i
48
49
 
49
50
  if campaign_id not in progress_data:
50
51
  return JSONResponse(content={"error": "Unknown campaign ID"}, status_code=400)
@@ -52,8 +53,8 @@ async def _log_response(request: LogResponseRequest):
52
53
  return JSONResponse(content={"error": "Unknown user ID"}, status_code=400)
53
54
 
54
55
  # append response to the output log
55
- with open(f"{ROOT}/data/outputs/{campaign_id}.jsonl", "a") as log_file:
56
- log_file.write(json.dumps(request.payload, ensure_ascii=False) + "\n")
56
+ save_db_payload(campaign_id, request.payload | {
57
+ "user_id": user_id, "item_i": item_i})
57
58
 
58
59
  # if actions were submitted, we can log time data
59
60
  if "actions" in request.payload:
@@ -68,7 +69,16 @@ async def _log_response(request: LogResponseRequest):
68
69
  for a, b in zip(times, times[1:])
69
70
  ])
70
71
 
71
- update_progress(campaign_id, user_id, tasks_data, progress_data, request.item_i, request.payload)
72
+ # Initialize validation_checks if it doesn't exist
73
+ print(request.payload.keys())
74
+ if "validations" in request.payload:
75
+ if "validations" not in progress_data[campaign_id][user_id]:
76
+ progress_data[campaign_id][user_id]["validations"] = {}
77
+
78
+ progress_data[campaign_id][user_id]["validations"][request.item_i] = request.payload["validations"]
79
+
80
+ update_progress(campaign_id, user_id, tasks_data,
81
+ progress_data, request.item_i, request.payload)
72
82
  save_progress_data(progress_data)
73
83
 
74
84
  return JSONResponse(content={"status": "ok"}, status_code=200)
@@ -97,6 +107,32 @@ async def _get_next_item(request: NextItemRequest):
97
107
  )
98
108
 
99
109
 
110
+ class GetItemRequest(BaseModel):
111
+ campaign_id: str
112
+ user_id: str
113
+ item_i: int
114
+
115
+
116
+ @app.post("/get-i-item")
117
+ async def _get_i_item(request: GetItemRequest):
118
+ campaign_id = request.campaign_id
119
+ user_id = request.user_id
120
+ item_i = request.item_i
121
+
122
+ if campaign_id not in progress_data:
123
+ return JSONResponse(content={"error": "Unknown campaign ID"}, status_code=400)
124
+ if user_id not in progress_data[campaign_id]:
125
+ return JSONResponse(content={"error": "Unknown user ID"}, status_code=400)
126
+
127
+ return get_i_item(
128
+ campaign_id,
129
+ user_id,
130
+ tasks_data,
131
+ progress_data,
132
+ item_i,
133
+ )
134
+
135
+
100
136
  class DashboardDataRequest(BaseModel):
101
137
  campaign_id: str
102
138
  token: str | None = None
@@ -119,6 +155,11 @@ async def _dashboard_data(request: DashboardDataRequest):
119
155
  for user_id, user_val in progress_data[campaign_id].items():
120
156
  # shallow copy
121
157
  entry = dict(user_val)
158
+ entry["validations"] = [
159
+ all(v)
160
+ for v in list(entry.get("validations", {}).values())
161
+ ]
162
+
122
163
 
123
164
  if not is_privileged:
124
165
  entry["token_correct"] = None
@@ -203,10 +244,11 @@ async def _download_progress(
203
244
 
204
245
  static_dir = f"{os.path.dirname(os.path.abspath(__file__))}/static/"
205
246
  if not os.path.exists(static_dir + "index.html"):
206
- raise FileNotFoundError("Static directory not found. Please build the frontend first.")
247
+ raise FileNotFoundError(
248
+ "Static directory not found. Please build the frontend first.")
207
249
 
208
250
  app.mount(
209
251
  "/",
210
252
  StaticFiles(directory=static_dir, html=True, follow_symlink=True),
211
253
  name="static",
212
- )
254
+ )
pearmut/assignment.py CHANGED
@@ -3,6 +3,8 @@ from typing import Any
3
3
 
4
4
  from fastapi.responses import JSONResponse
5
5
 
6
+ from .utils import get_db_log_item
7
+
6
8
 
7
9
  def _completed_response(
8
10
  progress_data: dict,
@@ -37,13 +39,117 @@ def get_next_item(
37
39
  if assignment == "task-based":
38
40
  return get_next_item_taskbased(campaign_id, user_id, tasks_data, progress_data)
39
41
  elif assignment == "single-stream":
40
- return get_next_item_single_stream(campaign_id, user_id, tasks_data, progress_data)
42
+ return get_next_item_singlestream(campaign_id, user_id, tasks_data, progress_data)
41
43
  elif assignment == "dynamic":
42
44
  return get_next_item_dynamic(campaign_id, user_id, tasks_data, progress_data)
43
45
  else:
44
46
  return JSONResponse(content={"error": "Unknown campaign assignment type"}, status_code=400)
45
47
 
46
48
 
49
+ def get_i_item(
50
+ campaign_id: str,
51
+ user_id: str,
52
+ tasks_data: dict,
53
+ progress_data: dict,
54
+ item_i: int,
55
+ ) -> JSONResponse:
56
+ """
57
+ Get a specific item by index for the user in the specified campaign.
58
+ """
59
+ assignment = tasks_data[campaign_id]["info"]["assignment"]
60
+ if assignment == "task-based":
61
+ return get_i_item_taskbased(campaign_id, user_id, tasks_data, progress_data, item_i)
62
+ elif assignment == "single-stream":
63
+ return get_i_item_singlestream(campaign_id, user_id, tasks_data, progress_data, item_i)
64
+ else:
65
+ return JSONResponse(content={"error": "Get item not supported for this assignment type"}, status_code=400)
66
+
67
+
68
+ def get_i_item_taskbased(
69
+ campaign_id: str,
70
+ user_id: str,
71
+ data_all: dict,
72
+ progress_data: dict,
73
+ item_i: int,
74
+ ) -> JSONResponse:
75
+ """
76
+ Get specific item for task-based protocol.
77
+ """
78
+ user_progress = progress_data[campaign_id][user_id]
79
+
80
+ # try to get existing annotations if any
81
+ items_existing = get_db_log_item(campaign_id, user_id, item_i)
82
+ if items_existing:
83
+ # get the latest ones
84
+ payload_existing = items_existing[-1]["annotations"]
85
+
86
+ if item_i < 0 or item_i >= len(data_all[campaign_id]["data"][user_id]):
87
+ return JSONResponse(
88
+ content={"status": "error", "message": "Item index out of range"},
89
+ status_code=400
90
+ )
91
+
92
+ return JSONResponse(
93
+ content={
94
+ "status": "ok",
95
+ "progress": user_progress["progress"],
96
+ "time": user_progress["time"],
97
+ "info": {
98
+ "item_i": item_i,
99
+ } | {
100
+ k: v
101
+ for k, v in data_all[campaign_id]["info"].items()
102
+ if k.startswith("protocol")
103
+ },
104
+ "payload": data_all[campaign_id]["data"][user_id][item_i]
105
+ } | ({"payload_existing": payload_existing} if items_existing else {}),
106
+ status_code=200
107
+ )
108
+
109
+
110
+ def get_i_item_singlestream(
111
+ campaign_id: str,
112
+ user_id: str,
113
+ data_all: dict,
114
+ progress_data: dict,
115
+ item_i: int,
116
+ ) -> JSONResponse:
117
+ """
118
+ Get specific item for single-stream assignment.
119
+ """
120
+ user_progress = progress_data[campaign_id][user_id]
121
+
122
+ # try to get existing annotations if any
123
+ # note the None user_id since it is shared
124
+ items_existing = get_db_log_item(campaign_id, None, item_i)
125
+ if items_existing:
126
+ # get the latest ones
127
+ payload_existing = items_existing[-1]["annotations"]
128
+
129
+ if item_i < 0 or item_i >= len(data_all[campaign_id]["data"]):
130
+ return JSONResponse(
131
+ content={"status": "error", "message": "Item index out of range"},
132
+ status_code=400
133
+ )
134
+
135
+ return JSONResponse(
136
+ content={
137
+ "status": "ok",
138
+ "progress": user_progress["progress"],
139
+ "time": user_progress["time"],
140
+ "info": {
141
+ "item_i": item_i,
142
+ } | {
143
+ k: v
144
+ for k, v in data_all[campaign_id]["info"].items()
145
+ if k.startswith("protocol")
146
+ },
147
+ "payload": data_all[campaign_id]["data"][item_i]
148
+ } | ({"payload_existing": payload_existing} if items_existing else {}),
149
+ status_code=200
150
+ )
151
+
152
+
47
153
  def get_next_item_taskbased(
48
154
  campaign_id: str,
49
155
  user_id: str,
@@ -51,7 +157,7 @@ def get_next_item_taskbased(
51
157
  progress_data: dict,
52
158
  ) -> JSONResponse:
53
159
  """
54
- Get the next item for task-based protocol.
160
+ Get the next item for task-based assignment.
55
161
  """
56
162
  user_progress = progress_data[campaign_id][user_id]
57
163
  if all(user_progress["progress"]):
@@ -59,6 +165,13 @@ def get_next_item_taskbased(
59
165
 
60
166
  # find first incomplete item
61
167
  item_i = min([i for i, v in enumerate(user_progress["progress"]) if not v])
168
+
169
+ # try to get existing annotations if any
170
+ items_existing = get_db_log_item(campaign_id, user_id, item_i)
171
+ if items_existing:
172
+ # get the latest ones
173
+ payload_existing = items_existing[-1]["annotations"]
174
+
62
175
  return JSONResponse(
63
176
  content={
64
177
  "status": "ok",
@@ -71,23 +184,20 @@ def get_next_item_taskbased(
71
184
  for k, v in data_all[campaign_id]["info"].items()
72
185
  if k.startswith("protocol")
73
186
  },
74
- "payload": data_all[campaign_id]["data"][user_id][item_i]},
187
+ "payload": data_all[campaign_id]["data"][user_id][item_i]
188
+ } | ({"payload_existing": payload_existing} if items_existing else {}),
75
189
  status_code=200
76
190
  )
77
191
 
78
192
 
79
- def get_next_item_dynamic(campaign_data: dict, user_id: str, progress_data: dict, data_all: dict):
80
- raise NotImplementedError("Dynamic protocol is not implemented yet.")
81
-
82
-
83
- def get_next_item_single_stream(
193
+ def get_next_item_singlestream(
84
194
  campaign_id: str,
85
195
  user_id: str,
86
196
  data_all: dict,
87
197
  progress_data: dict,
88
198
  ) -> JSONResponse:
89
199
  """
90
- Get the next item for single-stream protocol.
200
+ Get the next item for single-stream assignment.
91
201
  In this mode, all users share the same pool of items.
92
202
  Items are randomly selected from unfinished items.
93
203
 
@@ -104,6 +214,13 @@ def get_next_item_single_stream(
104
214
  incomplete_indices = [i for i, v in enumerate(progress) if not v]
105
215
  item_i = random.choice(incomplete_indices)
106
216
 
217
+ # try to get existing annotations if any
218
+ # note the None user_id since it is shared
219
+ items_existing = get_db_log_item(campaign_id, None, item_i)
220
+ if items_existing:
221
+ # get the latest ones
222
+ payload_existing = items_existing[-1]["annotations"]
223
+
107
224
  return JSONResponse(
108
225
  content={
109
226
  "status": "ok",
@@ -116,16 +233,24 @@ def get_next_item_single_stream(
116
233
  for k, v in data_all[campaign_id]["info"].items()
117
234
  if k.startswith("protocol")
118
235
  },
119
- "payload": data_all[campaign_id]["data"][item_i]},
236
+ "payload": data_all[campaign_id]["data"][item_i]
237
+ } | ({"payload_existing": payload_existing} if items_existing else {}),
120
238
  status_code=200
121
239
  )
122
240
 
123
241
 
242
+
243
+ def get_next_item_dynamic(campaign_data: dict, user_id: str, progress_data: dict, data_all: dict):
244
+ raise NotImplementedError("Dynamic protocol is not implemented yet.")
245
+
246
+
247
+
124
248
  def _reset_user_time(progress_data: dict, campaign_id: str, user_id: str) -> None:
125
249
  """Reset time tracking fields for a user."""
126
250
  progress_data[campaign_id][user_id]["time"] = 0.0
127
251
  progress_data[campaign_id][user_id]["time_start"] = None
128
252
  progress_data[campaign_id][user_id]["time_end"] = None
253
+ progress_data[campaign_id][user_id]["validations"] = {}
129
254
 
130
255
 
131
256
  def reset_task(
@@ -171,7 +296,6 @@ def update_progress(
171
296
  if assignment == "task-based":
172
297
  # even if it's already set it should be fine
173
298
  progress_data[campaign_id][user_id]["progress"][item_i] = True
174
- # TODO: log attention checks/quality?
175
299
  return JSONResponse(content={"status": "ok"}, status_code=200)
176
300
  elif assignment == "single-stream":
177
301
  # progress all users
pearmut/cli.py CHANGED
@@ -214,7 +214,7 @@ def main():
214
214
  import shutil
215
215
 
216
216
  confirm = input(
217
- "Are you sure you want to purge all campaign data? This action cannot be undone. [y/n]"
217
+ "Are you sure you want to purge all campaign data? This action cannot be undone. [y/n] "
218
218
  )
219
219
  if confirm.lower() == 'y':
220
220
  shutil.rmtree(f"{ROOT}/data/tasks", ignore_errors=True)
@@ -225,4 +225,11 @@ input[type="button"].error_delete:hover {
225
225
 
226
226
  #progress span.progress_incomplete:hover {
227
227
  background: #aaa;
228
+ }
229
+
230
+ /* Validation warning indicator */
231
+ .validation_warning {
232
+ margin-right: 5px;
233
+ position: relative;
234
+ top: -5px;
228
235
  }