From 049ccf7bd74024a4b6d4b30db4b6a9fde24c682d Mon Sep 17 00:00:00 2001
From: Jordi Mas
Date: Sat, 5 Sep 2026 11:31:41 +0200
Subject: [PATCH 1/7] feat: expose categories from YAML catalog
---
backend/README.md | 17 ++++
backend/app/main.py | 3 +-
backend/app/routes/categories.py | 15 ++++
backend/app/schemas.py | 12 +++
backend/tests/test_carrega_inferencies.py | 48 ++++++++++-
backend/tests/test_categories_api.py | 23 ++++++
categories.md | 87 ++++++++++++++++++++
data/prompts/categories.yaml | 10 +++
frontend/src/App.tsx | 45 ++++++++++-
frontend/src/api.ts | 12 ++-
frontend/src/components/RankingView.tsx | 28 +++++--
frontend/src/components/TaskView.tsx | 23 ++++--
frontend/src/types.ts | 13 ++-
scripts/README.md | 4 +-
scripts/carrega_inferencies.py | 99 ++++++++++++++++++++++-
15 files changed, 408 insertions(+), 31 deletions(-)
create mode 100644 backend/app/routes/categories.py
create mode 100644 backend/tests/test_categories_api.py
create mode 100644 categories.md
create mode 100644 data/prompts/categories.yaml
diff --git a/backend/README.md b/backend/README.md
index fd73cb0..2f2a362 100644
--- a/backend/README.md
+++ b/backend/README.md
@@ -54,6 +54,23 @@ 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."
+ }
+ ]
+}
+```
+
### `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/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..5fb02ff 100644
--- a/backend/app/schemas.py
+++ b/backend/app/schemas.py
@@ -5,6 +5,18 @@
from app.models import Winner
+class CategoryResponse(BaseModel):
+ model_config = ConfigDict(from_attributes=True)
+
+ code: str
+ name: str
+ description: str | None
+
+
+class CategoriesResponse(BaseModel):
+ categories: list[CategoryResponse]
+
+
class TaskResponse(BaseModel):
category_code: str
prompt: str
diff --git a/backend/tests/test_carrega_inferencies.py b/backend/tests/test_carrega_inferencies.py
index ff651ee..f560751 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,52 @@ def _count(session, model) -> int:
return session.scalar(select(func.count()).select_from(model))
+def write_categories(path: Path, name: str = "Cultura") -> 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.
+""",
+ 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")
+ 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 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..cdeeb69
--- /dev/null
+++ b/backend/tests/test_categories_api.py
@@ -0,0 +1,23 @@
+def test_get_categories_returns_catalog_sorted_by_code(client):
+ response = client.get("/api/categories")
+
+ assert response.status_code == 200
+ assert response.json() == {
+ "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/categories.md b/categories.md
new file mode 100644
index 0000000..06e5006
--- /dev/null
+++ b/categories.md
@@ -0,0 +1,87 @@
+# Pla: categories en YAML
+
+## Objectiu
+
+Fer que un YAML sigui la font de les categories, mantenint la categoria de cada prompt
+deduïda del nom del fitxer, i exposar-les a la UI mitjançant l'API.
+
+```text
+categories.yaml → carregador → PostgreSQL → API → frontend
+```
+
+## Implementació
+
+1. Crear `data/prompts/categories.yaml`:
+
+ ```yaml
+ 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.
+ ```
+
+2. Ampliar `scripts/carrega_inferencies.py` perquè:
+
+ - Llegeixi i validi el YAML abans de carregar els prompts.
+ - Insereixi categories noves i actualitzi `name` o `description` si canvien.
+ - No elimini categories absents del YAML, perquè poden tenir dades històriques.
+ - Continuï deduint la categoria: `correccio_1.txt` → `correccio`.
+ - Rebutgi categories deduïdes que no estiguin definides al catàleg.
+ - Accepti `--categories-file`, amb `data/prompts/categories.yaml` per defecte.
+
+3. Mantenir el seed i la migració inicial com a bootstrap, sense canviar `setup` ni Docker.
+ El YAML se sincronitza quan s'executa `make load_inferences`.
+
+4. Afegir l'endpoint públic `GET /api/categories`, que consulti PostgreSQL i retorni les
+ categories ordenades per `code`:
+
+ ```json
+ {
+ "categories": [
+ {
+ "code": "correccio",
+ "name": "Correcció",
+ "description": "Corregeix aquest text."
+ }
+ ]
+ }
+ ```
+
+5. Adaptar `frontend/` perquè usi l'endpoint:
+
+ - `types.ts`: afegir el tipus `Category`, convertir `CategoryCode` en `string` i eliminar
+ la constant hardcoded `CATEGORIES`.
+ - `api.ts`: afegir `api.categories()` per cridar `GET /categories`.
+ - `App.tsx`: carregar les categories una sola vegada i passar-les a les vistes.
+ - `TaskView.tsx` i `RankingView.tsx`: construir els filtres i les etiquetes amb les
+ categories rebudes, mostrant un estat de càrrega o error quan calgui.
+
+ «Qualsevol categoria» i «Global» continuaran sent opcions pròpies de la UI; no les
+ retornarà el backend.
+
+## Criteri de mínims canvis
+
+- No modificar la taula `categories`: els camps actuals són suficients.
+- Mantenir els prompts com a `.txt` i no afegir-hi `category_code`.
+- Ampliar el carregador existent, sense crear serveis innecessaris.
+- Mantenir la lògica especial de presentació de `correccio`.
+
+## Tests i verificació
+
+Cobrir la validació del YAML, la càrrega idempotent, l'actualització de metadades, la deducció
+de categoria, els noms invàlids i la resposta de l'endpoint. Adaptar `conftest.py` perquè
+carregui les categories des del YAML.
+
+Executar:
+
+```bash
+make test
+make check
+make frontend-check
+```
diff --git a/data/prompts/categories.yaml b/data/prompts/categories.yaml
new file mode 100644
index 0000000..4b67156
--- /dev/null
+++ b/data/prompts/categories.yaml
@@ -0,0 +1,10 @@
+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/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(null);
+ const [categories, setCategories] = useState(null);
+ const [categoriesError, setCategoriesError] = useState(false);
const navigate = useNavigate();
const refresh = useCallback(async () => {
@@ -31,6 +33,20 @@ export default function App() {
void refresh();
}, [refresh]);
+ const refreshCategories = useCallback(async () => {
+ setCategoriesError(false);
+ try {
+ setCategories(await api.categories());
+ } catch {
+ setCategories(null);
+ setCategoriesError(true);
+ }
+ }, []);
+
+ useEffect(() => {
+ void refreshCategories();
+ }, [refreshCategories]);
+
// La tasca en curs pertany a la sessió: quan s'acaba, s'ha de descartar. Si no,
// en tornar a entrar es restauraria amb el `vote_after` ja vençut (i per tant
// sense compte enrere), i si hi entrés una altra persona veuria una tasca que
@@ -96,9 +112,15 @@ export default function App() {
path="/"
element={
session.authenticated ? (
-
+ categories ? (
+
+ ) : (
+
+ )
+ ) : categories ? (
+ navigate("/login")} />
) : (
- navigate("/login")} />
+
)
}
/>
@@ -127,6 +149,23 @@ export default function App() {
);
}
+function CategoriesStatus({ error, onRetry }: { error: boolean; onRetry: () => void }) {
+ return (
+
+ {error ? (
+ <>
+
No s'han pogut carregar les categories.
+
+ >
+ ) : (
+
Carregant categories…
+ )}
+
+ );
+}
+
/** Porta amb una fletxa cap enfora: el gest habitual per a «surt». */
function LogoutIcon() {
return (
diff --git a/frontend/src/api.ts b/frontend/src/api.ts
index e8abf3c..05d408f 100644
--- a/frontend/src/api.ts
+++ b/frontend/src/api.ts
@@ -9,7 +9,15 @@
*/
import { readDetail, readErrorCode, TASK_TOKEN_INVALID } from "./errors";
-import type { CategoryFilter, Progress, Ranking, SessionState, Task, Winner } from "./types";
+import type {
+ Category,
+ CategoryFilter,
+ Progress,
+ Ranking,
+ SessionState,
+ Task,
+ Winner,
+} from "./types";
// `||` i no `??`: una variable definida però buida (cosa fàcil en un fitxer .env)
// ha de caure igualment al valor per defecte, o les crides perdrien el prefix /api.
@@ -55,6 +63,8 @@ async function request(path: string, init?: RequestInit): Promise {
}
export const api = {
+ categories: async () => (await request<{ categories: Category[] }>("/categories")).categories,
+
// Respon 200 tant si hi ha sessió com si no; la cookie és HttpOnly i el client
// no té cap altra manera de saber-ho.
session: () => request("/auth/session"),
diff --git a/frontend/src/components/RankingView.tsx b/frontend/src/components/RankingView.tsx
index a53cb36..0724e2f 100644
--- a/frontend/src/components/RankingView.tsx
+++ b/frontend/src/components/RankingView.tsx
@@ -1,9 +1,15 @@
import { useEffect, useState } from "react";
import { api, ApiError } from "../api";
-import { CATEGORIES, type CategoryFilter, type Ranking } from "../types";
+import type { Category, CategoryFilter, Ranking } from "../types";
-export default function RankingView({ onLogin }: { onLogin: () => void }) {
+export default function RankingView({
+ categories,
+ onLogin,
+}: {
+ categories: Category[];
+ onLogin: () => void;
+}) {
const [category, setCategory] = useState("");
const [ranking, setRanking] = useState(null);
const [message, setMessage] = useState(null);
@@ -39,7 +45,19 @@ export default function RankingView({ onLogin }: { onLogin: () => void }) {