feat: add compose and decompose numbers problem

This commit is contained in:
AlanSilvaaa
2026-05-26 11:26:26 -04:00
parent 801cdee331
commit dacf148a34
7 changed files with 182 additions and 0 deletions

View File

@@ -53,6 +53,8 @@ For a new grade, follow the same pattern:
3. Include the new grade router from `app/routers/math.py`. 3. Include the new grade router from `app/routers/math.py`.
4. Add tests. 4. Add tests.
Every time you add a new problem, also add an endpoint curl example on the ENDPOINTS-EXAMPLE.md file.
## Naming Conventions ## Naming Conventions
- Endpoint paths use the existing style: `/math/grade_1/<problem_name>`. - Endpoint paths use the existing style: `/math/grade_1/<problem_name>`.

View File

@@ -49,3 +49,20 @@ curl -X POST "http://127.0.0.1:8000/math/grade_1/where_are_more_items" \
"seed": 1 "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
}'
```

View File

@@ -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()

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

View File

@@ -1,9 +1,16 @@
from fastapi import APIRouter, HTTPException 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 ( from app.problems.grade_1.join_pictures_with_quantity import (
join_pictures_with_quantity, join_pictures_with_quantity,
) )
from app.problems.grade_1.where_are_more_items import where_are_more_items 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 ( from app.schemas.grade_1.join_pictures_with_quantity import (
JoinPicturesWithQuantityProblem, JoinPicturesWithQuantityProblem,
JoinPicturesWithQuantityRequest, JoinPicturesWithQuantityRequest,
@@ -16,6 +23,24 @@ from app.schemas.grade_1.where_are_more_items import (
router = APIRouter(prefix="/grade_1", tags=["Grade 1"]) 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( @router.post(
"/join_pictures_with_quantity", "/join_pictures_with_quantity",
response_model=JoinPicturesWithQuantityProblem, response_model=JoinPicturesWithQuantityProblem,

View File

@@ -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

View File

@@ -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()