Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
4e2c36e
feat: group account actions in a header menu and add the Arena Cat logo
lequims Nov 1, 2026
9237257
feat: include task skips in the data export
lequims Nov 1, 2026
01767ff
feat: let users download their data from the account menu
lequims Nov 1, 2026
b1ec06e
style: use a subtle user icon for the account menu and isolate the ac…
lequims Nov 1, 2026
588fd6b
style: add icons to the account menu items
lequims Nov 1, 2026
5820b1e
fix: make the account menu fit narrow screens and keyboard-friendly, …
lequims Nov 1, 2026
53ee858
feat: add administrator role and user blocking
lequims Nov 1, 2026
210f26a
feat: manage prompts from the admin API
lequims Nov 1, 2026
a329d9f
feat: manage inferences from the admin API
lequims Nov 1, 2026
b2852a5
feat: add the admin web interface
lequims Nov 1, 2026
d5d879a
feat: show the last activity of each user in the admin list
lequims Nov 1, 2026
63cc937
feat: let admins activate and deactivate prompts
lequims Nov 1, 2026
c1f7bc8
fix: keep the delete confirmation on one line in the admin tables
lequims Nov 1, 2026
149d289
feat: import inference YAML files from the admin interface
lequims Nov 1, 2026
a8af030
style: add an icon to the admin entry of the account menu
lequims Nov 1, 2026
1635c8b
fix: accept trailing spaces when checking the prompt hash on YAML import
lequims Nov 1, 2026
ef497eb
feat: add a script to grant and revoke the administrator role
lequims Nov 1, 2026
7c68ee2
fix: drop the leftover menu role from the admin entry of the account …
lequims Nov 1, 2026
2043d06
feat: hide the Telegram help banner in the admin pages
lequims Nov 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,67 @@ Exposa els noms dels models i les metadades d'inferència; `/api/task` manté
les respostes anònimes. La resposta porta `Cache-Control: no-store`.
Podeu consultar-lo amb el [CLI de consulta remota](../scripts/README.md#consulta-de-les-dades-carregades-en-remot).

### `/api/admin/*`

Reservats als usuaris amb `is_admin`, amb la mateixa cookie de sessió que la resta
de l'API: 401 sense sessió, 403 si no és administrador. El rol no es pot canviar des de l'API:
per concedir-lo o revocar-lo, executeu des de `backend/` l'script
[`fes_administrador.py`](../scripts/fes_administrador.py):

```bash
uv run python ../scripts/fes_administrador.py correu@exemple.cat # concedeix
uv run python ../scripts/fes_administrador.py correu@exemple.cat --revoca # revoca
```

El compte ha d'existir, tenir el correu verificat i no estar bloquejat ni donat de baixa.
L'script calcula el hash del correu amb `EMAIL_HASH_PEPPER`, així que cal executar-lo amb la
configuració de l'entorn on es vol aplicar.

- `GET /api/admin/users?limit=50&offset=0`: usuaris per ordre d'`id` (`limit` de 1 a 200),
amb `total` i `last_activity_at` (la data més recent entre l'inici de sessió, el darrer vot i
la darrera omissió; nul si no n'ha fet cap). No inclou contrasenyes ni tokens. La resposta porta `Cache-Control: no-store`.
- `POST /api/admin/users/{id}/block`: bloqueja l'usuari i revoca totes les seves sessions.
Idempotent. 409 si és un mateix o un compte donat de baixa; 404 si no existeix.
- `POST /api/admin/users/{id}/unblock`: treu el bloqueig. Les sessions revocades no es recuperen.

Un usuari bloquejat que encerta la contrasenya rep 403 en iniciar sessió.

- `GET /api/admin/prompts?limit=50&offset=0&version=v1&category_code=correccio&active=true`: prompts per
ordre d'`id`, amb `content_sha256`, `category_code` i `response_count` (inferències).
- `POST /api/admin/prompts` amb `{ version, code, category_code, text }`: crea un prompt (201).
El text s'escurça de blancs i se'n desa el SHA-256. 409 si ja existeix la parella
versió-codi o el mateix contingut dins la versió; 422 si la categoria no existeix o els
camps no són vàlids. El mateix text sí que es pot publicar en una versió nova.
- `PATCH /api/admin/prompts/{id}` amb `{ active }`: activa o desactiva un prompt. Un prompt
inactiu no es serveix i els seus vots no compten al rànquing, que només considera la
versió activa de cada codi: si es desactiva la més alta, hi cau la versió anterior. Serveix
per desfer una versió publicada amb errades sense esborrar-ne res.
- `DELETE /api/admin/prompts/{id}`: esborra un prompt (204). 409 si té inferències associades;
404 si no existeix.

Les inferències (`responses`) tenen dos indicadors independents:

- `active`: si és fals, no es mostra als avaluadors ni compta per al progrés, però conserva
els vots. Un prompt amb menys de dues respostes actives deixa de servir-se.
- `included_in_ranking`: si és fals, els vots que la inclouen (com a A o B) no compten al
rànquing ni a la confiança.

- `GET /api/admin/responses?prompt_id=&model=&active=&included_in_ranking=&limit=&offset=`:
inferències per ordre d'`id`, amb `vote_count`.
- `POST /api/admin/responses` amb `{ prompt_id, model, text, inference_metadata? }`: la crea
activa i inclosa (201). 404 si el prompt no existeix; 409 si el model ja hi té resposta.
- `POST /api/admin/responses/import` amb `{ version, files: [{ name, content }] }` (1 a 200
fitxers de fins a 200.000 caràcters): importa els YAML que genera `scripts/inferencia.py`,
amb les mateixes regles que `make load_inferences`. Cada fitxer és independent: la resposta
porta `inserted`, `skipped` i `errors` i, per fitxer, `status` (`inserted`, `skipped` o
`error`) i `detail`. El prompt es busca per `version` i `prompt.id` (el codi), i si el
fitxer duu `prompt.sha256` s'ha de correspondre amb el text del prompt (error altrament). Una
resposta que ja existeix idèntica s'omet; si té un contingut diferent és un error i no es
modifica. Els fitxers van dins del JSON perquè el navegador els llegeix amb `file.text()`.
- `PATCH /api/admin/responses/{id}` amb `{ active?, included_in_ranking? }`: canvia els
indicadors; cal indicar-ne almenys un (422 altrament).
- `DELETE /api/admin/responses/{id}`: esborra una inferència (204). 409 si té vots o omissions.

### `GET /api/qualification` i `POST /api/qualification`

Requereixen una sessió vàlida i la verificació del correu, si està activada.
Expand Down
10 changes: 10 additions & 0 deletions backend/app/deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,13 @@ def get_current_qualified_user(user: CurrentVerifiedUser) -> User:


CurrentQualifiedUser = Annotated[User, Depends(get_current_qualified_user)]


def get_current_admin(user: CurrentVerifiedUser) -> User:
"""Exigeix que l'usuari autenticat sigui administrador."""
if not user.is_admin:
raise HTTPException(status_code=403, detail="Cal ser administrador")
return user


CurrentAdmin = Annotated[User, Depends(get_current_admin)]
13 changes: 12 additions & 1 deletion backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,17 @@
from fastapi.responses import JSONResponse

from app.exceptions import TASK_TOKEN_INVALID, TaskTokenError
from app.routes import activity, auth, categories, dataset, qualification, ranking, task, vote
from app.routes import (
activity,
admin,
auth,
categories,
dataset,
qualification,
ranking,
task,
vote,
)
from app.telemetry.metrics import http_errors_total, http_requests_duration, http_requests_total

logging.basicConfig(
Expand Down Expand Up @@ -39,6 +49,7 @@ async def task_token_error_handler(_request: Request, exc: TaskTokenError) -> JS
app.include_router(auth.router, prefix="/api", tags=["Auth"])
app.include_router(categories.router, prefix="/api", tags=["Categories"])
app.include_router(dataset.router, prefix="/api", tags=["Dataset"])
app.include_router(admin.router, prefix="/api/admin", tags=["Admin"])
app.include_router(activity.router, prefix="/api", tags=["Activity"])
app.include_router(qualification.router, prefix="/api", tags=["Qualification"])

Expand Down
42 changes: 41 additions & 1 deletion backend/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"""

import enum
import hashlib
from datetime import datetime
from decimal import Decimal

Expand All @@ -34,6 +35,11 @@
from app.db import Base


def text_sha256(text: str) -> str:
"""Empremta SHA-256 d'un text en UTF-8, en hexadecimal."""
return hashlib.sha256(text.encode("utf-8")).hexdigest()


class Winner(enum.Enum):
"""Resultat d'un vot: guanya A, guanya B, empat o cap de les dues."""

Expand Down Expand Up @@ -64,16 +70,33 @@ class Prompt(Base):
"""Tasca d'avaluació que es mostra a l'usuari."""

__tablename__ = "prompts"
__table_args__ = (UniqueConstraint("version", "code", name="uq_prompts_version_code"),)
__table_args__ = (
UniqueConstraint("version", "code", name="uq_prompts_version_code"),
UniqueConstraint("version", "content_sha256", name="uq_prompts_version_content_sha256"),
CheckConstraint("content_sha256 ~ '^[0-9a-f]{64}$'", name="ck_prompts_content_sha256"),
)

id: Mapped[int] = mapped_column(Integer, primary_key=True)
version: Mapped[str] = mapped_column(String(32), nullable=False)
code: Mapped[str] = mapped_column(String(64), nullable=False)
category_id: Mapped[int] = mapped_column(ForeignKey("categories.id"), nullable=False)
text: Mapped[str] = mapped_column(Text, nullable=False)
# SHA-256 del text en UTF-8: impedeix repetir el mateix contingut dins d'una versió.
content_sha256: Mapped[str] = mapped_column(
String(64), default=lambda context: text_sha256(context.get_current_parameters()["text"])
)
# Un prompt inactiu no es serveix ni compta al rànquing; si n'hi ha d'anteriors, hi cau.
active: Mapped[bool] = mapped_column(Boolean, server_default="true", nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
# `clock_timestamp` canvia dins la transacció: invalida la memòria cau del rànquing.
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.clock_timestamp(),
nullable=False,
)

category: Mapped["Category"] = relationship()
responses: Mapped[list["Response"]] = relationship(
Expand All @@ -99,9 +122,22 @@ class Response(Base):
text: Mapped[str] = mapped_column(Text, nullable=False)
# Metadades de la inferència (seed, temperatura, top_p, max_new_tokens, quantization...).
inference_metadata: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
# Una resposta inactiva no es mostra als avaluadors, però conserva els vots.
active: Mapped[bool] = mapped_column(Boolean, server_default="true", nullable=False)
# Els vots que impliquen una resposta exclosa no compten al rànquing.
included_in_ranking: Mapped[bool] = mapped_column(
Boolean, server_default="true", nullable=False
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
# `clock_timestamp` canvia dins la transacció: invalida la memòria cau del rànquing.
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.clock_timestamp(),
nullable=False,
)

prompt: Mapped["Prompt"] = relationship(back_populates="responses")

Expand All @@ -117,6 +153,7 @@ class User(Base):
"password_hash IS NOT NULL AND consent_at IS NOT NULL)",
name="ck_users_active_have_credentials",
),
CheckConstraint("NOT is_admin OR deleted_at IS NULL", name="ck_users_admin_not_deleted"),
CheckConstraint("reminder_count BETWEEN 0 AND 3", name="ck_users_reminder_count"),
CheckConstraint(
"NOT reminder_enabled OR "
Expand All @@ -140,6 +177,9 @@ class User(Base):
DateTime(timezone=True), nullable=True
)
qualified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
is_admin: Mapped[bool] = mapped_column(Boolean, server_default="false", nullable=False)
# Un usuari bloquejat no pot iniciar sessió ni conservar les sessions obertes.
blocked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# Últim cop que s'ha enviat el correu de verificació: limita els reenviaments.
verification_sent_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
Expand Down
4 changes: 2 additions & 2 deletions backend/app/prompt_versions.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@


def active_prompt_ids():
"""Tria per codi la revisió més alta amb dues respostes de models diferents."""
"""Tria per codi la revisió activa més alta amb dues respostes de models diferents."""
number = cast(func.substring(Prompt.version, VERSION_PATTERN), Numeric)
ranked = (
select(
Expand All @@ -20,7 +20,7 @@ def active_prompt_ids():
.label("position"),
)
.join(Response, Response.prompt_id == Prompt.id)
.where(number.is_not(None))
.where(number.is_not(None), Prompt.active)
.group_by(Prompt.id)
# UNIQUE(prompt_id, model) garanteix que són models diferents.
.having(func.count(Response.id) >= 2)
Expand Down
1 change: 1 addition & 0 deletions backend/app/ranking/confidence.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ def _load_clustered_votes(
.join(response_a, Vote.response_a_id == response_a.id)
.join(response_b, Vote.response_b_id == response_b.id)
.where(Prompt.id.in_(active_prompt_ids()))
.where(response_a.included_in_ranking, response_b.included_in_ranking)
)
if category_code is not None:
stmt = stmt.where(Category.code == category_code)
Expand Down
1 change: 1 addition & 0 deletions backend/app/ranking/ranking.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ def _load_votes(session: Session, category_code: str | None) -> list[tuple[Winne
.join(response_a, Vote.response_a_id == response_a.id)
.join(response_b, Vote.response_b_id == response_b.id)
.where(Prompt.id.in_(active_prompt_ids()))
.where(response_a.included_in_ranking, response_b.included_in_ranking)
)
if category_code is not None:
stmt = stmt.where(Category.code == category_code)
Expand Down
2 changes: 1 addition & 1 deletion backend/app/ranking/sampler.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ def _load_prompts_and_responses(
prompts = session.scalars(stmt).all()
out = []
for prompt in prompts:
responses_by_model = {r.model: r for r in prompt.responses}
responses_by_model = {r.model: r for r in prompt.responses if r.active}
if len(responses_by_model) >= 2:
out.append((prompt, responses_by_model))
return out
Expand Down
Loading
Loading