pearmut 0.0.5__py3-none-any.whl → 0.1.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.
@@ -1,6 +1,5 @@
1
1
  import json
2
2
  import os
3
- import urllib
4
3
  from typing import Any
5
4
 
6
5
  from fastapi import FastAPI, Query
@@ -9,7 +8,7 @@ from fastapi.responses import JSONResponse
9
8
  from fastapi.staticfiles import StaticFiles
10
9
  from pydantic import BaseModel
11
10
 
12
- from .protocols import get_next_item, log_response, reset_task
11
+ from .protocols import get_next_item, reset_task, update_progress
13
12
  from .utils import ROOT, load_progress_data, save_progress_data
14
13
 
15
14
  os.makedirs(f"{ROOT}/data/outputs", exist_ok=True)
@@ -32,15 +31,6 @@ for campaign_id in progress_data.keys():
32
31
  with open(f"{ROOT}/data/tasks/{campaign_id}.json", "r") as f:
33
32
  tasks_data[campaign_id] = json.load(f)
34
33
 
35
- if tasks_data:
36
- # print access dashboard URL for all campaigns
37
- print(
38
- list(tasks_data.values())[0]["info"]["url"] + "/dashboard.html?" + "&".join([
39
- f"campaign_id={urllib.parse.quote_plus(campaign_id)}&token={campaign_data["token"]}"
40
- for campaign_id, campaign_data in tasks_data.items()
41
- ])
42
- )
43
-
44
34
 
45
35
  class LogResponseRequest(BaseModel):
46
36
  campaign_id: str
@@ -61,6 +51,7 @@ async def _log_response(request: LogResponseRequest):
61
51
  if user_id not in progress_data[campaign_id]:
62
52
  return JSONResponse(content={"error": "Unknown user ID"}, status_code=400)
63
53
 
54
+ # append response to the output log
64
55
  with open(f"{ROOT}/data/outputs/{campaign_id}.jsonl", "a") as log_file:
65
56
  log_file.write(json.dumps(request.payload, ensure_ascii=False) + "\n")
66
57
 
@@ -77,7 +68,7 @@ async def _log_response(request: LogResponseRequest):
77
68
  for a, b in zip(times, times[1:])
78
69
  ])
79
70
 
80
- log_response(campaign_id, user_id, tasks_data, progress_data, request.item_i, request.payload)
71
+ update_progress(campaign_id, user_id, tasks_data, progress_data, request.item_i, request.payload)
81
72
  save_progress_data(progress_data)
82
73
 
83
74
  return JSONResponse(content={"status": "ok"}, status_code=200)
@@ -214,4 +205,4 @@ app.mount(
214
205
  "/",
215
206
  StaticFiles(directory=f"{os.path.dirname(os.path.abspath(__file__))}/static/" , html=True, follow_symlink=True),
216
207
  name="static",
217
- )
208
+ )
pearmut/cli.py CHANGED
@@ -1,9 +1,15 @@
1
+ """
2
+ Command-line interface for managing and running the Pearmut server.
3
+ """
4
+
1
5
  import argparse
2
6
  import hashlib
3
7
  import json
4
8
  import os
5
9
  import urllib.parse
6
10
 
11
+ import psutil
12
+
7
13
  from .utils import ROOT, load_progress_data
8
14
 
9
15
  os.makedirs(f"{ROOT}/data/tasks", exist_ok=True)
@@ -13,39 +19,58 @@ load_progress_data(warn=None)
13
19
  def _run(args_unknown):
14
20
  import uvicorn
15
21
 
22
+ from .app import app, tasks_data
23
+
16
24
  args = argparse.ArgumentParser()
17
- args.add_argument('--dev', action='store_true', help='Re-build frontend on start')
25
+ args.add_argument(
26
+ "--port", type=int, default=8001,
27
+ help="Port to run the server on"
28
+ )
29
+ args.add_argument(
30
+ "--server", default="http://localhost:8001",
31
+ help="Prefix server URL for protocol links"
32
+ )
18
33
  args = args.parse_args(args_unknown)
19
34
 
20
- if args.dev:
21
- # build frontend
22
- from pynpm import NPMPackage
23
- pkg = NPMPackage('web/package.json')
24
- pkg.install()
25
- pkg.run_script('build')
35
+ # print access dashboard URL for all campaigns
36
+ if tasks_data:
37
+ print(
38
+ args.server + "/dashboard.html?" + "&".join([
39
+ f"campaign_id={urllib.parse.quote_plus(campaign_id)}&token={campaign_data["token"]}"
40
+ for campaign_id, campaign_data in tasks_data.items()
41
+ ])
42
+ )
26
43
 
27
- from .run import app
28
44
  uvicorn.run(
29
45
  app,
30
46
  host="127.0.0.1",
31
- port=8001,
32
- # reload=reload_enabled,
47
+ port=args.port,
48
+ reload=False,
33
49
  # log_level="info",
34
- # app_dir="src",
35
- # factory=False # factory=False means it expects 'app' to be a variable
36
50
  )
37
51
 
38
52
 
39
53
  def _add_campaign(args_unknown):
54
+ """
55
+ Add a new campaign from a JSON data file.
56
+ """
40
57
  import random
41
58
 
42
59
  import wonderwords
43
60
 
44
61
  args = argparse.ArgumentParser()
45
- args.add_argument('data_file', type=str,
46
- help='Path to the campaign data file')
47
- args.add_argument("-o", "--overwrite", action="store_true",
48
- help="Overwrite existing campaign if it exists")
62
+ args.add_argument(
63
+ 'data_file', type=str,
64
+ help='Path to the campaign data file'
65
+ )
66
+ args.add_argument(
67
+ "-o", "--overwrite", action="store_true",
68
+ help="Overwrite existing campaign if it exists"
69
+ )
70
+ args.add_argument(
71
+ "--server", default="http://localhost:8001",
72
+ help="Prefix server URL for protocol links"
73
+ )
49
74
  args = args.parse_args(args_unknown)
50
75
 
51
76
  with open(args.data_file, 'r') as f:
@@ -61,13 +86,30 @@ def _add_campaign(args_unknown):
61
86
  )
62
87
  exit(1)
63
88
 
89
+ if "info" not in campaign_data:
90
+ raise ValueError("Campaign data must contain 'info' field.")
91
+ if "data" not in campaign_data:
92
+ raise ValueError("Campaign data must contain 'data' field.")
93
+ if "type" not in campaign_data["info"]:
94
+ raise ValueError("Campaign 'info' must contain 'type' field.")
95
+ if "template" not in campaign_data["info"]:
96
+ raise ValueError("Campaign 'info' must contain 'template' field.")
97
+
64
98
  # use random words for identifying users
65
99
  rng = random.Random(campaign_data["campaign_id"])
66
100
  rword = wonderwords.RandomWord(rng=rng)
67
101
  if campaign_data["info"]["type"] == "task-based":
68
102
  tasks = campaign_data["data"]
103
+ if not isinstance(tasks, list):
104
+ raise ValueError("Task-based campaign 'data' must be a list of tasks.")
105
+ if not all(isinstance(task, list) for task in tasks):
106
+ raise ValueError("Each task in task-based campaign 'data' must be a list of items.")
69
107
  amount = len(tasks)
70
108
  elif campaign_data["info"]["type"] == "dynamic":
109
+ if "num_users" not in campaign_data:
110
+ raise ValueError("Dynamic campaigns must specify 'num_users'.")
111
+ if not isinstance(campaign_data["data"], list):
112
+ raise ValueError("Dynamic campaign 'data' must be a list of items.")
71
113
  amount = campaign_data["num_users"]
72
114
  else:
73
115
  raise ValueError(
@@ -75,6 +117,7 @@ def _add_campaign(args_unknown):
75
117
 
76
118
  user_ids = []
77
119
  while len(user_ids) < amount:
120
+ # generate random user IDs
78
121
  new_id = f"{rword.random_words(amount=1, include_parts_of_speech=['adjective'])[0]}-{rword.random_words(amount=1, include_parts_of_speech=['noun'])[0]}"
79
122
  if new_id not in user_ids:
80
123
  user_ids.append(new_id)
@@ -83,11 +126,6 @@ def _add_campaign(args_unknown):
83
126
  for user_id in user_ids
84
127
  ]
85
128
 
86
- server_url = campaign_data["info"].get(
87
- "url",
88
- "127.0.0.1:8001", # by default local server
89
- ).removesuffix("/")
90
-
91
129
  campaign_data["data"] = {
92
130
  user_id: task
93
131
  for user_id, task in zip(user_ids, tasks)
@@ -106,7 +144,7 @@ def _add_campaign(args_unknown):
106
144
  "time_end": None,
107
145
  "time": 0,
108
146
  "url": (
109
- f"{server_url}/{campaign_data["info"]["template"]}.html"
147
+ f"{args.server}/{campaign_data["info"]["template"]}.html"
110
148
  f"?campaign_id={urllib.parse.quote_plus(campaign_data['campaign_id'])}"
111
149
  f"&user_id={user_id}"
112
150
  ),
@@ -125,7 +163,7 @@ def _add_campaign(args_unknown):
125
163
  json.dump(progress_data, f, indent=2, ensure_ascii=False)
126
164
 
127
165
  print(
128
- f"{server_url}/dashboard.html"
166
+ f"{args.server}/dashboard.html"
129
167
  f"?campaign_id={urllib.parse.quote_plus(campaign_data['campaign_id'])}"
130
168
  f"&token={campaign_data['token']}"
131
169
  )
@@ -136,10 +174,20 @@ def _add_campaign(args_unknown):
136
174
 
137
175
 
138
176
  def main():
177
+ """
178
+ Main entry point for the CLI.
179
+ """
139
180
  args = argparse.ArgumentParser()
140
181
  args.add_argument('command', type=str, choices=['run', 'add', 'purge'])
141
182
  args, args_unknown = args.parse_known_args()
142
183
 
184
+ # enforce that only one pearmut process is running
185
+ for p in psutil.process_iter():
186
+ if "pearmut" == p.name() and p.pid != os.getpid():
187
+ print("Exit all running pearmut processes before running more commands.")
188
+ print(p)
189
+ exit(1)
190
+
143
191
  if args.command == 'run':
144
192
  _run(args_unknown)
145
193
  elif args.command == 'add':
pearmut/protocols.py CHANGED
@@ -9,6 +9,9 @@ def get_next_item(
9
9
  tasks_data: dict,
10
10
  progress_data: dict,
11
11
  ) -> JSONResponse:
12
+ """
13
+ Get the next item for the user in the specified campaign.
14
+ """
12
15
  if tasks_data[campaign_id]["info"]["type"] == "task-based":
13
16
  return get_next_item_taskbased(campaign_id, user_id, tasks_data, progress_data)
14
17
  elif tasks_data[campaign_id]["info"]["type"] == "dynamic":
@@ -23,6 +26,9 @@ def get_next_item_taskbased(
23
26
  data_all: dict,
24
27
  progress_data: dict,
25
28
  ) -> JSONResponse:
29
+ """
30
+ Get the next item for task-based protocol.
31
+ """
26
32
  if all(progress_data[campaign_id][user_id]["progress"]):
27
33
  # all items completed
28
34
  # TODO: add check for data quality
@@ -51,7 +57,7 @@ def get_next_item_taskbased(
51
57
  "total": len(data_all[campaign_id]["data"][user_id]),
52
58
  },
53
59
  "info": {
54
- "status_message": data_all[campaign_id]["info"].get("status_message", ""),
60
+ "instructions": data_all[campaign_id]["info"].get("instructions", ""),
55
61
  "item_i": item_i,
56
62
  } | {
57
63
  k: v
@@ -74,6 +80,9 @@ def reset_task(
74
80
  tasks_data: dict,
75
81
  progress_data: dict,
76
82
  ) -> JSONResponse:
83
+ """
84
+ Reset the task progress for the user in the specified campaign.
85
+ """
77
86
  if tasks_data[campaign_id]["info"]["type"] == "task-based":
78
87
  progress_data[campaign_id][user_id]["progress"] = [False]*len(tasks_data[campaign_id]["data"][user_id])
79
88
  progress_data[campaign_id][user_id]["time"] = 0.0
@@ -89,7 +98,7 @@ def reset_task(
89
98
 
90
99
 
91
100
 
92
- def log_response(
101
+ def update_progress(
93
102
  campaign_id: str,
94
103
  user_id: str,
95
104
  tasks_data: dict,
@@ -97,9 +106,13 @@ def log_response(
97
106
  item_i: int,
98
107
  payload: Any,
99
108
  ) -> JSONResponse:
109
+ """
110
+ Log the user's response for the specified item in the campaign.
111
+ """
100
112
  if tasks_data[campaign_id]["info"]["type"] == "task-based":
101
113
  # even if it's already set it should be fine
102
114
  progress_data[campaign_id][user_id]["progress"][item_i] = True
115
+ # TODO: log attention checks/quality?
103
116
  return JSONResponse(content={"status": "ok"}, status_code=200)
104
117
  elif tasks_data[campaign_id]["info"]["type"] == "dynamic":
105
118
  return JSONResponse(content={"status": "error", "message": "Dynamic protocol logging not implemented yet."}, status_code=400)
@@ -1,7 +1,7 @@
1
1
  body {
2
2
  margin: 0;
3
3
  padding: 0;
4
- background: linear-gradient(135deg, #b9e2a1 0%, #dbcfe7 100%);
4
+ background: linear-gradient(135deg, #b9e2a1 0%, #e7e2cf 100%);
5
5
  background-attachment: fixed;
6
6
  }
7
7
 
@@ -14,7 +14,7 @@ body {
14
14
  width: 30%;
15
15
  background-color: #fffc;
16
16
  padding: 10px;
17
- border-radius: 4px;
17
+ border-radius: 8px;
18
18
  vertical-align: top;
19
19
  margin-left: 5px;
20
20
  }
@@ -32,25 +32,29 @@ body {
32
32
  input[type="button"] {
33
33
  background: #fff;
34
34
  border: none;
35
- border-radius: 5px;
35
+ border-radius: 8px;
36
36
  font-size: large;
37
+ box-shadow: 0 2px 4px #0001;
37
38
  }
38
39
 
39
- .button_navigation.button_selected {
40
- background: #8db3ec !important;
41
- }
42
40
 
43
41
  input[type="button"]:hover:not(:disabled) {
44
- background: #ffe;
42
+ background: #ffd;
45
43
  cursor: pointer;
46
44
  }
47
45
 
46
+ input[type="button"]:disabled {
47
+ background: #bbb;
48
+ cursor: not-allowed;
49
+ }
50
+
48
51
  label {
49
52
  user-select: none;
50
53
  }
51
54
 
52
55
  .white-box {
53
- border-radius: 5pt;
56
+ border-radius: 8px;
54
57
  background: #fff;
55
58
  padding: 15pt;
59
+ box-shadow: 0 4px 6px #0000001a
56
60
  }