diff --git a/backend/README.md b/backend/README.md
index fd73cb0..79b21ae 100644
--- a/backend/README.md
+++ b/backend/README.md
@@ -54,6 +54,24 @@ On first startup, `docker compose` provisions:
El backend de FastAPI exposa els següents endpoints:
+### `GET /api/categories`
+
+Retorna el catàleg públic de categories, ordenat per codi. Les dades provenen de la taula
+`categories`, sincronitzada des de `data/prompts/categories.yaml`.
+
+```json
+{
+ "categories": [
+ {
+ "code": "correccio",
+ "name": "Correcció",
+ "description": "Corregeix aquest text.",
+ "evaluation_instructions": "- Correcció ortogràfica i gramatical.\n- Conservació del significat original.\n- Naturalitat en català.\n- Absència de canvis innecessaris."
+ }
+ ]
+}
+```
+
### `GET /api/task`
Obté una nova tasca (un prompt amb dues respostes de models diferents) per a que un usuari l'avaluï.
diff --git a/backend/app/main.py b/backend/app/main.py
index 4a7f785..8dabe82 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -5,7 +5,7 @@
from fastapi.responses import JSONResponse
from app.exceptions import TASK_TOKEN_INVALID, TaskTokenError
-from app.routes import auth, ranking, task, vote
+from app.routes import auth, categories, ranking, task, vote
logging.basicConfig(
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
@@ -35,3 +35,4 @@ async def task_token_error_handler(request: Request, exc: TaskTokenError) -> JSO
app.include_router(vote.router, prefix="/api", tags=["Vote"])
app.include_router(ranking.router, prefix="/api", tags=["Ranking"])
app.include_router(auth.router, prefix="/api", tags=["Auth"])
+app.include_router(categories.router, prefix="/api", tags=["Categories"])
diff --git a/backend/app/models.py b/backend/app/models.py
index 9bc6ddd..f600d2a 100644
--- a/backend/app/models.py
+++ b/backend/app/models.py
@@ -56,6 +56,7 @@ class Category(Base):
code: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
name: Mapped[str] = mapped_column(String(128), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
+ evaluation_instructions: Mapped[str | None] = mapped_column(Text, nullable=True)
class Prompt(Base):
diff --git a/backend/app/routes/categories.py b/backend/app/routes/categories.py
new file mode 100644
index 0000000..7abe7f4
--- /dev/null
+++ b/backend/app/routes/categories.py
@@ -0,0 +1,15 @@
+from fastapi import APIRouter
+from sqlalchemy import select
+
+from app.deps import DbSession
+from app.models import Category
+from app.schemas import CategoriesResponse
+
+router = APIRouter()
+
+
+@router.get("/categories")
+def get_categories(db: DbSession) -> CategoriesResponse:
+ """Retorna el catàleg públic de categories ordenat per codi."""
+ categories = db.scalars(select(Category).order_by(Category.code)).all()
+ return CategoriesResponse(categories=categories)
diff --git a/backend/app/schemas.py b/backend/app/schemas.py
index 4db55c7..34fd04a 100644
--- a/backend/app/schemas.py
+++ b/backend/app/schemas.py
@@ -5,6 +5,19 @@
from app.models import Winner
+class CategoryResponse(BaseModel):
+ model_config = ConfigDict(from_attributes=True)
+
+ code: str
+ name: str
+ description: str | None
+ evaluation_instructions: str | None
+
+
+class CategoriesResponse(BaseModel):
+ categories: list[CategoryResponse]
+
+
class TaskResponse(BaseModel):
category_code: str
prompt: str
diff --git a/backend/app/seeds.py b/backend/app/seeds.py
deleted file mode 100644
index 7c76c80..0000000
--- a/backend/app/seeds.py
+++ /dev/null
@@ -1,7 +0,0 @@
-"""Dades de referència inicials, compartides entre la migració i els tests."""
-
-INITIAL_CATEGORIES = [
- {"code": "correccio", "name": "Correcció", "description": "Corregeix aquest text."},
- {"code": "reformulacio", "name": "Reformulació", "description": "Reformula aquest text."},
- {"code": "traduccio", "name": "Traducció", "description": "Tradueix aquest text."},
-]
diff --git a/backend/migrations/versions/94019e30371a_initial_schema.py b/backend/migrations/versions/94019e30371a_initial_schema.py
index 3835a54..60f2c73 100644
--- a/backend/migrations/versions/94019e30371a_initial_schema.py
+++ b/backend/migrations/versions/94019e30371a_initial_schema.py
@@ -12,8 +12,6 @@
from alembic import op
from sqlalchemy.dialects import postgresql
-from app.seeds import INITIAL_CATEGORIES
-
revision: str = "94019e30371a"
down_revision: str | Sequence[str] | None = None
branch_labels: str | Sequence[str] | None = None
@@ -27,6 +25,7 @@ def upgrade() -> None:
sa.Column("code", sa.String(length=64), nullable=False),
sa.Column("name", sa.String(length=128), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
+ sa.Column("evaluation_instructions", sa.Text(), nullable=True),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("code"),
)
@@ -101,16 +100,6 @@ def upgrade() -> None:
op.create_index("ix_votes_created_at", "votes", ["created_at"], unique=False)
op.create_index("ix_votes_prompt_id", "votes", ["prompt_id"], unique=False)
- op.bulk_insert(
- sa.table(
- "categories",
- sa.column("code", sa.String),
- sa.column("name", sa.String),
- sa.column("description", sa.Text),
- ),
- INITIAL_CATEGORIES,
- )
-
def downgrade() -> None:
op.drop_index("ix_votes_prompt_id", table_name="votes")
diff --git a/backend/scripts/auth_flow_demo.py b/backend/scripts/auth_flow_demo.py
index 4ea1f83..85e3ab5 100644
--- a/backend/scripts/auth_flow_demo.py
+++ b/backend/scripts/auth_flow_demo.py
@@ -22,7 +22,7 @@
Des de `backend/`:
uv sync # instal·la dependències
- uv run alembic upgrade head # aplica migracions i seeds
+ uv run alembic upgrade head # aplica les migracions
uv run python scripts/auth_flow_demo.py
Opcions:
diff --git a/backend/scripts/seed_mock_tasks.py b/backend/scripts/seed_mock_tasks.py
index 1fa222b..f1d6ff8 100644
--- a/backend/scripts/seed_mock_tasks.py
+++ b/backend/scripts/seed_mock_tasks.py
@@ -82,7 +82,7 @@ def seed_mock_tasks(
categories = db.scalars(stmt).all()
if not categories:
- raise SystemExit("No s'ha trobat cap categoria. Has aplicat les migracions (seeds)?")
+ raise SystemExit("No s'ha trobat cap categoria. Has carregat el catàleg YAML?")
new_prompts = 0
new_responses = 0
diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py
index 143e901..90cc222 100644
--- a/backend/tests/conftest.py
+++ b/backend/tests/conftest.py
@@ -5,8 +5,10 @@
"""
from datetime import UTC, datetime
+from pathlib import Path
import pytest
+import yaml
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
@@ -17,7 +19,9 @@
from app.main import app
from app.models import User
from app.security import compute_email_hash, hash_password
-from app.seeds import INITIAL_CATEGORIES
+
+REPO_ROOT = Path(__file__).resolve().parents[2]
+CATEGORIES_FILE = REPO_ROOT / "data" / "prompts" / "categories.yaml"
DEFAULT_PASSWORD = "ContrasenyaSegura123!"
@@ -30,7 +34,8 @@ def engine():
Base.metadata.drop_all(eng)
Base.metadata.create_all(eng)
with Session(eng) as seed_session:
- seed_session.add_all([models.Category(**c) for c in INITIAL_CATEGORIES])
+ document = yaml.safe_load(CATEGORIES_FILE.read_text(encoding="utf-8"))
+ seed_session.add_all([models.Category(**category) for category in document["categories"]])
seed_session.commit()
yield eng
Base.metadata.drop_all(eng)
diff --git a/backend/tests/test_carrega_inferencies.py b/backend/tests/test_carrega_inferencies.py
index ff651ee..6459225 100644
--- a/backend/tests/test_carrega_inferencies.py
+++ b/backend/tests/test_carrega_inferencies.py
@@ -7,7 +7,7 @@
import yaml
from sqlalchemy import func, select
-from app.models import Prompt, Response
+from app.models import Category, Prompt, Response
# L'script viu a scripts/ (projecte arrel), fora del paquet backend.
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "scripts"))
@@ -63,6 +63,63 @@ def _count(session, model) -> int:
return session.scalar(select(func.count()).select_from(model))
+def write_categories(
+ path: Path,
+ name: str = "Cultura",
+ evaluation_instructions: str = "Comprova quatre aspectes importants.",
+) -> None:
+ """Escriu un catàleg mínim per provar-ne la sincronització."""
+ path.write_text(
+ f"""categories:
+ - code: cultura
+ name: {name}
+ description: Avalua coneixements culturals.
+ evaluation_instructions: {evaluation_instructions}
+""",
+ encoding="utf-8",
+ )
+
+
+def test_categories_are_inserted_updated_and_loaded_idempotently(session, dirs, tmp_path):
+ prompts_dir, inferencies_dir = dirs
+ categories_file = tmp_path / "categories.yaml"
+ write_categories(categories_file)
+
+ loader.run_load(session, prompts_dir, inferencies_dir, categories_file=categories_file)
+ loader.run_load(session, prompts_dir, inferencies_dir, categories_file=categories_file)
+ write_categories(
+ categories_file,
+ name="Cultura catalana",
+ evaluation_instructions="Comprova quatre criteris culturals.",
+ )
+ loader.run_load(session, prompts_dir, inferencies_dir, categories_file=categories_file)
+
+ category = session.scalar(select(Category).where(Category.code == "cultura"))
+ assert category.name == "Cultura catalana"
+ assert category.evaluation_instructions == "Comprova quatre criteris culturals."
+ assert (
+ session.scalar(select(func.count()).select_from(Category).where(Category.code == "cultura"))
+ == 1
+ )
+
+
+@pytest.mark.parametrize(
+ "document",
+ [
+ "categories: [{code: correccio}]",
+ "categories: [{code: 'Còrrecció', name: Correcció}]",
+ "categories: [{code: correccio, name: Correcció, extra: true}]",
+ "categories: [{code: correccio, name: Correcció}, {code: correccio, name: Altra}]",
+ ],
+)
+def test_invalid_category_catalog_is_rejected(tmp_path, document):
+ categories_file = tmp_path / "categories.yaml"
+ categories_file.write_text(document, encoding="utf-8")
+
+ with pytest.raises(loader.CategoryCatalogError):
+ loader.load_category_catalog(categories_file)
+
+
def test_prompt_is_inserted_with_derived_category(session, dirs):
prompts_dir, inferencies_dir = dirs
write_prompt(prompts_dir, "traduccio_1", "Tradueix això.")
diff --git a/backend/tests/test_categories_api.py b/backend/tests/test_categories_api.py
new file mode 100644
index 0000000..e6c2546
--- /dev/null
+++ b/backend/tests/test_categories_api.py
@@ -0,0 +1,12 @@
+def test_get_categories_returns_catalog_sorted_by_code(client):
+ response = client.get("/api/categories")
+
+ assert response.status_code == 200
+ categories = response.json()["categories"]
+ assert [category["code"] for category in categories] == [
+ "correccio",
+ "reformulacio",
+ "traduccio",
+ ]
+ assert all(category["evaluation_instructions"] for category in categories)
+ assert all(category["evaluation_instructions"].count("\n") == 3 for category in categories)
diff --git a/backend/tests/test_integration_scale.py b/backend/tests/test_integration_scale.py
index e977fbc..f2cd630 100644
--- a/backend/tests/test_integration_scale.py
+++ b/backend/tests/test_integration_scale.py
@@ -880,7 +880,7 @@ def test_new_category_added_mid_campaign_is_independent(session):
- Fase 1: 5 prompts × 3 models a `correccio`. 1800 vots amb gemma
al 60% (~120 vots/cel·la per obtenir un rànquing clarament estable).
- Comprovem: sampling de `cultura` retorna None (encara no té prompts).
- - Creem la categoria `cultura` (no és a `INITIAL_CATEGORIES`) i
+ - Creem la categoria `cultura` (no és al catàleg YAML) i
afegim 3 prompts × 3 respostes.
- Fase 2: 800 vots a `cultura` amb salamandra al 60% (3 prompts ×
3 parelles = 9 cel·les → ~89 vots/cel·la, prou generós).
diff --git a/data/prompts/categories.yaml b/data/prompts/categories.yaml
new file mode 100644
index 0000000..ecad51d
--- /dev/null
+++ b/data/prompts/categories.yaml
@@ -0,0 +1,25 @@
+categories:
+ - code: correccio
+ name: Correcció
+ description: Corregeix aquest text.
+ evaluation_instructions: |-
+ - Correcció ortogràfica i gramatical.
+ - Conservació del significat original.
+ - Naturalitat en català.
+ - Absència de canvis innecessaris.
+ - code: reformulacio
+ name: Reformulació
+ description: Reformula aquest text.
+ evaluation_instructions: |-
+ - Conservació del significat i la informació.
+ - Compliment de la reformulació demanada.
+ - Claredat i naturalitat.
+ - Absència d'afegits o omissions rellevants.
+ - code: traduccio
+ name: Traducció
+ description: Tradueix aquest text.
+ evaluation_instructions: |-
+ - Fidelitat al significat original.
+ - Absència d'omissions o informació afegida.
+ - Correcció i naturalitat en català.
+ - Coherència del registre i la terminologia.
diff --git a/docs/db_schema.md b/docs/db_schema.md
index cffb70d..c03083a 100644
--- a/docs/db_schema.md
+++ b/docs/db_schema.md
@@ -11,6 +11,7 @@ erDiagram
varchar(64) code
varchar(128) name
text description
+ text evaluation_instructions
}
prompts {
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 8aed5ef..172907b 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -7,7 +7,7 @@ import Login from "./components/Login";
import RankingView from "./components/RankingView";
import TaskView from "./components/TaskView";
import { clearTask } from "./taskStore";
-import type { SessionState } from "./types";
+import type { Category, SessionState } from "./types";
const UNKNOWN: SessionState = { authenticated: false, email: null, email_verified: false };
@@ -15,6 +15,8 @@ export default function App() {
// `null` mentre no sabem si hi ha sessió: sense aquest estat intermedi
// ensenyaríem el formulari un instant a qui ja té la sessió oberta.
const [session, setSession] = useState No s'han pogut carregar les categories. Carregant categories…
{category === "" ? "Global" - : CATEGORIES.find((item) => item.code === category)?.label} + : (categories.find((item) => item.code === category)?.name ?? category)} {" · "}actualitzat amb els vots de la comunitat
diff --git a/frontend/src/components/TaskView.tsx b/frontend/src/components/TaskView.tsx index 6ed4358..50ce572 100644 --- a/frontend/src/components/TaskView.tsx +++ b/frontend/src/components/TaskView.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { api, ApiError } from "../api"; import { splitPrompt } from "../diff"; import { clearTask, loadSavedTask, saveTask, secondsUntilVote } from "../taskStore"; -import { CATEGORIES, type CategoryFilter, type Progress, type Task, type Winner } from "../types"; +import type { Category, CategoryFilter, Progress, Task, Winner } from "../types"; import Onboarding, { type OnboardingStep } from "./Onboarding"; import ProgressBar from "./ProgressBar"; import ResponseCard from "./ResponseCard"; @@ -19,7 +19,7 @@ const VOTE_OPTIONS: { { winner: "neither", label: "Cap de les dues", shortcut: "c" }, ]; -export default function TaskView() { +export default function TaskView({ categories }: { categories: Category[] }) { const [category, setCategory] = useState