lanczos commited on
Commit
083fb75
·
verified ·
1 Parent(s): 62a142e

deploy: labeling server

Browse files
labeling/static/app.js CHANGED
@@ -3,6 +3,9 @@
3
  const AXES = ["art_style", "color", "art_medium", "lighting"];
4
  const TOKEN_STORAGE_KEY = "aamcq_token";
5
  const THEME_STORAGE_KEY = "aamcq_theme";
 
 
 
6
 
7
  function setTheme(theme) {
8
  document.documentElement.setAttribute("data-theme", theme);
@@ -37,6 +40,11 @@ async function fetchJSON(path, init) {
37
  // 2. localStorage (returning visitor)
38
  // 3. POST /api/register (fresh anonymous session; only works when the
39
  // server was launched with --anonymous-register)
 
 
 
 
 
40
  async function ensureToken() {
41
  const urlToken = new URL(window.location.href).searchParams.get("token");
42
  if (urlToken) {
@@ -45,7 +53,8 @@ async function ensureToken() {
45
  }
46
  const stored = localStorage.getItem(TOKEN_STORAGE_KEY);
47
  if (stored) return stored;
48
- const resp = await fetch("/api/register", { method: "POST" });
 
49
  if (!resp.ok) {
50
  throw new Error(
51
  "No ?token= in URL and anonymous registration is disabled on this server."
@@ -102,6 +111,18 @@ async function loadNext(token) {
102
  ? `All done — you labeled ${labeled} items. Thank you!`
103
  : `All items are fully labeled (you contributed ${labeled}). Thank you!`;
104
  card.innerHTML = `<p class='done'>${msg}</p>`;
 
 
 
 
 
 
 
 
 
 
 
 
105
  submit.disabled = true;
106
  updateProgress(data.labeled, data.cap);
107
  return;
 
3
  const AXES = ["art_style", "color", "art_medium", "lighting"];
4
  const TOKEN_STORAGE_KEY = "aamcq_token";
5
  const THEME_STORAGE_KEY = "aamcq_theme";
6
+ const ROUNDS_KEY = "aamcq_rounds_done";
7
+ const FIRST_SESSION_CAP = 20;
8
+ const REPEAT_SESSION_CAP = 10;
9
 
10
  function setTheme(theme) {
11
  document.documentElement.setAttribute("data-theme", theme);
 
40
  // 2. localStorage (returning visitor)
41
  // 3. POST /api/register (fresh anonymous session; only works when the
42
  // server was launched with --anonymous-register)
43
+ function nextSessionCap() {
44
+ const rounds = parseInt(localStorage.getItem(ROUNDS_KEY) || "0", 10);
45
+ return rounds === 0 ? FIRST_SESSION_CAP : REPEAT_SESSION_CAP;
46
+ }
47
+
48
  async function ensureToken() {
49
  const urlToken = new URL(window.location.href).searchParams.get("token");
50
  if (urlToken) {
 
53
  }
54
  const stored = localStorage.getItem(TOKEN_STORAGE_KEY);
55
  if (stored) return stored;
56
+ const cap = nextSessionCap();
57
+ const resp = await fetch(`/api/register?cap=${cap}`, { method: "POST" });
58
  if (!resp.ok) {
59
  throw new Error(
60
  "No ?token= in URL and anonymous registration is disabled on this server."
 
111
  ? `All done — you labeled ${labeled} items. Thank you!`
112
  : `All items are fully labeled (you contributed ${labeled}). Thank you!`;
113
  card.innerHTML = `<p class='done'>${msg}</p>`;
114
+ if (data.reason === "cap_reached") {
115
+ const btn = document.createElement("button");
116
+ btn.id = "new-session";
117
+ btn.textContent = `Start a new session (+${REPEAT_SESSION_CAP} more)`;
118
+ btn.addEventListener("click", () => {
119
+ const rounds = parseInt(localStorage.getItem(ROUNDS_KEY) || "0", 10);
120
+ localStorage.setItem(ROUNDS_KEY, String(rounds + 1));
121
+ localStorage.removeItem(TOKEN_STORAGE_KEY);
122
+ location.reload();
123
+ });
124
+ card.appendChild(btn);
125
+ }
126
  submit.disabled = true;
127
  updateProgress(data.labeled, data.cap);
128
  return;
labeling/static/index.html CHANGED
@@ -4,7 +4,7 @@
4
  <meta charset="utf-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
  <title>AestheticMCQ — Annotation</title>
7
- <link rel="stylesheet" href="/style.css?v=7" />
8
  <script>
9
  // Apply saved theme before CSS paints to avoid a flash.
10
  (function () {
@@ -41,6 +41,6 @@
41
  <span id="error"></span>
42
  </footer>
43
  </main>
44
- <script src="/app.js?v=7"></script>
45
  </body>
46
  </html>
 
4
  <meta charset="utf-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
  <title>AestheticMCQ — Annotation</title>
7
+ <link rel="stylesheet" href="/style.css?v=8" />
8
  <script>
9
  // Apply saved theme before CSS paints to avoid a flash.
10
  (function () {
 
41
  <span id="error"></span>
42
  </footer>
43
  </main>
44
+ <script src="/app.js?v=9"></script>
45
  </body>
46
  </html>
labeling/static/style.css CHANGED
@@ -179,4 +179,17 @@ button#submit:disabled {
179
  }
180
 
181
  #error { color: #e66; font-size: 0.9rem; }
182
- .done { text-align: center; font-size: 1.2rem; color: var(--muted); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
179
  }
180
 
181
  #error { color: #e66; font-size: 0.9rem; }
182
+ .done { text-align: center; font-size: 1.2rem; color: var(--muted); margin: 24px 0 12px; }
183
+
184
+ button#new-session {
185
+ display: block;
186
+ margin: 12px auto 0;
187
+ background: transparent;
188
+ color: var(--accent);
189
+ border: 1px solid var(--accent);
190
+ padding: 8px 16px;
191
+ border-radius: 6px;
192
+ font-size: 0.95rem;
193
+ cursor: pointer;
194
+ }
195
+ button#new-session:hover { background: var(--card); }
src/aamcq/annotation/api.py CHANGED
@@ -85,8 +85,12 @@ def create_app(
85
  raise HTTPException(status_code=401, detail="invalid token")
86
  return annotator_id
87
 
 
 
 
 
88
  def _next_task_payload(annotator_id: str, conn: sqlite3.Connection, n_done: int) -> dict:
89
- cap = app.state.max_labels_per_annotator
90
  if cap is not None and n_done >= cap:
91
  return {"done": True, "reason": "cap_reached", "labeled": n_done, "cap": cap}
92
  if app.state.pool_mode:
@@ -105,8 +109,16 @@ def create_app(
105
  }
106
 
107
  @app.post("/api/register")
108
- def api_register(conn: sqlite3.Connection = Depends(get_conn)):
109
- """Mint a fresh anonymous annotator. Only enabled when anonymous_register."""
 
 
 
 
 
 
 
 
110
  if not app.state.anonymous_register:
111
  raise HTTPException(status_code=404, detail="anonymous register disabled")
112
  existing = {row["annotator_id"] for row in conn.execute(
@@ -120,8 +132,8 @@ def create_app(
120
  n += 1
121
  if n > 8:
122
  raise HTTPException(status_code=500, detail="could not mint unique id")
123
- tokens = bootstrap_annotators(conn, [candidate])
124
- return {"annotator_id": candidate, "token": tokens[candidate]}
125
 
126
  @app.get("/api/task")
127
  def api_task(
 
85
  raise HTTPException(status_code=401, detail="invalid token")
86
  return annotator_id
87
 
88
+ def _effective_cap(conn: sqlite3.Connection, annotator_id: str) -> int | None:
89
+ per = dbmod.get_annotator_cap(conn, annotator_id)
90
+ return per if per is not None else app.state.max_labels_per_annotator
91
+
92
  def _next_task_payload(annotator_id: str, conn: sqlite3.Connection, n_done: int) -> dict:
93
+ cap = _effective_cap(conn, annotator_id)
94
  if cap is not None and n_done >= cap:
95
  return {"done": True, "reason": "cap_reached", "labeled": n_done, "cap": cap}
96
  if app.state.pool_mode:
 
109
  }
110
 
111
  @app.post("/api/register")
112
+ def api_register(
113
+ cap: int | None = Query(default=None, ge=1, le=10000),
114
+ conn: sqlite3.Connection = Depends(get_conn),
115
+ ):
116
+ """Mint a fresh anonymous annotator. Only enabled when anonymous_register.
117
+
118
+ Optional `?cap=N` sets a per-annotator label cap that overrides the
119
+ server default (used by the frontend to give the first session a
120
+ larger quota than subsequent ones).
121
+ """
122
  if not app.state.anonymous_register:
123
  raise HTTPException(status_code=404, detail="anonymous register disabled")
124
  existing = {row["annotator_id"] for row in conn.execute(
 
132
  n += 1
133
  if n > 8:
134
  raise HTTPException(status_code=500, detail="could not mint unique id")
135
+ tokens = bootstrap_annotators(conn, [candidate], cap=cap)
136
+ return {"annotator_id": candidate, "token": tokens[candidate], "cap": cap}
137
 
138
  @app.get("/api/task")
139
  def api_task(
src/aamcq/annotation/assignment.py CHANGED
@@ -61,12 +61,17 @@ def assign_items_round_robin(
61
 
62
 
63
  def bootstrap_annotators(
64
- conn, annotator_ids: Iterable[str]
65
  ) -> dict[str, str]:
66
- """Create annotator rows with freshly minted tokens. Returns {annotator_id: token}."""
 
 
 
 
 
67
  tokens: dict[str, str] = {}
68
  for aid in annotator_ids:
69
  token = dbmod.mint_token()
70
- dbmod.insert_annotator(conn, aid, token)
71
  tokens[aid] = token
72
  return tokens
 
61
 
62
 
63
  def bootstrap_annotators(
64
+ conn, annotator_ids: Iterable[str], cap: int | None = None,
65
  ) -> dict[str, str]:
66
+ """Create annotator rows with freshly minted tokens. Returns {annotator_id: token}.
67
+
68
+ `cap` sets a per-annotator label cap that overrides the server default for
69
+ just these annotators (used for anonymous registration to customise the
70
+ cap per session).
71
+ """
72
  tokens: dict[str, str] = {}
73
  for aid in annotator_ids:
74
  token = dbmod.mint_token()
75
+ dbmod.insert_annotator(conn, aid, token, cap=cap)
76
  tokens[aid] = token
77
  return tokens
src/aamcq/annotation/db.py CHANGED
@@ -25,7 +25,8 @@ CREATE TABLE IF NOT EXISTS items (
25
  CREATE TABLE IF NOT EXISTS annotators (
26
  annotator_id TEXT PRIMARY KEY,
27
  token TEXT NOT NULL UNIQUE,
28
- created_at REAL NOT NULL
 
29
  );
30
  CREATE TABLE IF NOT EXISTS assignments (
31
  item_id TEXT NOT NULL,
@@ -82,6 +83,11 @@ def connect(db_path: str | Path) -> sqlite3.Connection:
82
 
83
  def init_schema(conn: sqlite3.Connection) -> None:
84
  conn.executescript(SCHEMA)
 
 
 
 
 
85
 
86
 
87
  def mint_token() -> str:
@@ -95,13 +101,26 @@ def insert_item(conn: sqlite3.Connection, item_id: str, payload: dict, is_gold:
95
  )
96
 
97
 
98
- def insert_annotator(conn: sqlite3.Connection, annotator_id: str, token: str) -> None:
 
 
 
 
 
99
  conn.execute(
100
- "INSERT OR REPLACE INTO annotators(annotator_id, token, created_at) VALUES (?, ?, ?)",
101
- (annotator_id, token, time.time()),
 
102
  )
103
 
104
 
 
 
 
 
 
 
 
105
  def insert_assignment(conn: sqlite3.Connection, item_id: str, annotator_id: str) -> None:
106
  conn.execute(
107
  "INSERT OR IGNORE INTO assignments(item_id, annotator_id, assigned_at) "
 
25
  CREATE TABLE IF NOT EXISTS annotators (
26
  annotator_id TEXT PRIMARY KEY,
27
  token TEXT NOT NULL UNIQUE,
28
+ created_at REAL NOT NULL,
29
+ cap INTEGER
30
  );
31
  CREATE TABLE IF NOT EXISTS assignments (
32
  item_id TEXT NOT NULL,
 
83
 
84
  def init_schema(conn: sqlite3.Connection) -> None:
85
  conn.executescript(SCHEMA)
86
+ # Migrate older DBs that predate the `cap` column on annotators.
87
+ try:
88
+ conn.execute("ALTER TABLE annotators ADD COLUMN cap INTEGER")
89
+ except sqlite3.OperationalError:
90
+ pass # column already exists
91
 
92
 
93
  def mint_token() -> str:
 
101
  )
102
 
103
 
104
+ def insert_annotator(
105
+ conn: sqlite3.Connection,
106
+ annotator_id: str,
107
+ token: str,
108
+ cap: int | None = None,
109
+ ) -> None:
110
  conn.execute(
111
+ "INSERT OR REPLACE INTO annotators(annotator_id, token, created_at, cap) "
112
+ "VALUES (?, ?, ?, ?)",
113
+ (annotator_id, token, time.time(), cap),
114
  )
115
 
116
 
117
+ def get_annotator_cap(conn: sqlite3.Connection, annotator_id: str) -> int | None:
118
+ row = conn.execute(
119
+ "SELECT cap FROM annotators WHERE annotator_id = ?", (annotator_id,)
120
+ ).fetchone()
121
+ return None if row is None or row["cap"] is None else int(row["cap"])
122
+
123
+
124
  def insert_assignment(conn: sqlite3.Connection, item_id: str, annotator_id: str) -> None:
125
  conn.execute(
126
  "INSERT OR IGNORE INTO assignments(item_id, annotator_id, assigned_at) "