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(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..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(""); const [task, setTask] = useState(null); const [progress, setProgress] = useState(null); @@ -177,6 +177,7 @@ export default function TaskView() { }, [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[] = [ @@ -238,9 +239,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 +257,12 @@ export default function TaskView() { )} {exhausted && ( - setCategory("")} /> + setCategory("")} + /> )} {task && parts && ( @@ -272,7 +279,7 @@ export default function TaskView() { Tipus de tasca: {" "} - {CATEGORIES.find((item) => item.code === task.category_code)?.label} + {taskCategory?.name ?? task.category_code}

@@ -312,14 +319,26 @@ export default function TaskView() { ))} -

- - La vostra tasca: - {" "} - - 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 && } @@ -403,14 +422,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..71dbe87 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -1,16 +1,16 @@ /** 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; + evaluation_instructions: 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..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` 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. @@ -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..7c5ee0a 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,52 @@ 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 + evaluation_instructions: str + + @field_validator("name", "evaluation_instructions") + @classmethod + 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") + 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 +411,28 @@ def load_prompts( upsert_prompt(session, record, category_ids, stats) +def load_categories( + session: Session, + definitions: list[CategoryDefinition], +) -> dict[str, int]: + """Sincronitza el catàleg i retorna els identificadors per codi.""" + categories = {category.code: category for category in session.scalars(select(Category)).all()} + + 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: """Carrega totes les inferències d'un directori (recursivament). @@ -387,6 +457,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 +478,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 +502,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 +547,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)}")