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 }) {

- {CATEGORIES.map((item) => ( + + {categories.map((item) => ( ))}
@@ -71,7 +89,7 @@ export default function RankingView({ onLogin }: { onLogin: () => void }) {

{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..f605cd1 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(""); const [task, setTask] = useState(null); const [progress, setProgress] = useState(null); @@ -238,9 +238,10 @@ export default function TaskView() { // automàtic en tocar el desplegable i deixa la pàgina desquadrada. className="shrink-0 rounded-md border border-slate-300 px-3 py-2 text-base sm:w-56" > - {CATEGORIES.map((item) => ( + + {categories.map((item) => ( ))} @@ -255,7 +256,12 @@ export default function TaskView() { )} {exhausted && ( - setCategory("")} /> + setCategory("")} + /> )} {task && parts && ( @@ -272,7 +278,8 @@ export default function TaskView() { Tipus de tasca: {" "} - {CATEGORIES.find((item) => item.code === task.category_code)?.label} + {categories.find((item) => item.code === task.category_code)?.name ?? + task.category_code}

@@ -403,14 +410,16 @@ export default function TaskView() { */ function Exhausted({ category, + categories, progress, onContinue, }: { category: CategoryFilter; + categories: Category[]; progress: Progress | null; onContinue: () => void; }) { - const label = CATEGORIES.find((item) => item.code === category)?.label; + const label = categories.find((item) => item.code === category)?.name ?? category; // Sense filtre, el backend ja ha mirat totes les categories. Amb filtre, només ho // podem afirmar si tenim el progrés: si ha fallat, oferim continuar, que és el // camí segur — mai anunciem el final basant-nos en una xifra que no tenim. diff --git a/frontend/src/types.ts b/frontend/src/types.ts index bc14475..ce28939 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -1,16 +1,15 @@ /** Tipus compartits, alineats amb els esquemes Pydantic de `backend/app/schemas.py`. */ -export type CategoryCode = "correccio" | "reformulacio" | "traduccio"; +export type CategoryCode = string; /** `""` vol dir «qualsevol»: `GET /api/task` sense `category_code` tria la primera pendent. */ export type CategoryFilter = CategoryCode | ""; -export const CATEGORIES: { code: CategoryFilter; label: string }[] = [ - { code: "", label: "Qualsevol categoria" }, - { code: "correccio", label: "Correcció" }, - { code: "reformulacio", label: "Reformulació" }, - { code: "traduccio", label: "Traducció" }, -]; +export interface Category { + code: CategoryCode; + name: string; + description: string | null; +} /** El backend no revela mai quin model ha generat cada resposta: l'avaluació és cega. */ export interface Task { diff --git a/scripts/README.md b/scripts/README.md index 986df2a..f0b58ac 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -118,7 +118,7 @@ Aquesta configuració fa servir `hf-internal-testing/tiny-random-gpt2`, un model ### 8. Carregar prompts i inferències a la base de dades -`scripts/carrega_inferencies.py` publica els fitxers disponibles localment a les taules `prompts` i `responses`. Llegeix els prompts de `data/prompts/v1/*.txt` (text pla, clau `(version, code)`, on `code` és el nom del fitxer i la categoria es dedueix del prefix, p. ex. `traduccio_1` -> `traduccio`) i les inferències de `data/inferencies/v1//*.yaml` (clau `(prompt_id, model)`). El raonament intern es desa a les metadades, no al text visible, perquè l'avaluació és a cegues. +`scripts/carrega_inferencies.py` sincronitza `data/prompts/categories.yaml` amb la taula `categories` i publica els fitxers disponibles localment a les taules `prompts` i `responses`. Llegeix els prompts de `data/prompts/v1/*.txt` (text pla, clau `(version, code)`, on `code` és el nom del fitxer i la categoria es dedueix del prefix, p. ex. `traduccio_1` -> `traduccio`) i les inferències de `data/inferencies/v1//*.yaml` (clau `(prompt_id, model)`). El raonament intern es desa a les metadades, no al text visible, perquè l'avaluació és a cegues. Les inferències de referència es conserven a la branca `dades_inferencia`, separades de les branques de codi. El target `make load_reference_inferences` crea o reutilitza un worktree paral·lel i apunta el carregador a les dades de referència. @@ -130,7 +130,7 @@ Necessita la base de dades en marxa i migrada, i les mateixes variables de conne make load_inferences ``` -Per defecte usa `data/prompts/v1` i `data/inferencies/v1`. Es poden sobreescriure els directoris i la versió amb variables d'entorn: +Per defecte usa `data/prompts/categories.yaml`, `data/prompts/v1` i `data/inferencies/v1`. Es poden sobreescriure els directoris i la versió amb variables d'entorn: ```bash PROMPTS_DIR=data/prompts/v2 INFERENCIES_DIR=data/inferencies/v2 make load_inferences diff --git a/scripts/carrega_inferencies.py b/scripts/carrega_inferencies.py index daef716..855bd8d 100644 --- a/scripts/carrega_inferencies.py +++ b/scripts/carrega_inferencies.py @@ -22,6 +22,7 @@ from typing import Any import yaml +from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator REPO_ROOT = Path(__file__).resolve().parents[1] # El model de dades i la configuració viuen al paquet backend/app. @@ -36,6 +37,7 @@ LOGGER = logging.getLogger("carrega_inferencies") DEFAULT_VERSION = "v1" +DEFAULT_CATEGORIES_FILE = REPO_ROOT / "data" / "prompts" / "categories.yaml" DEFAULT_PROMPTS_DIR = REPO_ROOT / "data" / "prompts" / DEFAULT_VERSION DEFAULT_INFERENCIES_DIR = REPO_ROOT / "data" / "inferencies" / DEFAULT_VERSION @@ -72,6 +74,51 @@ class SchemaError(Exception): """ +class CategoryCatalogError(ValueError): + """El catàleg no existeix o no compleix l'esquema esperat.""" + + +class CategoryDefinition(BaseModel): + """Metadades d'una categoria declarada al catàleg.""" + + model_config = ConfigDict(extra="forbid") + + code: str = Field(pattern=r"^[a-z][a-z0-9_]*$") + name: str + description: str | None = None + + @field_validator("name") + @classmethod + def validate_name(cls, value: str) -> str: + """Normalitza el nom i rebutja cadenes buides.""" + value = value.strip() + if not value: + raise ValueError("el nom no pot ser buit") + return value + + +class CategoryCatalog(BaseModel): + """Document arrel del fitxer YAML.""" + + model_config = ConfigDict(extra="forbid") + + categories: list[CategoryDefinition] + + +def load_category_catalog(path: Path | str) -> list[CategoryDefinition]: + """Llegeix i valida un catàleg YAML de categories.""" + path = Path(path) + try: + document = CategoryCatalog.model_validate(yaml.safe_load(path.read_text(encoding="utf-8"))) + except (OSError, yaml.YAMLError, ValidationError) as error: + raise CategoryCatalogError(f"catàleg de categories no vàlid ({path}): {error}") from error + + codes = [category.code for category in document.categories] + if len(codes) != len(set(codes)): + raise CategoryCatalogError("el catàleg conté codis de categoria duplicats") + return document.categories + + @dataclass(slots=True) class PromptRecord: """Prompt normalitzat, a punt per fer *upsert* a la taula ``prompts``.""" @@ -363,6 +410,33 @@ def load_prompts( upsert_prompt(session, record, category_ids, stats) +def load_categories( + session: Session, + categories: list[CategoryDefinition], +) -> dict[str, int]: + """Sincronitza el catàleg i retorna els identificadors per codi.""" + existing_by_code = { + category.code: category for category in session.scalars(select(Category)).all() + } + + for definition in categories: + existing = existing_by_code.get(definition.code) + if existing is None: + existing = Category( + code=definition.code, + name=definition.name, + description=definition.description, + ) + session.add(existing) + session.flush() + existing_by_code[definition.code] = existing + elif existing.name != definition.name or existing.description != definition.description: + existing.name = definition.name + existing.description = definition.description + + return {code: category.id for code, category in existing_by_code.items()} + + def load_responses(session: Session, inferencies_dir: Path, version: str, stats: Stats) -> None: """Carrega totes les inferències d'un directori (recursivament). @@ -387,6 +461,7 @@ def run_load( prompts_dir: Path | str, inferencies_dir: Path | str, version: str | None = None, + categories_file: Path | str = DEFAULT_CATEGORIES_FILE, ) -> Summary: """Carrega prompts i inferències dins de la sessió donada. @@ -407,8 +482,8 @@ def run_load( inferencies_dir = Path(inferencies_dir) version = version or prompts_dir.name - category_ids = {category.code: category.id for category in session.scalars(select(Category))} - + categories = load_category_catalog(categories_file) + category_ids = load_categories(session, categories) summary = Summary(prompts=Stats(), responses=Stats()) load_prompts(session, prompts_dir, version, category_ids, summary.prompts) load_responses(session, inferencies_dir, version, summary.responses) @@ -431,11 +506,17 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: parser = argparse.ArgumentParser( description="Càrrega idempotent de prompts i inferències a la base de dades.", ) + parser.add_argument( + "--categories-file", + type=Path, + default=DEFAULT_CATEGORIES_FILE, + help="Fitxer YAML amb el catàleg de categories.", + ) parser.add_argument( "--prompts-dir", type=Path, default=DEFAULT_PROMPTS_DIR, - help="Directori amb els fitxers YAML de prompts.", + help="Directori amb els fitxers de text dels prompts.", ) parser.add_argument( "--inferencies-dir", @@ -470,7 +551,17 @@ def main(argv: list[str] | None = None) -> int: logging.basicConfig(level=args.log_level, format="%(levelname)s:%(name)s:%(message)s") with get_sessionmaker()() as session: - summary = run_load(session, args.prompts_dir, args.inferencies_dir, args.version) + try: + summary = run_load( + session, + args.prompts_dir, + args.inferencies_dir, + args.version, + categories_file=args.categories_file, + ) + except CategoryCatalogError as error: + LOGGER.error("%s", error) + return 1 session.commit() print(f"Prompts: {_format_stats(summary.prompts)}") From 918ae9361f968d2dd87140be7f826a8c2aa65bce Mon Sep 17 00:00:00 2001 From: Jordi Mas Date: Sat, 5 Sep 2026 11:34:44 +0200 Subject: [PATCH 2/7] style: format category loader tests --- backend/tests/test_carrega_inferencies.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/backend/tests/test_carrega_inferencies.py b/backend/tests/test_carrega_inferencies.py index f560751..a169834 100644 --- a/backend/tests/test_carrega_inferencies.py +++ b/backend/tests/test_carrega_inferencies.py @@ -87,9 +87,10 @@ def test_categories_are_inserted_updated_and_loaded_idempotently(session, dirs, 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 + assert ( + session.scalar(select(func.count()).select_from(Category).where(Category.code == "cultura")) + == 1 + ) @pytest.mark.parametrize( From 2684faeb85fcefdde510f686237ea71f1e3ebf63 Mon Sep 17 00:00:00 2001 From: Jordi Mas Date: Sat, 5 Sep 2026 11:38:27 +0200 Subject: [PATCH 3/7] chore: stop tracking category implementation plan --- categories.md | 87 --------------------------------------------------- 1 file changed, 87 deletions(-) delete mode 100644 categories.md diff --git a/categories.md b/categories.md deleted file mode 100644 index 06e5006..0000000 --- a/categories.md +++ /dev/null @@ -1,87 +0,0 @@ -# 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 -``` From 0f8c4d04f88297e11c2f250bb891263dec48e401 Mon Sep 17 00:00:00 2001 From: Jordi Mas Date: Sat, 5 Sep 2026 18:19:47 +0200 Subject: [PATCH 4/7] Descripcions --- backend/README.md | 3 ++- backend/app/models.py | 1 + backend/app/schemas.py | 1 + backend/app/seeds.py | 7 ----- .../versions/94019e30371a_initial_schema.py | 13 +-------- backend/scripts/auth_flow_demo.py | 2 +- backend/scripts/seed_mock_tasks.py | 2 +- backend/tests/conftest.py | 9 +++++-- backend/tests/test_carrega_inferencies.py | 14 ++++++++-- backend/tests/test_categories_api.py | 27 ++++++------------- backend/tests/test_integration_scale.py | 2 +- data/prompts/categories.yaml | 15 +++++++++++ docs/db_schema.md | 1 + frontend/src/types.ts | 1 + scripts/README.md | 2 +- scripts/carrega_inferencies.py | 15 ++++++++--- 16 files changed, 64 insertions(+), 51 deletions(-) delete mode 100644 backend/app/seeds.py diff --git a/backend/README.md b/backend/README.md index 2f2a362..79b21ae 100644 --- a/backend/README.md +++ b/backend/README.md @@ -65,7 +65,8 @@ Retorna el catàleg públic de categories, ordenat per codi. Les dades provenen { "code": "correccio", "name": "Correcció", - "description": "Corregeix aquest text." + "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." } ] } 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/schemas.py b/backend/app/schemas.py index 5fb02ff..34fd04a 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -11,6 +11,7 @@ class CategoryResponse(BaseModel): code: str name: str description: str | None + evaluation_instructions: str | None class CategoriesResponse(BaseModel): 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 a169834..6459225 100644 --- a/backend/tests/test_carrega_inferencies.py +++ b/backend/tests/test_carrega_inferencies.py @@ -63,13 +63,18 @@ def _count(session, model) -> int: return session.scalar(select(func.count()).select_from(model)) -def write_categories(path: Path, name: str = "Cultura") -> None: +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", ) @@ -82,11 +87,16 @@ def test_categories_are_inserted_updated_and_loaded_idempotently(session, dirs, 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") + 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 diff --git a/backend/tests/test_categories_api.py b/backend/tests/test_categories_api.py index cdeeb69..e6c2546 100644 --- a/backend/tests/test_categories_api.py +++ b/backend/tests/test_categories_api.py @@ -2,22 +2,11 @@ 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.", - }, - ] - } + 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 index 4b67156..ecad51d 100644 --- a/data/prompts/categories.yaml +++ b/data/prompts/categories.yaml @@ -2,9 +2,24 @@ 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/types.ts b/frontend/src/types.ts index ce28939..71dbe87 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -9,6 +9,7 @@ export interface Category { code: CategoryCode; name: string; description: string | null; + evaluation_instructions: string | null; } /** El backend no revela mai quin model ha generat cada resposta: l'avaluació és cega. */ diff --git a/scripts/README.md b/scripts/README.md index f0b58ac..4504451 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -118,7 +118,7 @@ Aquesta configuració fa servir `hf-internal-testing/tiny-random-gpt2`, un model ### 8. Carregar prompts i inferències a la base de dades -`scripts/carrega_inferencies.py` sincronitza `data/prompts/categories.yaml` amb la taula `categories` i publica els fitxers disponibles localment a les taules `prompts` i `responses`. Llegeix els prompts de `data/prompts/v1/*.txt` (text pla, clau `(version, code)`, on `code` és el nom del fitxer i la categoria es dedueix del prefix, p. ex. `traduccio_1` -> `traduccio`) i les inferències de `data/inferencies/v1//*.yaml` (clau `(prompt_id, model)`). El raonament intern es desa a les metadades, no al text visible, perquè l'avaluació és a cegues. +`scripts/carrega_inferencies.py` sincronitza `data/prompts/categories.yaml`, la font de veritat de les categories, amb la taula `categories` i publica els fitxers disponibles localment a les taules `prompts` i `responses`. Llegeix els prompts de `data/prompts/v1/*.txt` (text pla, clau `(version, code)`, on `code` és el nom del fitxer i la categoria es dedueix del prefix, p. ex. `traduccio_1` -> `traduccio`) i les inferències de `data/inferencies/v1//*.yaml` (clau `(prompt_id, model)`). El raonament intern es desa a les metadades, no al text visible, perquè l'avaluació és a cegues. Les inferències de referència es conserven a la branca `dades_inferencia`, separades de les branques de codi. El target `make load_reference_inferences` crea o reutilitza un worktree paral·lel i apunta el carregador a les dades de referència. diff --git a/scripts/carrega_inferencies.py b/scripts/carrega_inferencies.py index 855bd8d..70d6804 100644 --- a/scripts/carrega_inferencies.py +++ b/scripts/carrega_inferencies.py @@ -86,11 +86,12 @@ class CategoryDefinition(BaseModel): code: str = Field(pattern=r"^[a-z][a-z0-9_]*$") name: str description: str | None = None + evaluation_instructions: str - @field_validator("name") + @field_validator("name", "evaluation_instructions") @classmethod - def validate_name(cls, value: str) -> str: - """Normalitza el nom i rebutja cadenes buides.""" + def validate_required_text(cls, value: str) -> str: + """Normalitza els textos obligatoris i rebutja cadenes buides.""" value = value.strip() if not value: raise ValueError("el nom no pot ser buit") @@ -426,13 +427,19 @@ def load_categories( code=definition.code, name=definition.name, description=definition.description, + evaluation_instructions=definition.evaluation_instructions, ) session.add(existing) session.flush() existing_by_code[definition.code] = existing - elif existing.name != definition.name or existing.description != definition.description: + elif ( + existing.name != definition.name + or existing.description != definition.description + or existing.evaluation_instructions != definition.evaluation_instructions + ): existing.name = definition.name existing.description = definition.description + existing.evaluation_instructions = definition.evaluation_instructions return {code: category.id for code, category in existing_by_code.items()} From d51bc81c7b6757149204f6c9b8f709d779cf99f0 Mon Sep 17 00:00:00 2001 From: Jordi Mas Date: Sat, 5 Sep 2026 18:26:00 +0200 Subject: [PATCH 5/7] refactor: simplify category loading --- scripts/carrega_inferencies.py | 41 +++++++++++++--------------------- 1 file changed, 15 insertions(+), 26 deletions(-) diff --git a/scripts/carrega_inferencies.py b/scripts/carrega_inferencies.py index 70d6804..7c5ee0a 100644 --- a/scripts/carrega_inferencies.py +++ b/scripts/carrega_inferencies.py @@ -413,35 +413,24 @@ def load_prompts( def load_categories( session: Session, - categories: list[CategoryDefinition], + definitions: list[CategoryDefinition], ) -> dict[str, int]: """Sincronitza el catàleg i retorna els identificadors per codi.""" - existing_by_code = { - category.code: category for category in session.scalars(select(Category)).all() - } + categories = {category.code: category for category in session.scalars(select(Category)).all()} - for definition in categories: - existing = existing_by_code.get(definition.code) - if existing is None: - existing = Category( - code=definition.code, - name=definition.name, - description=definition.description, - evaluation_instructions=definition.evaluation_instructions, - ) - session.add(existing) - session.flush() - existing_by_code[definition.code] = existing - elif ( - existing.name != definition.name - or existing.description != definition.description - or existing.evaluation_instructions != definition.evaluation_instructions - ): - existing.name = definition.name - existing.description = definition.description - existing.evaluation_instructions = definition.evaluation_instructions - - return {code: category.id for code, category in existing_by_code.items()} + for definition in definitions: + category = categories.get(definition.code) + if category is None: + category = Category(code=definition.code) + categories[definition.code] = category + session.add(category) + + category.name = definition.name + category.description = definition.description + category.evaluation_instructions = definition.evaluation_instructions + + session.flush() + return {definition.code: categories[definition.code].id for definition in definitions} def load_responses(session: Session, inferencies_dir: Path, version: str, stats: Stats) -> None: From 1c0fdd73f8aae522e80b83f6dd4f9a702c159ea9 Mon Sep 17 00:00:00 2001 From: Jordi Mas Date: Sat, 5 Sep 2026 18:28:37 +0200 Subject: [PATCH 6/7] feat: show category evaluation instructions --- frontend/src/components/TaskView.tsx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/frontend/src/components/TaskView.tsx b/frontend/src/components/TaskView.tsx index f605cd1..c687875 100644 --- a/frontend/src/components/TaskView.tsx +++ b/frontend/src/components/TaskView.tsx @@ -177,6 +177,7 @@ export default function TaskView({ categories }: { categories: Category[] }) { }, [locked, task, resolve, showOnboarding]); const parts = task ? splitPrompt(task.prompt) : null; + const taskCategory = categories.find((item) => item.code === task?.category_code); const isCorrection = task?.category_code === "correccio"; const onboardingSteps: OnboardingStep[] = [ @@ -278,8 +279,7 @@ export default function TaskView({ categories }: { categories: Category[] }) { Tipus de tasca: {" "} - {categories.find((item) => item.code === task.category_code)?.name ?? - task.category_code} + {taskCategory?.name ?? task.category_code}

@@ -319,14 +319,14 @@ export default function TaskView({ categories }: { categories: Category[] }) { ))} -

- +

+

La vostra tasca: - {" "} - - avaluar la resposta dels dos models. - -

+

+

+ {taskCategory?.evaluation_instructions ?? "Avaluar la resposta dels dos models."} +

+
{isCorrection && } From 20e9f67473d0a148ace8de4d183057d6a3d0b303 Mon Sep 17 00:00:00 2001 From: Jordi Mas Date: Sat, 5 Sep 2026 18:32:31 +0200 Subject: [PATCH 7/7] Indicacions --- frontend/src/components/TaskView.tsx | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/TaskView.tsx b/frontend/src/components/TaskView.tsx index c687875..50ce572 100644 --- a/frontend/src/components/TaskView.tsx +++ b/frontend/src/components/TaskView.tsx @@ -320,12 +320,24 @@ export default function TaskView({ categories }: { categories: Category[] }) { ))}
-

- La vostra tasca: -

-

- {taskCategory?.evaluation_instructions ?? "Avaluar la resposta dels dos models."} +

+ + La vostra tasca: + {" "} + + avaluar la resposta dels dos models. +

+ {taskCategory?.evaluation_instructions && ( + <> +

+ A tenir en compte: +

+

+ {taskCategory.evaluation_instructions} +

+ + )}
{isCorrection && }