Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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ï.
Expand Down
3 changes: 2 additions & 1 deletion backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"])
1 change: 1 addition & 0 deletions backend/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
15 changes: 15 additions & 0 deletions backend/app/routes/categories.py
Original file line number Diff line number Diff line change
@@ -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)
13 changes: 13 additions & 0 deletions backend/app/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 0 additions & 7 deletions backend/app/seeds.py

This file was deleted.

13 changes: 1 addition & 12 deletions backend/migrations/versions/94019e30371a_initial_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"),
)
Expand Down Expand Up @@ -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")
Expand Down
2 changes: 1 addition & 1 deletion backend/scripts/auth_flow_demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion backend/scripts/seed_mock_tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 7 additions & 2 deletions backend/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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!"

Expand All @@ -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)
Expand Down
59 changes: 58 additions & 1 deletion backend/tests/test_carrega_inferencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down Expand Up @@ -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ò.")
Expand Down
12 changes: 12 additions & 0 deletions backend/tests/test_categories_api.py
Original file line number Diff line number Diff line change
@@ -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)
2 changes: 1 addition & 1 deletion backend/tests/test_integration_scale.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
25 changes: 25 additions & 0 deletions data/prompts/categories.yaml
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions docs/db_schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ erDiagram
varchar(64) code
varchar(128) name
text description
text evaluation_instructions
}

prompts {
Expand Down
45 changes: 42 additions & 3 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,16 @@ 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 };

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<SessionState | null>(null);
const [categories, setCategories] = useState<Category[] | null>(null);
const [categoriesError, setCategoriesError] = useState(false);
const navigate = useNavigate();

const refresh = useCallback(async () => {
Expand All @@ -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
Expand Down Expand Up @@ -96,9 +112,15 @@ export default function App() {
path="/"
element={
session.authenticated ? (
<TaskView />
categories ? (
<TaskView categories={categories} />
) : (
<CategoriesStatus error={categoriesError} onRetry={refreshCategories} />
)
) : categories ? (
<RankingView categories={categories} onLogin={() => navigate("/login")} />
) : (
<RankingView onLogin={() => navigate("/login")} />
<CategoriesStatus error={categoriesError} onRetry={refreshCategories} />
)
}
/>
Expand Down Expand Up @@ -127,6 +149,23 @@ export default function App() {
);
}

function CategoriesStatus({ error, onRetry }: { error: boolean; onRetry: () => void }) {
return (
<div className="px-4 py-10 text-center text-slate-500">
{error ? (
<>
<p role="alert">No s'han pogut carregar les categories.</p>
<button type="button" onClick={onRetry} className="mt-3 underline hover:text-brand-600">
Torna-ho a provar
</button>
</>
) : (
<p>Carregant categories…</p>
)}
</div>
);
}

/** Porta amb una fletxa cap enfora: el gest habitual per a «surt». */
function LogoutIcon() {
return (
Expand Down
Loading