lanczos's picture
deploy: labeling server
000a5ee verified
"use strict";
const AXES = ["art_style", "color", "art_medium", "lighting"];
const TOKEN_STORAGE_KEY = "aamcq_token";
const EMAIL_STORAGE_KEY = "aamcq_email";
const THEME_STORAGE_KEY = "aamcq_theme";
const PASSWORD_SESSION_KEY = "aamcq_access_password";
const FIRST_SESSION_CAP = 20;
const EXTRA_ROUND_CAP = 10;
function setTheme(theme) {
document.documentElement.setAttribute("data-theme", theme);
localStorage.setItem(THEME_STORAGE_KEY, theme);
const btn = document.getElementById("theme-toggle");
if (btn) btn.textContent = theme === "dark" ? "☾" : "☀";
}
function initThemeToggle() {
const current = localStorage.getItem(THEME_STORAGE_KEY) || "light";
setTheme(current);
const btn = document.getElementById("theme-toggle");
btn.addEventListener("click", () => {
const now = document.documentElement.getAttribute("data-theme") === "dark"
? "light"
: "dark";
setTheme(now);
});
}
class HttpError extends Error {
constructor(status, body) {
super(`${status}: ${body}`);
this.status = status;
this.body = body;
}
}
async function fetchJSON(path, init) {
const resp = await fetch(path, init);
if (!resp.ok) {
const body = await resp.text();
throw new HttpError(resp.status, body);
}
return resp.json();
}
async function attemptRegister(params, password) {
const qs = new URLSearchParams(params);
if (password) qs.set("password", password);
return fetch(`/api/register?${qs.toString()}`, { method: "POST" });
}
async function registerWithParams(params) {
// Try with cached password (could be empty).
let password = sessionStorage.getItem(PASSWORD_SESSION_KEY) || "";
let resp = await attemptRegister(params, password);
while (resp.status === 403) {
sessionStorage.removeItem(PASSWORD_SESSION_KEY);
const entered = window.prompt(
password
? "Wrong access password. Try again:"
: "Enter the access password to start labeling:"
);
if (entered == null) throw new Error("Access password required.");
password = entered;
resp = await attemptRegister(params, password);
}
if (resp.status === 429) {
throw new HttpError(429, await resp.text());
}
if (!resp.ok) {
const body = await resp.text();
throw new HttpError(resp.status, body);
}
if (password) sessionStorage.setItem(PASSWORD_SESSION_KEY, password);
const data = await resp.json();
localStorage.setItem(TOKEN_STORAGE_KEY, data.token);
return data;
}
async function ensureToken() {
const urlToken = new URL(window.location.href).searchParams.get("token");
if (urlToken) {
localStorage.setItem(TOKEN_STORAGE_KEY, urlToken);
return urlToken;
}
const stored = localStorage.getItem(TOKEN_STORAGE_KEY);
if (stored) return stored;
const { token } = await registerWithParams({ cap: String(FIRST_SESSION_CAP) });
return token;
}
function renderProfileCard(idx, profile) {
const ul = document.createElement("ul");
ul.className = "profile";
for (const axis of AXES) {
const li = document.createElement("li");
const key = document.createElement("span");
key.className = "axis";
key.textContent = axis.replace("_", " ") + ": ";
const val = document.createElement("span");
val.className = "value";
val.textContent = profile[axis] ?? "?";
li.appendChild(key);
li.appendChild(val);
ul.appendChild(li);
}
const wrapper = document.createElement("label");
wrapper.className = "option";
const input = document.createElement("input");
input.type = "radio";
input.name = "choice";
input.value = String(idx);
wrapper.appendChild(input);
const badge = document.createElement("div");
badge.className = "badge";
badge.textContent = String.fromCharCode(65 + idx);
wrapper.appendChild(badge);
wrapper.appendChild(ul);
return wrapper;
}
let currentItem = null;
let shownAt = 0;
async function loadNext(token) {
const data = await fetchJSON(`/api/task?token=${encodeURIComponent(token)}`);
const card = document.getElementById("card");
const submit = document.getElementById("submit");
const err = document.getElementById("error");
err.textContent = "";
if (data.done) {
await renderDonePage(token, data);
submit.style.display = "none";
updateProgress(data.labeled, data.cap);
return;
}
currentItem = data;
shownAt = performance.now();
document.getElementById("stimulus").src = data.image_url;
document.getElementById("base-prompt").textContent =
data.payload.base_prompt ? `"${data.payload.base_prompt}"` : "";
const form = document.getElementById("options");
form.innerHTML = "";
const options = data.payload.options || [];
options.forEach((opt, i) => form.appendChild(renderProfileCard(i, opt)));
submit.style.display = "";
submit.disabled = true;
form.querySelectorAll("input[type=radio]").forEach((el) => {
el.addEventListener("change", () => { submit.disabled = false; });
});
updateProgress(data.labeled, data.cap);
}
async function renderDonePage(token, taskData) {
const card = document.getElementById("card");
const labeled = taskData.labeled ?? 0;
if (taskData.reason !== "cap_reached") {
// Pool is drained entirely. Thank and stop.
card.innerHTML = `<p class='done'>All items are fully labeled (you contributed ${labeled}). Thank you!</p>`;
appendAnnouncement(card);
return;
}
// cap_reached: fetch detailed session status to decide UI state
let status;
try {
status = await fetchJSON(`/api/session_status?token=${encodeURIComponent(token)}`);
} catch (e) {
card.innerHTML = `<p class='done'>Session complete (${labeled} labeled). Couldn't load status: ${e.message}</p>`;
return;
}
card.innerHTML = "";
const msg = document.createElement("p");
msg.className = "done";
card.appendChild(msg);
if (!status.acc_pass) {
// Fail state
msg.innerHTML =
`<strong>Low agreement rate detected.</strong><br>` +
`Your ${labeled}-item session doesn't meet the quality threshold ` +
`and can't be credited. Please try again more carefully.`;
msg.classList.add("fail");
const btn = document.createElement("button");
btn.id = "retry-session";
if (status.round_number === 1) {
btn.textContent = "Try again";
btn.addEventListener("click", () => {
localStorage.removeItem(TOKEN_STORAGE_KEY);
localStorage.removeItem(EMAIL_STORAGE_KEY);
location.reload();
});
} else {
btn.textContent = `Redo round ${status.round_number}`;
btn.addEventListener("click", async () => {
localStorage.removeItem(TOKEN_STORAGE_KEY);
const email = status.email || localStorage.getItem(EMAIL_STORAGE_KEY);
if (!email) { location.reload(); return; }
try {
await registerWithParams({
cap: String(EXTRA_ROUND_CAP),
email,
round: String(status.round_number),
});
location.reload();
} catch (e) {
document.getElementById("error").textContent = `Retry failed: ${e.message}`;
}
});
}
card.appendChild(btn);
appendAnnouncement(card);
return;
}
// Pass state
if (status.round_number === 1 && !status.email) {
msg.innerHTML =
`<strong>Great job!</strong> Your session passed the quality check. ` +
`Submit your email to enter the lottery.`;
const form = document.createElement("form");
form.id = "email-form";
form.innerHTML =
`<input type="email" id="email-input" placeholder="your@email" required autocomplete="email" />` +
`<button type="submit">Submit email</button>`;
card.appendChild(form);
form.addEventListener("submit", async (ev) => {
ev.preventDefault();
const email = document.getElementById("email-input").value.trim();
if (!email) return;
try {
await fetchJSON("/api/submit_email", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ token, email }),
});
localStorage.setItem(EMAIL_STORAGE_KEY, email);
await renderDonePage(token, taskData);
} catch (e) {
document.getElementById("error").textContent = `Submit failed: ${e.message}`;
}
});
appendAnnouncement(card);
return;
}
// Already on email chain: thank + offer "Label more" if not maxed
const entries = status.labels_in_lottery ?? 0;
msg.innerHTML =
`<strong>Thanks, ${status.email}!</strong> You're entered in the lottery ` +
`with <strong>${entries}</strong> labels counted so far.`;
if (status.can_extend) {
const hint = document.createElement("p");
hint.className = "muted-info";
hint.textContent =
`Want better odds? Label ${EXTRA_ROUND_CAP} more to add to your entry count.`;
card.appendChild(hint);
const btn = document.createElement("button");
btn.id = "extend-session";
btn.textContent = `Label ${EXTRA_ROUND_CAP} more`;
btn.addEventListener("click", async () => {
try {
await registerWithParams({
cap: String(EXTRA_ROUND_CAP),
email: status.email,
round: String(status.round_number + 1),
});
location.reload();
} catch (e) {
document.getElementById("error").textContent = `Couldn't start round: ${e.message}`;
}
});
card.appendChild(btn);
} else {
const hint = document.createElement("p");
hint.className = "muted-info";
hint.textContent = "Thanks for participating!";
card.appendChild(hint);
}
appendAnnouncement(card);
}
function appendAnnouncement(card) {
const div = document.createElement("div");
div.className = "announcement";
div.innerHTML =
`Winning probability is proportional to labels that pass the quality check. ` +
`Prize pool: <strong>10 × $20 Amazon gift cards</strong>, drawn after ` +
`we reach 2,000 labels total.`;
card.appendChild(div);
}
function updateProgress(labeled, cap) {
const el = document.getElementById("progress");
if (cap != null) {
el.textContent = `${labeled ?? 0} / ${cap} done`;
} else {
el.textContent = `${labeled ?? 0} labeled`;
}
}
async function submitLabel(token) {
const err = document.getElementById("error");
err.textContent = "";
const chosen = document.querySelector("input[name=choice]:checked");
if (!chosen || !currentItem) return;
const elapsed = (performance.now() - shownAt) / 1000;
await fetchJSON("/api/label", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
token,
item_id: currentItem.item_id,
chosen_index: Number(chosen.value),
seconds: elapsed,
confidence: null,
}),
});
await loadNext(token);
}
async function recoverFromInvalidToken() {
// Server doesn't know this token. Wipe client state and start fresh.
localStorage.removeItem(TOKEN_STORAGE_KEY);
localStorage.removeItem(EMAIL_STORAGE_KEY);
const { token } = await registerWithParams({ cap: String(FIRST_SESSION_CAP) });
return token;
}
async function main() {
initThemeToggle();
let token;
try {
token = await ensureToken();
} catch (e) {
document.getElementById("error").textContent = e.message;
return;
}
document.getElementById("submit").addEventListener("click", async () => {
try {
await submitLabel(token);
} catch (e) {
if (e instanceof HttpError && e.status === 401) {
token = await recoverFromInvalidToken();
await loadNext(token);
} else {
document.getElementById("error").textContent = `Submit failed: ${e.message}`;
}
}
});
try {
await loadNext(token);
} catch (e) {
if (e instanceof HttpError && e.status === 401) {
try {
token = await recoverFromInvalidToken();
await loadNext(token);
} catch (e2) {
document.getElementById("error").textContent = `Load failed: ${e2.message}`;
}
} else {
document.getElementById("error").textContent = `Load failed: ${e.message}`;
}
}
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", main);
} else {
main();
}