diff --git a/AGENTS.md b/AGENTS.md index f730256..912936c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,6 +53,8 @@ For a new grade, follow the same pattern: 3. Include the new grade router from `app/routers/math.py`. 4. Add tests. +Every time you add a new problem, also add an endpoint curl example on the ENDPOINTS-EXAMPLE.md file. + ## Naming Conventions - Endpoint paths use the existing style: `/math/grade_1/`. diff --git a/ENDPOINTS-EXAMPLE.md b/ENDPOINTS-EXAMPLE.md index 39d8f92..3efe8b0 100644 --- a/ENDPOINTS-EXAMPLE.md +++ b/ENDPOINTS-EXAMPLE.md @@ -49,3 +49,20 @@ curl -X POST "http://127.0.0.1:8000/math/grade_1/where_are_more_items" \ "seed": 1 }' ``` + +### Compose and decompose numbers + +``` +curl -X POST "http://127.0.0.1:8000/math/grade_1/compose_and_decompose_numbers" \ + -H "Content-Type: application/json" \ + -d '{ + "picture": { + "id": "cube", + "name": "Cube", + "image_path": "/images/cube.png" + }, + "whole": 10, + "randomize_rows": false, + "seed": 1 + }' +``` diff --git a/app/problems/grade_1/compose_and_decompose_numbers.py b/app/problems/grade_1/compose_and_decompose_numbers.py new file mode 100644 index 0000000..b3dc374 --- /dev/null +++ b/app/problems/grade_1/compose_and_decompose_numbers.py @@ -0,0 +1,42 @@ +import random + +from app.schemas.grade_1.compose_and_decompose_numbers import ( + ComposeAndDecomposeNumbersProblem, + DecompositionRow, + PictureAsset, +) + + +def compose_and_decompose_numbers( + picture: dict, + whole: int = 10, + randomize_rows: bool = False, + seed: int | None = None, +) -> dict: + """Generate number decomposition rows for one picture asset.""" + if whole < 2: + raise ValueError("whole must be at least 2") + + selected_picture = PictureAsset.model_validate(picture) + pairs = [(first_part, whole - first_part) for first_part in range(whole - 1, 0, -1)] + + if randomize_rows: + rng = random.Random(seed) + rng.shuffle(pairs) + + problem = ComposeAndDecomposeNumbersProblem( + whole=whole, + picture=selected_picture, + rows=[ + DecompositionRow( + position=index + 1, + whole=whole, + first_part=first_part, + second_part=second_part, + picture=selected_picture, + ) + for index, (first_part, second_part) in enumerate(pairs) + ], + ) + + return problem.model_dump() diff --git a/app/problems/grade_1/images_for_reference/compose-and-decompose-numbers.png b/app/problems/grade_1/images_for_reference/compose-and-decompose-numbers.png new file mode 100644 index 0000000..dd7fcae Binary files /dev/null and b/app/problems/grade_1/images_for_reference/compose-and-decompose-numbers.png differ diff --git a/app/routers/grade_1.py b/app/routers/grade_1.py index 6cbdf57..c3ec5c3 100644 --- a/app/routers/grade_1.py +++ b/app/routers/grade_1.py @@ -1,9 +1,16 @@ from fastapi import APIRouter, HTTPException +from app.problems.grade_1.compose_and_decompose_numbers import ( + compose_and_decompose_numbers, +) 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.compose_and_decompose_numbers import ( + ComposeAndDecomposeNumbersProblem, + ComposeAndDecomposeNumbersRequest, +) from app.schemas.grade_1.join_pictures_with_quantity import ( JoinPicturesWithQuantityProblem, JoinPicturesWithQuantityRequest, @@ -16,6 +23,24 @@ from app.schemas.grade_1.where_are_more_items import ( router = APIRouter(prefix="/grade_1", tags=["Grade 1"]) +@router.post( + "/compose_and_decompose_numbers", + response_model=ComposeAndDecomposeNumbersProblem, +) +def create_compose_and_decompose_numbers_problem( + request: ComposeAndDecomposeNumbersRequest, +) -> dict: + try: + return compose_and_decompose_numbers( + picture=request.picture.model_dump(), + whole=request.whole, + randomize_rows=request.randomize_rows, + seed=request.seed, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @router.post( "/join_pictures_with_quantity", response_model=JoinPicturesWithQuantityProblem, diff --git a/app/schemas/grade_1/compose_and_decompose_numbers.py b/app/schemas/grade_1/compose_and_decompose_numbers.py new file mode 100644 index 0000000..25fca5f --- /dev/null +++ b/app/schemas/grade_1/compose_and_decompose_numbers.py @@ -0,0 +1,30 @@ +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 DecompositionRow(BaseModel): + position: PositiveInt + whole: PositiveInt + first_part: PositiveInt + second_part: PositiveInt + picture: PictureAsset + has_answer_boxes: bool = True + + +class ComposeAndDecomposeNumbersProblem(BaseModel): + instructions: str = "Compón y descompón el número." + whole: PositiveInt + picture: PictureAsset + rows: list[DecompositionRow] + + +class ComposeAndDecomposeNumbersRequest(BaseModel): + picture: PictureAsset + whole: PositiveInt = 10 + randomize_rows: bool = False + seed: int | None = None diff --git a/tests/test_compose_and_decompose_numbers_endpoint.py b/tests/test_compose_and_decompose_numbers_endpoint.py new file mode 100644 index 0000000..e207a32 --- /dev/null +++ b/tests/test_compose_and_decompose_numbers_endpoint.py @@ -0,0 +1,66 @@ +import unittest + +from fastapi.testclient import TestClient + +from app.main import create_app + + +class ComposeAndDecomposeNumbersEndpointTest(unittest.TestCase): + def setUp(self) -> None: + self.client = TestClient(create_app()) + self.picture = { + "id": "cube", + "name": "Cube", + "image_path": "/images/cube.png", + } + + def test_creates_ordered_problem(self) -> None: + response = self.client.post( + "/math/grade_1/compose_and_decompose_numbers", + json={"picture": self.picture}, + ) + + self.assertEqual(response.status_code, 200) + problem = response.json() + rows = problem["rows"] + + self.assertEqual(problem["instructions"], "Compón y descompón el número.") + self.assertEqual(problem["whole"], 10) + self.assertEqual(problem["picture"], self.picture) + self.assertEqual(len(rows), 9) + self.assertEqual( + [(row["first_part"], row["second_part"]) for row in rows], + [(9, 1), (8, 2), (7, 3), (6, 4), (5, 5), (4, 6), (3, 7), (2, 8), (1, 9)], + ) + + for row in rows: + self.assertEqual(row["whole"], 10) + self.assertEqual(row["first_part"] + row["second_part"], 10) + self.assertEqual(row["picture"], self.picture) + + def test_can_randomize_rows(self) -> None: + response = self.client.post( + "/math/grade_1/compose_and_decompose_numbers", + json={"picture": self.picture, "randomize_rows": True, "seed": 1}, + ) + + self.assertEqual(response.status_code, 200) + rows = response.json()["rows"] + ordered_pairs = [(9, 1), (8, 2), (7, 3), (6, 4), (5, 5), (4, 6), (3, 7), (2, 8), (1, 9)] + randomized_pairs = [(row["first_part"], row["second_part"]) for row in rows] + + self.assertNotEqual(randomized_pairs, ordered_pairs) + self.assertCountEqual(randomized_pairs, ordered_pairs) + + def test_returns_bad_request_for_whole_less_than_two(self) -> None: + response = self.client.post( + "/math/grade_1/compose_and_decompose_numbers", + json={"picture": self.picture, "whole": 1}, + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json(), {"detail": "whole must be at least 2"}) + + +if __name__ == "__main__": + unittest.main()