quillbench 1.4.1__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.
- quillbench/__init__.py +11 -0
- quillbench/__main__.py +3 -0
- quillbench/accounts.py +344 -0
- quillbench/backup.py +48 -0
- quillbench/builder.py +190 -0
- quillbench/cli.py +297 -0
- quillbench/client.py +65 -0
- quillbench/config.py +588 -0
- quillbench/export.py +112 -0
- quillbench/graphdb.py +182 -0
- quillbench/ldap_auth.py +59 -0
- quillbench/paths.py +47 -0
- quillbench/quality.py +196 -0
- quillbench/registry.py +114 -0
- quillbench/settings.py +64 -0
- quillbench/shard_gold_cache.py +165 -0
- quillbench/storage.py +385 -0
- quillbench/tasks.py +346 -0
- quillbench/ui/__init__.py +0 -0
- quillbench/ui/admin.py +167 -0
- quillbench/ui/app.py +293 -0
- quillbench/ui/assets/icon.png +0 -0
- quillbench/ui/assets/logo.svg +7 -0
- quillbench/ui/builder.py +162 -0
- quillbench/ui/components.py +192 -0
- quillbench/ui/console.py +121 -0
- quillbench/ui/dashboard.py +91 -0
- quillbench/ui/embed.py +15 -0
- quillbench/ui/generic.py +301 -0
- quillbench/ui/query_review.py +578 -0
- quillbench/ui/session.py +58 -0
- quillbench/ui/shortcuts.py +53 -0
- quillbench/ui/theme.py +473 -0
- quillbench/validate.py +147 -0
- quillbench-1.4.1.dist-info/METADATA +205 -0
- quillbench-1.4.1.dist-info/RECORD +40 -0
- quillbench-1.4.1.dist-info/WHEEL +5 -0
- quillbench-1.4.1.dist-info/entry_points.txt +2 -0
- quillbench-1.4.1.dist-info/licenses/LICENSE +26 -0
- quillbench-1.4.1.dist-info/top_level.txt +1 -0
quillbench/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""QuillBench.
|
|
2
|
+
|
|
3
|
+
A config-driven human-annotation platform for research datasets. The software lives
|
|
4
|
+
in its own folder; every paper keeps its annotation data in its own folder (a
|
|
5
|
+
*workspace*), described by a ``quillbench_workspace.yaml`` manifest that the studio reads.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "1.4.1"
|
|
9
|
+
NAME = "QuillBench"
|
|
10
|
+
SHORT_NAME = "QuillBench"
|
|
11
|
+
AUTHOR = "A.C. Pogaku"
|
quillbench/__main__.py
ADDED
quillbench/accounts.py
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Accounts, assignments and session persistence.
|
|
3
|
+
|
|
4
|
+
Two files, two owners:
|
|
5
|
+
|
|
6
|
+
* **Identities** -- who a person is -- live once, in the studio home
|
|
7
|
+
(``accounts.json``, mode 600)::
|
|
8
|
+
|
|
9
|
+
{"users": {"alice": {"password_hash": "pbkdf2_sha256$...", "role": "annotator",
|
|
10
|
+
"display_name": "Alice", "active": true}}}
|
|
11
|
+
|
|
12
|
+
Passwords are stored as salted PBKDF2-SHA256 hashes (standard library, no
|
|
13
|
+
extra dependency).
|
|
14
|
+
|
|
15
|
+
* **Assignments** -- what a person works on in a paper -- live in that paper's
|
|
16
|
+
workspace (``quillbench_assignments.json``), so they travel with the paper's data and
|
|
17
|
+
contain no secrets::
|
|
18
|
+
|
|
19
|
+
{"alice": {"batches": ["demo_001", "demo_002"]}}
|
|
20
|
+
|
|
21
|
+
Session persistence uses an HMAC-signed cookie rather than session state alone:
|
|
22
|
+
annotators work over flaky mobile and SSH-tunnelled connections, and every
|
|
23
|
+
WebSocket reconnect wipes Streamlit's ``session_state``. The cookie carries
|
|
24
|
+
``name:issued:token`` where ``token`` is an HMAC of the name, the issue time and
|
|
25
|
+
the user's ``session_epoch`` under a server-side secret, so it cannot be forged
|
|
26
|
+
client-side. The server rejects a cookie older than the session lifetime, and
|
|
27
|
+
bumping ``session_epoch`` (password change, disabling, signing out) cancels every
|
|
28
|
+
cookie already handed out. It is a cookie rather than a URL parameter because
|
|
29
|
+
URLs leak through history, screen-sharing and proxy logs.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import hashlib
|
|
35
|
+
import hmac
|
|
36
|
+
import json
|
|
37
|
+
import os
|
|
38
|
+
import secrets
|
|
39
|
+
import tempfile
|
|
40
|
+
import threading
|
|
41
|
+
import time
|
|
42
|
+
from pathlib import Path
|
|
43
|
+
|
|
44
|
+
from . import ldap_auth, paths
|
|
45
|
+
from .config import WorkspaceConfig
|
|
46
|
+
|
|
47
|
+
ROLES = ("annotator", "admin")
|
|
48
|
+
_ITERATIONS = 310_000
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
52
|
+
# Password hashing
|
|
53
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
54
|
+
def hash_password(password: str, *, iterations: int = _ITERATIONS) -> str:
|
|
55
|
+
salt = secrets.token_hex(16)
|
|
56
|
+
digest = hashlib.pbkdf2_hmac("sha256", password.encode(), salt.encode(), iterations).hex()
|
|
57
|
+
return f"pbkdf2_sha256${iterations}${salt}${digest}"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def verify_password(password: str, stored: str) -> bool:
|
|
61
|
+
try:
|
|
62
|
+
algo, iterations, salt, digest = stored.split("$", 3)
|
|
63
|
+
except ValueError:
|
|
64
|
+
return False
|
|
65
|
+
if algo != "pbkdf2_sha256":
|
|
66
|
+
return False
|
|
67
|
+
check = hashlib.pbkdf2_hmac("sha256", password.encode(), salt.encode(), int(iterations)).hex()
|
|
68
|
+
return hmac.compare_digest(check, digest)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
72
|
+
# JSON files written atomically with private permissions
|
|
73
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
74
|
+
def _read_json(path: Path) -> dict:
|
|
75
|
+
if not path.exists():
|
|
76
|
+
return {}
|
|
77
|
+
try:
|
|
78
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
79
|
+
except json.JSONDecodeError:
|
|
80
|
+
return {}
|
|
81
|
+
return data if isinstance(data, dict) else {}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def write_json_atomic(path: Path, data: dict, mode: int = 0o600) -> None:
|
|
85
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
86
|
+
fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp")
|
|
87
|
+
try:
|
|
88
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
89
|
+
json.dump(data, fh, indent=2, ensure_ascii=False)
|
|
90
|
+
os.chmod(tmp, mode)
|
|
91
|
+
os.replace(tmp, path)
|
|
92
|
+
except BaseException:
|
|
93
|
+
Path(tmp).unlink(missing_ok=True)
|
|
94
|
+
raise
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
98
|
+
# Identities
|
|
99
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
100
|
+
def load_users(path: Path | None = None) -> dict:
|
|
101
|
+
return _read_json(path or paths.accounts_file()).get("users", {})
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def save_users(users: dict, path: Path | None = None) -> None:
|
|
105
|
+
write_json_atomic(path or paths.accounts_file(), {"users": users})
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def add_user(name: str, password: str, role: str = "annotator", display_name: str = "",
|
|
109
|
+
path: Path | None = None) -> None:
|
|
110
|
+
name = name.strip()
|
|
111
|
+
if not name or ":" in name or "/" in name or "__" in name:
|
|
112
|
+
raise ValueError("A user id must be non-empty and contain no ':', '/' or '__'.")
|
|
113
|
+
if role not in ROLES:
|
|
114
|
+
raise ValueError(f"role must be one of {ROLES}")
|
|
115
|
+
if len(password) < 8:
|
|
116
|
+
raise ValueError("Use a password of at least 8 characters.")
|
|
117
|
+
users = load_users(path)
|
|
118
|
+
if name in users:
|
|
119
|
+
raise ValueError(f"User '{name}' already exists.")
|
|
120
|
+
users[name] = {"password_hash": hash_password(password), "role": role,
|
|
121
|
+
"display_name": display_name or name, "active": True}
|
|
122
|
+
save_users(users, path)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def create_first_admin(name: str, password: str, display_name: str = "") -> None:
|
|
126
|
+
"""Make the first account an admin. Only ever works while no account exists."""
|
|
127
|
+
claim = paths.studio_home() / "first_admin.claim"
|
|
128
|
+
try:
|
|
129
|
+
fd = os.open(claim, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
|
130
|
+
except FileExistsError:
|
|
131
|
+
raise ValueError("Setup is already being completed.") from None
|
|
132
|
+
os.close(fd)
|
|
133
|
+
try:
|
|
134
|
+
if load_users():
|
|
135
|
+
raise ValueError("An admin already exists.")
|
|
136
|
+
add_user(name, password, "admin", display_name)
|
|
137
|
+
finally:
|
|
138
|
+
claim.unlink(missing_ok=True)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def set_password(name: str, password: str, path: Path | None = None) -> None:
|
|
142
|
+
if len(password) < 8:
|
|
143
|
+
raise ValueError("Use a password of at least 8 characters.")
|
|
144
|
+
users = load_users(path)
|
|
145
|
+
if name not in users:
|
|
146
|
+
raise ValueError(f"No user '{name}'.")
|
|
147
|
+
users[name]["password_hash"] = hash_password(password)
|
|
148
|
+
users[name]["session_epoch"] = int(users[name].get("session_epoch", 0)) + 1
|
|
149
|
+
save_users(users, path)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def update_user(name: str, path: Path | None = None, **fields) -> None:
|
|
153
|
+
users = load_users(path)
|
|
154
|
+
if name not in users:
|
|
155
|
+
raise ValueError(f"No user '{name}'.")
|
|
156
|
+
if "role" in fields and fields["role"] not in ROLES:
|
|
157
|
+
raise ValueError(f"role must be one of {ROLES}")
|
|
158
|
+
was_active = users[name].get("active", True)
|
|
159
|
+
users[name].update(fields)
|
|
160
|
+
if was_active and not users[name].get("active", True):
|
|
161
|
+
users[name]["session_epoch"] = int(users[name].get("session_epoch", 0)) + 1
|
|
162
|
+
save_users(users, path)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def revoke_sessions(name: str, path: Path | None = None) -> None:
|
|
166
|
+
"""Cancel every sign-in cookie this person holds, on every device."""
|
|
167
|
+
users = load_users(path)
|
|
168
|
+
if name in users:
|
|
169
|
+
users[name]["session_epoch"] = int(users[name].get("session_epoch", 0)) + 1
|
|
170
|
+
save_users(users, path)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# Verified against when the user id is unknown, so a wrong id takes as long as a
|
|
174
|
+
# wrong password and response time does not reveal which ids exist.
|
|
175
|
+
_DUMMY_HASH = hash_password(secrets.token_hex(16))
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def check_login(name: str, password: str, users: dict) -> bool:
|
|
179
|
+
record = users.get(name)
|
|
180
|
+
if not isinstance(record, dict):
|
|
181
|
+
verify_password(password, _DUMMY_HASH)
|
|
182
|
+
return False
|
|
183
|
+
ok = verify_password(password, str(record.get("password_hash", "")))
|
|
184
|
+
return ok and record.get("active", True)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def sign_in(name: str, password: str, users: dict, ldap: dict | None = None) -> bool:
|
|
188
|
+
"""Check a sign-in against QuillBench's own accounts, then LDAP when it is set up.
|
|
189
|
+
|
|
190
|
+
An account with its own password always uses it, so LDAP can never take over a
|
|
191
|
+
local account. A person LDAP accepts for the first time gets an annotator
|
|
192
|
+
account (no password stored) when ``auto_create`` is on, or must already have
|
|
193
|
+
been added by an admin.
|
|
194
|
+
"""
|
|
195
|
+
record = users.get(name)
|
|
196
|
+
if not ldap_auth.enabled(ldap) or (isinstance(record, dict) and record.get("password_hash")):
|
|
197
|
+
return check_login(name, password, users)
|
|
198
|
+
if isinstance(record, dict) and not record.get("active", True):
|
|
199
|
+
return False
|
|
200
|
+
if record is None and not ldap.get("auto_create", True):
|
|
201
|
+
verify_password(password, _DUMMY_HASH)
|
|
202
|
+
return False
|
|
203
|
+
if not ldap_auth.check(name, password, ldap):
|
|
204
|
+
return False
|
|
205
|
+
if record is None:
|
|
206
|
+
users = load_users()
|
|
207
|
+
if name not in users:
|
|
208
|
+
users[name] = {"role": "annotator", "display_name": name, "active": True, "source": "ldap"}
|
|
209
|
+
save_users(users)
|
|
210
|
+
return True
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
214
|
+
# Sign-in throttling (in-process: the studio runs as one server process)
|
|
215
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
216
|
+
MAX_FAILURES = 5
|
|
217
|
+
LOCKOUT_SECONDS = 15 * 60
|
|
218
|
+
_failures: dict[str, list[float]] = {}
|
|
219
|
+
_failures_lock = threading.Lock()
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _recent_failures(name: str, now: float) -> list[float]:
|
|
223
|
+
recent = [t for t in _failures.get(name, []) if now - t < LOCKOUT_SECONDS]
|
|
224
|
+
if recent:
|
|
225
|
+
_failures[name] = recent
|
|
226
|
+
else:
|
|
227
|
+
_failures.pop(name, None)
|
|
228
|
+
return recent
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def login_locked(name: str) -> bool:
|
|
232
|
+
"""True after MAX_FAILURES wrong passwords for this id in the last LOCKOUT_SECONDS."""
|
|
233
|
+
with _failures_lock:
|
|
234
|
+
return len(_recent_failures(name, time.time())) >= MAX_FAILURES
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def record_login(name: str, ok: bool) -> None:
|
|
238
|
+
with _failures_lock:
|
|
239
|
+
if ok:
|
|
240
|
+
_failures.pop(name, None)
|
|
241
|
+
else:
|
|
242
|
+
_recent_failures(name, time.time())
|
|
243
|
+
_failures.setdefault(name, []).append(time.time())
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def session_epoch(name: str, users: dict) -> int:
|
|
247
|
+
return int(users.get(name, {}).get("session_epoch", 0))
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def is_admin(name: str, users: dict) -> bool:
|
|
251
|
+
return users.get(name, {}).get("role", "") == "admin"
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
255
|
+
# Per-workspace assignments
|
|
256
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
257
|
+
def load_assignments(ws: WorkspaceConfig) -> dict:
|
|
258
|
+
return _read_json(ws.assignments_file)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def save_assignments(ws: WorkspaceConfig, data: dict) -> None:
|
|
262
|
+
write_json_atomic(ws.assignments_file, data, mode=0o640)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def assigned_batches(ws: WorkspaceConfig, name: str, assignments: dict | None = None) -> list[str]:
|
|
266
|
+
assignments = load_assignments(ws) if assignments is None else assignments
|
|
267
|
+
batches = assignments.get(name, {}).get("batches", [])
|
|
268
|
+
return [str(b) for b in batches] if isinstance(batches, list) else []
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def legacy_view(ws: WorkspaceConfig, users: dict, assignments: dict | None = None) -> dict:
|
|
272
|
+
"""``{name: {"role": ..., "batches": [...]}}`` for one workspace.
|
|
273
|
+
|
|
274
|
+
The combined shape the query-review tasks use (role and batches in one
|
|
275
|
+
record); views that need both -- e.g. the adjudicator's live peer labels --
|
|
276
|
+
read this.
|
|
277
|
+
"""
|
|
278
|
+
assignments = load_assignments(ws) if assignments is None else assignments
|
|
279
|
+
return {name: {"role": users.get(name, {}).get("role", "annotator"),
|
|
280
|
+
"batches": assigned_batches(ws, name, assignments)}
|
|
281
|
+
for name in assignments}
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
285
|
+
# Signed session tokens
|
|
286
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
287
|
+
def app_secret(path: Path | None = None) -> str:
|
|
288
|
+
path = path or paths.secret_file()
|
|
289
|
+
if not path.exists():
|
|
290
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
291
|
+
path.write_text(secrets.token_hex(32), encoding="utf-8")
|
|
292
|
+
try:
|
|
293
|
+
path.chmod(0o600)
|
|
294
|
+
except OSError:
|
|
295
|
+
pass
|
|
296
|
+
return path.read_text(encoding="utf-8").strip()
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def make_token(name: str, issued: int, epoch: int, secret: str | None = None) -> str:
|
|
300
|
+
secret = secret or app_secret()
|
|
301
|
+
msg = f"{name}:{issued}:{epoch}".encode()
|
|
302
|
+
return hmac.new(secret.encode(), msg, hashlib.sha256).hexdigest()[:32]
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def cookie_value(name: str, issued: int, users: dict, secret: str | None = None) -> str:
|
|
306
|
+
return f"{name}:{issued}:{make_token(name, issued, session_epoch(name, users), secret)}"
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def session_from_cookie(raw: str, users: dict, secret: str | None = None,
|
|
310
|
+
max_age: int = 30 * 24 * 3600) -> tuple[str, int] | None:
|
|
311
|
+
"""Return ``(user, issued)`` for a valid, unexpired cookie value, else None.
|
|
312
|
+
|
|
313
|
+
DECODE FIRST, THEN SPLIT: the cookie is written through JS
|
|
314
|
+
``encodeURIComponent()``, which encodes the ':' separators as '%3A', so a
|
|
315
|
+
check against the raw value would reject every valid cookie. ``rsplit``
|
|
316
|
+
because the issue time and token can never contain ':', while a user id is
|
|
317
|
+
parsed unambiguously from the right.
|
|
318
|
+
"""
|
|
319
|
+
from urllib.parse import unquote
|
|
320
|
+
|
|
321
|
+
if not raw:
|
|
322
|
+
return None
|
|
323
|
+
parts = unquote(raw).rsplit(":", 2)
|
|
324
|
+
if len(parts) != 3:
|
|
325
|
+
return None
|
|
326
|
+
name, issued_s, token = parts
|
|
327
|
+
try:
|
|
328
|
+
issued = int(issued_s)
|
|
329
|
+
except ValueError:
|
|
330
|
+
return None
|
|
331
|
+
if not 0 <= time.time() - issued <= max_age:
|
|
332
|
+
return None
|
|
333
|
+
record = users.get(name)
|
|
334
|
+
if not record or not record.get("active", True):
|
|
335
|
+
return None
|
|
336
|
+
if hmac.compare_digest(token, make_token(name, issued, session_epoch(name, users), secret)):
|
|
337
|
+
return name, issued
|
|
338
|
+
return None
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def name_from_cookie(raw: str, users: dict, secret: str | None = None,
|
|
342
|
+
max_age: int = 30 * 24 * 3600) -> str | None:
|
|
343
|
+
found = session_from_cookie(raw, users, secret, max_age)
|
|
344
|
+
return found[0] if found else None
|
quillbench/backup.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Backups: every workspace's answers and assignments, plus the studio home.
|
|
3
|
+
|
|
4
|
+
One ``.tar.gz`` per run in ``settings.backup_dir`` (mode 600), the newest
|
|
5
|
+
``backup_keep`` kept. Batch and benchmark files are not included -- they are
|
|
6
|
+
inputs that already live in the papers' own folders and version control; the
|
|
7
|
+
answers are what cannot be recreated.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import tarfile
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from . import paths, registry
|
|
18
|
+
from .settings import load_settings
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def run(dest: Path | None = None, keep: int | None = None) -> Path:
|
|
22
|
+
settings = load_settings()
|
|
23
|
+
dest = Path(dest or settings.backup_dir)
|
|
24
|
+
keep = settings.backup_keep if keep is None else keep
|
|
25
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
26
|
+
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
27
|
+
target = dest / f"quillbench_backup_{stamp}.tar.gz"
|
|
28
|
+
workspaces, _ = registry.load_all(settings)
|
|
29
|
+
tmp = target.with_suffix(".tmp")
|
|
30
|
+
with tarfile.open(tmp, "w:gz") as tar:
|
|
31
|
+
tar.add(paths.studio_home(), arcname="studio_home")
|
|
32
|
+
for ws in workspaces:
|
|
33
|
+
base = f"workspaces/{ws.id}"
|
|
34
|
+
tar.add(ws.manifest, arcname=f"{base}/{ws.manifest.name}")
|
|
35
|
+
if ws.assignments_file.exists():
|
|
36
|
+
tar.add(ws.assignments_file, arcname=f"{base}/{ws.assignments_file.name}")
|
|
37
|
+
for i, d in enumerate(ws.all_progress_dirs):
|
|
38
|
+
if d.exists():
|
|
39
|
+
tar.add(d, arcname=f"{base}/progress_{i}_{d.parent.name}")
|
|
40
|
+
tmp.chmod(0o600)
|
|
41
|
+
tmp.rename(target)
|
|
42
|
+
digest = hashlib.sha256(target.read_bytes()).hexdigest()
|
|
43
|
+
target.with_name(target.name + ".sha256").write_text(f"{digest} {target.name}\n")
|
|
44
|
+
old = sorted(dest.glob("quillbench_backup_*.tar.gz")) + sorted(dest.glob("labelwise_backup_*.tar.gz"))
|
|
45
|
+
for stale in old[:-keep] if keep > 0 else []:
|
|
46
|
+
stale.unlink(missing_ok=True)
|
|
47
|
+
stale.with_name(stale.name + ".sha256").unlink(missing_ok=True)
|
|
48
|
+
return target
|
quillbench/builder.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Create projects from the web interface.
|
|
2
|
+
|
|
3
|
+
Each project made here is its own workspace folder under the studio home, so it
|
|
4
|
+
never touches a workspace that already exists: nothing here edits another
|
|
5
|
+
paper's manifest, batches, progress or assignments.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
import shutil
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import pandas as pd
|
|
15
|
+
import yaml
|
|
16
|
+
|
|
17
|
+
from . import accounts, paths, registry
|
|
18
|
+
from .config import MANIFEST_NAME, ConfigError, WorkspaceConfig, load_workspace
|
|
19
|
+
|
|
20
|
+
BATCH_PREFIX = "batch_"
|
|
21
|
+
TASKS = {"choice": "Label buttons", "rating": "Slider rating", "text": "Short written answer"}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def projects_root() -> Path:
|
|
25
|
+
return paths.studio_home() / "projects"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def is_app_project(ws: WorkspaceConfig) -> bool:
|
|
29
|
+
return projects_root().resolve() in ws.root.parents
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def clean_labels(text: str) -> list[str]:
|
|
33
|
+
"""One label per comma or line, duplicates dropped, order kept."""
|
|
34
|
+
return list(dict.fromkeys(s.strip() for s in re.split(r"[,\n]", text) if s.strip()))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def read_table(source) -> pd.DataFrame:
|
|
38
|
+
df = pd.read_csv(source, dtype=str, keep_default_na=False)
|
|
39
|
+
if df.empty:
|
|
40
|
+
raise ValueError("The CSV has no rows.")
|
|
41
|
+
return df
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def with_ids(df: pd.DataFrame, id_column: str | None, start: int = 1) -> pd.DataFrame:
|
|
45
|
+
"""Give the table the ``id`` column batches need, unique within it."""
|
|
46
|
+
df = df.copy()
|
|
47
|
+
if id_column and id_column != "id":
|
|
48
|
+
df = df.drop(columns=["id"], errors="ignore").rename(columns={id_column: "id"})
|
|
49
|
+
elif not id_column:
|
|
50
|
+
df = df.drop(columns=["id"], errors="ignore")
|
|
51
|
+
df.insert(0, "id", [str(i) for i in range(start, start + len(df))])
|
|
52
|
+
if df["id"].astype(str).str.strip().eq("").any():
|
|
53
|
+
raise ValueError("Some rows have an empty id.")
|
|
54
|
+
repeats = df["id"][df["id"].duplicated()].unique().tolist()
|
|
55
|
+
if repeats:
|
|
56
|
+
raise ValueError(f"These ids appear more than once: {repeats[:5]}")
|
|
57
|
+
return df
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def split(df: pd.DataFrame, size: int, shuffle: bool = False, seed: int = 42) -> list[pd.DataFrame]:
|
|
61
|
+
"""Cut a table into batches of ``size`` rows; 0 keeps it whole."""
|
|
62
|
+
if shuffle:
|
|
63
|
+
df = df.sample(frac=1, random_state=seed).reset_index(drop=True)
|
|
64
|
+
size = size if size > 0 else len(df)
|
|
65
|
+
return [df.iloc[i:i + size] for i in range(0, len(df), size)]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _existing(ws_root: Path) -> list[Path]:
|
|
69
|
+
return sorted((ws_root / "batches").glob(f"{BATCH_PREFIX}*.csv"))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _write_batches(root: Path, parts: list[pd.DataFrame]) -> list[str]:
|
|
73
|
+
taken = [int(m.group(1)) for p in _existing(root)
|
|
74
|
+
if (m := re.fullmatch(rf"{BATCH_PREFIX}(\d+)", p.stem))]
|
|
75
|
+
number = max(taken, default=0)
|
|
76
|
+
names = []
|
|
77
|
+
for part in parts:
|
|
78
|
+
number += 1
|
|
79
|
+
name = f"{BATCH_PREFIX}{number:03d}"
|
|
80
|
+
part.to_csv(root / "batches" / f"{name}.csv", index=False)
|
|
81
|
+
names.append(name)
|
|
82
|
+
return names
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def assign(ws: WorkspaceConfig, batches: list[str], people: list[str], per_batch: int = 1) -> None:
|
|
86
|
+
"""Deal batches out in turn, each to ``per_batch`` different people."""
|
|
87
|
+
if not people:
|
|
88
|
+
return
|
|
89
|
+
data = accounts.load_assignments(ws)
|
|
90
|
+
per_batch = min(max(per_batch, 1), len(people))
|
|
91
|
+
for i, batch in enumerate(batches):
|
|
92
|
+
for k in range(per_batch):
|
|
93
|
+
person = people[(i * per_batch + k) % len(people)]
|
|
94
|
+
queue = data.setdefault(person, {"batches": []})["batches"]
|
|
95
|
+
if batch not in queue:
|
|
96
|
+
queue.append(batch)
|
|
97
|
+
accounts.save_assignments(ws, data)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _new_id(name: str) -> str:
|
|
101
|
+
base = re.sub(r"[^a-z0-9]+", "_", name.lower()).strip("_") or "project"
|
|
102
|
+
taken = {p.name for p in projects_root().glob("*")}
|
|
103
|
+
for manifest in registry.manifest_paths():
|
|
104
|
+
taken.add(registry._peek_id(manifest))
|
|
105
|
+
found, n = base, 1
|
|
106
|
+
while found in taken:
|
|
107
|
+
n += 1
|
|
108
|
+
found = f"{base}_{n}"
|
|
109
|
+
return found
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def create_project(name: str, task: str, instructions: str, table: pd.DataFrame | None,
|
|
113
|
+
display: list[str], owner: str, batch_size: int = 0, shuffle: bool = False,
|
|
114
|
+
labels: list[str] | None = None, scale: dict | None = None,
|
|
115
|
+
people: list[str] | None = None, per_batch: int = 1) -> WorkspaceConfig:
|
|
116
|
+
name = name.strip()
|
|
117
|
+
if not name:
|
|
118
|
+
raise ValueError("Give the project a name.")
|
|
119
|
+
if task not in TASKS:
|
|
120
|
+
raise ValueError(f"Unknown task '{task}'.")
|
|
121
|
+
if task == "choice" and len(labels or []) < 2:
|
|
122
|
+
raise ValueError("Add at least two labels.")
|
|
123
|
+
if task == "rating" and not (scale and scale["min"] < scale["max"] and scale["step"] > 0):
|
|
124
|
+
raise ValueError("The slider needs a minimum below its maximum and a step above 0.")
|
|
125
|
+
if table is not None and not display:
|
|
126
|
+
raise ValueError("Choose at least one column to show.")
|
|
127
|
+
|
|
128
|
+
ws_id = _new_id(name)
|
|
129
|
+
project = {"id": ws_id, "name": name, "task": task, "default": True,
|
|
130
|
+
"batch_prefix": BATCH_PREFIX, "display": list(display or []),
|
|
131
|
+
"instructions": instructions.strip()}
|
|
132
|
+
if task == "choice":
|
|
133
|
+
project["labels"] = list(labels)
|
|
134
|
+
if task == "rating":
|
|
135
|
+
project["scale"] = scale
|
|
136
|
+
manifest = {"workspace": {"id": ws_id, "name": name, "owner": owner,
|
|
137
|
+
"batches_dir": "batches", "progress_dir": "progress"},
|
|
138
|
+
"projects": [project]}
|
|
139
|
+
root = projects_root() / ws_id
|
|
140
|
+
(root / "batches").mkdir(parents=True)
|
|
141
|
+
try:
|
|
142
|
+
(root / MANIFEST_NAME).write_text(yaml.safe_dump(manifest, sort_keys=False, allow_unicode=True),
|
|
143
|
+
encoding="utf-8")
|
|
144
|
+
batches = [] if table is None else _write_batches(root, split(table, batch_size, shuffle))
|
|
145
|
+
registry.add(root)
|
|
146
|
+
except (ConfigError, OSError, ValueError):
|
|
147
|
+
shutil.rmtree(root, ignore_errors=True)
|
|
148
|
+
raise
|
|
149
|
+
ws = load_workspace(root)
|
|
150
|
+
assign(ws, batches, people or [], per_batch)
|
|
151
|
+
return ws
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _update_project(ws: WorkspaceConfig, **fields) -> None:
|
|
155
|
+
raw = yaml.safe_load(ws.manifest.read_text(encoding="utf-8"))
|
|
156
|
+
raw["projects"][0].update(fields)
|
|
157
|
+
ws.manifest.write_text(yaml.safe_dump(raw, sort_keys=False, allow_unicode=True), encoding="utf-8")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def add_data(ws: WorkspaceConfig, table: pd.DataFrame, id_column: str | None, batch_size: int = 0,
|
|
161
|
+
shuffle: bool = False, people: list[str] | None = None, per_batch: int = 1,
|
|
162
|
+
display: list[str] | None = None) -> list[str]:
|
|
163
|
+
"""Add rows to a project made here, as new batches; existing batches are untouched.
|
|
164
|
+
|
|
165
|
+
``display`` picks the columns to show when the project has none chosen yet.
|
|
166
|
+
"""
|
|
167
|
+
seen: list[str] = []
|
|
168
|
+
for path in _existing(ws.root):
|
|
169
|
+
seen += pd.read_csv(path, dtype=str, keep_default_na=False)["id"].tolist()
|
|
170
|
+
new = with_ids(table, id_column, start=len(seen) + 1)
|
|
171
|
+
clash = sorted(set(new["id"]) & set(seen))
|
|
172
|
+
if clash:
|
|
173
|
+
raise ValueError(f"These ids are already in the project: {clash[:5]}")
|
|
174
|
+
shown = [f.column for f in ws.projects[0].display]
|
|
175
|
+
missing = [c for c in shown if c not in new.columns]
|
|
176
|
+
if missing:
|
|
177
|
+
raise ValueError(f"The CSV is missing the column(s) annotators see: {missing}")
|
|
178
|
+
if display and not shown:
|
|
179
|
+
_update_project(ws, display=list(display))
|
|
180
|
+
batches = _write_batches(ws.root, split(new, batch_size, shuffle))
|
|
181
|
+
assign(ws, batches, people or [], per_batch)
|
|
182
|
+
return batches
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def add_labels(ws: WorkspaceConfig, new: list[str]) -> None:
|
|
186
|
+
"""Add label buttons to a project made here. Labels are never removed: saved answers use them."""
|
|
187
|
+
if ws.projects[0].task != "choice":
|
|
188
|
+
raise ValueError("Only label-button projects have labels.")
|
|
189
|
+
current = yaml.safe_load(ws.manifest.read_text(encoding="utf-8"))["projects"][0]["labels"]
|
|
190
|
+
_update_project(ws, labels=list(dict.fromkeys(current + new)))
|