diff --git a/AGENTS.md b/AGENTS.md index 7ea5ac5..f730256 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,8 +55,8 @@ For a new grade, follow the same pattern: ## Naming Conventions -- Endpoint paths use the existing style: `/math/1_grade/`. -- Python package names should use valid identifiers like `grade_1`, not `1_grade`. +- Endpoint paths use the existing style: `/math/grade_1/`. +- Python package names should use valid identifiers like `grade_1`. - Problem files should use snake case, for example `join_pictures_with_quantity.py`. - Request schema names should end in `Request`. - Response schema names should describe the generated problem, for example `JoinPicturesWithQuantityProblem`. diff --git a/ENDPOINTS-EXAMPLE.md b/ENDPOINTS-EXAMPLE.md new file mode 100644 index 0000000..39d8f92 --- /dev/null +++ b/ENDPOINTS-EXAMPLE.md @@ -0,0 +1,51 @@ +# Endpoints examples + +Test quick examples for each endpoint: + +## Grade 1 + +### Join pictures with quantity + +```bash +curl -X POST "http://127.0.0.1:8000/math/grade_1/join_pictures_with_quantity" \ + -H "Content-Type: application/json" \ + -d '{ + "available_pictures": [ + {"id": "picture-1", "name": "Apple", "image_path": "/images/apple.png"}, + {"id": "picture-2", "name": "Banana", "image_path": "/images/banana.png"}, + {"id": "picture-3", "name": "Car", "image_path": "/images/car.png"}, + {"id": "picture-4", "name": "Dog", "image_path": "/images/dog.png"}, + {"id": "picture-5", "name": "Elephant", "image_path": "/images/elephant.png"}, + {"id": "picture-6", "name": "Fish", "image_path": "/images/fish.png"}, + {"id": "picture-7", "name": "Grape", "image_path": "/images/grape.png"}, + {"id": "picture-8", "name": "Hat", "image_path": "/images/hat.png"}, + {"id": "picture-9", "name": "Ice Cream", "image_path": "/images/ice-cream.png"}, + {"id": "picture-10", "name": "Juice", "image_path": "/images/juice.png"} + ], + "container_count_per_side": 5, + "min_quantity": 1, + "max_quantity": 10, + "seed": 1 + }' +``` + +### Where are more items + +```bash +curl -X POST "http://127.0.0.1:8000/math/grade_1/where_are_more_items" \ + -H "Content-Type: application/json" \ + -d '{ + "available_pictures": [ + {"id": "picture-1", "name": "Panda", "image_path": "/images/panda.png"}, + {"id": "picture-2", "name": "Koala", "image_path": "/images/koala.png"}, + {"id": "picture-3", "name": "Orange", "image_path": "/images/orange.png"}, + {"id": "picture-4", "name": "Apple", "image_path": "/images/apple.png"}, + {"id": "picture-5", "name": "Donut", "image_path": "/images/donut.png"}, + {"id": "picture-6", "name": "Candy", "image_path": "/images/candy.png"} + ], + "comparison_count": 3, + "min_quantity": 1, + "max_quantity": 10, + "seed": 1 + }' +``` diff --git a/app/problems/grade_1/images_for_reference/where-are-more-items.png b/app/problems/grade_1/images_for_reference/where-are-more-items.png new file mode 100644 index 0000000..34a0f18 Binary files /dev/null and b/app/problems/grade_1/images_for_reference/where-are-more-items.png differ diff --git a/app/problems/grade_1/where_are_more_items.py b/app/problems/grade_1/where_are_more_items.py new file mode 100644 index 0000000..4b1b9f2 --- /dev/null +++ b/app/problems/grade_1/where_are_more_items.py @@ -0,0 +1,55 @@ +import random + +from app.schemas.grade_1.where_are_more_items import ( + ItemGroup, + MoreItemsComparison, + PictureAsset, + WhereAreMoreItemsProblem, +) + + +def where_are_more_items( + available_pictures: list[dict], + comparison_count: int = 3, + min_quantity: int = 1, + max_quantity: int = 10, + seed: int | None = None, +) -> dict: + """Generate comparison cards where students choose the group with more items.""" + pictures = [PictureAsset.model_validate(picture) for picture in available_pictures] + + if len(pictures) < 2: + raise ValueError("available_pictures must contain at least 2 pictures") + + if min_quantity >= max_quantity: + raise ValueError("max_quantity must be greater than min_quantity") + + rng = random.Random(seed) + quantity_values = list(range(min_quantity, max_quantity + 1)) + comparisons: list[MoreItemsComparison] = [] + + for index in range(comparison_count): + left_picture, right_picture = rng.sample(pictures, 2) + left_quantity, right_quantity = rng.sample(quantity_values, 2) + answer_side = "left" if left_quantity > right_quantity else "right" + + comparisons.append( + MoreItemsComparison( + position=index + 1, + left_group=ItemGroup( + side="left", + picture=left_picture, + quantity=left_quantity, + ), + right_group=ItemGroup( + side="right", + picture=right_picture, + quantity=right_quantity, + ), + answer_side=answer_side, + ) + ) + + problem = WhereAreMoreItemsProblem(comparisons=comparisons) + + return problem.model_dump() diff --git a/app/routers/grade_1.py b/app/routers/grade_1.py index 12d8c21..6cbdf57 100644 --- a/app/routers/grade_1.py +++ b/app/routers/grade_1.py @@ -3,10 +3,15 @@ from fastapi import APIRouter, HTTPException from app.problems.grade_1.join_pictures_with_quantity import ( join_pictures_with_quantity, ) +from app.problems.grade_1.where_are_more_items import where_are_more_items from app.schemas.grade_1.join_pictures_with_quantity import ( JoinPicturesWithQuantityProblem, JoinPicturesWithQuantityRequest, ) +from app.schemas.grade_1.where_are_more_items import ( + WhereAreMoreItemsProblem, + WhereAreMoreItemsRequest, +) router = APIRouter(prefix="/grade_1", tags=["Grade 1"]) @@ -30,3 +35,24 @@ def create_join_pictures_with_quantity_problem( ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.post( + "/where_are_more_items", + response_model=WhereAreMoreItemsProblem, +) +def create_where_are_more_items_problem( + request: WhereAreMoreItemsRequest, +) -> dict: + try: + return where_are_more_items( + available_pictures=[ + picture.model_dump() for picture in request.available_pictures + ], + comparison_count=request.comparison_count, + min_quantity=request.min_quantity, + max_quantity=request.max_quantity, + seed=request.seed, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc diff --git a/app/schemas/grade_1/where_are_more_items.py b/app/schemas/grade_1/where_are_more_items.py new file mode 100644 index 0000000..b5cbbb2 --- /dev/null +++ b/app/schemas/grade_1/where_are_more_items.py @@ -0,0 +1,36 @@ +from typing import Literal + +from pydantic import BaseModel, Field, PositiveInt + + +class PictureAsset(BaseModel): + id: str = Field(min_length=1) + name: str = Field(min_length=1) + image_path: str = Field(min_length=1) + + +class ItemGroup(BaseModel): + side: Literal["left", "right"] + picture: PictureAsset + quantity: PositiveInt + + +class MoreItemsComparison(BaseModel): + position: PositiveInt + left_group: ItemGroup + right_group: ItemGroup + answer_side: Literal["left", "right"] + has_answer_box: bool = True + + +class WhereAreMoreItemsProblem(BaseModel): + instructions: str = "Marca dónde hay más." + comparisons: list[MoreItemsComparison] + + +class WhereAreMoreItemsRequest(BaseModel): + available_pictures: list[PictureAsset] = Field(min_length=1) + comparison_count: PositiveInt = 3 + min_quantity: PositiveInt = 1 + max_quantity: PositiveInt = 10 + seed: int | None = None diff --git a/tests/test_join_pictures_with_quantity_endpoint.py b/tests/test_join_pictures_with_quantity_endpoint.py index f28e6b2..7029426 100644 --- a/tests/test_join_pictures_with_quantity_endpoint.py +++ b/tests/test_join_pictures_with_quantity_endpoint.py @@ -11,7 +11,7 @@ class JoinPicturesWithQuantityEndpointTest(unittest.TestCase): def test_creates_problem(self) -> None: response = self.client.post( - "/math/1_grade/join_pictures_with_quantity", + "/math/grade_1/join_pictures_with_quantity", json={ "available_pictures": [ { @@ -37,7 +37,7 @@ class JoinPicturesWithQuantityEndpointTest(unittest.TestCase): def test_returns_bad_request_for_too_few_pictures(self) -> None: response = self.client.post( - "/math/1_grade/join_pictures_with_quantity", + "/math/grade_1/join_pictures_with_quantity", json={ "available_pictures": [ { diff --git a/tests/test_where_are_more_items_endpoint.py b/tests/test_where_are_more_items_endpoint.py new file mode 100644 index 0000000..8a172a3 --- /dev/null +++ b/tests/test_where_are_more_items_endpoint.py @@ -0,0 +1,72 @@ +import unittest + +from fastapi.testclient import TestClient + +from app.main import create_app + + +class WhereAreMoreItemsEndpointTest(unittest.TestCase): + def setUp(self) -> None: + self.client = TestClient(create_app()) + + def test_creates_problem(self) -> None: + response = self.client.post( + "/math/grade_1/where_are_more_items", + json={ + "available_pictures": [ + { + "id": f"picture-{index}", + "name": f"Picture {index}", + "image_path": f"/images/{index}.png", + } + for index in range(6) + ], + "seed": 1, + }, + ) + + self.assertEqual(response.status_code, 200) + problem = response.json() + self.assertEqual(problem["instructions"], "Marca dónde hay más.") + self.assertEqual(len(problem["comparisons"]), 3) + + for comparison in problem["comparisons"]: + left_group = comparison["left_group"] + right_group = comparison["right_group"] + + self.assertEqual(left_group["side"], "left") + self.assertEqual(right_group["side"], "right") + self.assertNotEqual(left_group["quantity"], right_group["quantity"]) + self.assertIn("picture", left_group) + self.assertIn("picture", right_group) + + expected_answer_side = ( + "left" + if left_group["quantity"] > right_group["quantity"] + else "right" + ) + self.assertEqual(comparison["answer_side"], expected_answer_side) + + def test_returns_bad_request_for_too_few_figures(self) -> None: + response = self.client.post( + "/math/grade_1/where_are_more_items", + json={ + "available_pictures": [ + { + "id": "picture-1", + "name": "Picture 1", + "image_path": "/images/1.png", + } + ] + }, + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual( + response.json(), + {"detail": "available_pictures must contain at least 2 pictures"}, + ) + + +if __name__ == "__main__": + unittest.main()