diff --git a/ENDPOINTS-EXAMPLE.md b/ENDPOINTS-EXAMPLE.md index 9a29eb3..e4bea12 100644 --- a/ENDPOINTS-EXAMPLE.md +++ b/ENDPOINTS-EXAMPLE.md @@ -66,3 +66,31 @@ curl -X POST "http://127.0.0.1:8000/math/grade_1/compose_and_decompose_numbers" "seed": 1 }' ``` + +### Sum with image reference + +```bash +curl -X POST "http://127.0.0.1:8000/math/grade_1/sum_with_image_reference" \ + -H "Content-Type: application/json" \ + -d '{ + "first_quantity": 5, + "second_quantity": 4, + "first_description": "flores rojas", + "second_description": "flores blancas", + "common_object": "flores", + "first_picture": { + "id": "red-flower", + "name": "Red flower", + "image_path": "/images/red-flower.png" + }, + "second_picture": { + "id": "white-flower", + "name": "White flower", + "image_path": "/images/white-flower.png" + }, + "option_count": 3, + "min_option_quantity": 1, + "max_option_quantity": 20, + "seed": 1 + }' +``` diff --git a/app/problems/grade_1/images_for_reference/sum-with-image-reference.png b/app/problems/grade_1/images_for_reference/sum-with-image-reference.png new file mode 100644 index 0000000..acf5b2e Binary files /dev/null and b/app/problems/grade_1/images_for_reference/sum-with-image-reference.png differ diff --git a/app/problems/grade_1/sum_with_image_reference.py b/app/problems/grade_1/sum_with_image_reference.py new file mode 100644 index 0000000..bdfe8ad --- /dev/null +++ b/app/problems/grade_1/sum_with_image_reference.py @@ -0,0 +1,92 @@ +import random + +from app.schemas.grade_1.sum_with_image_reference import ( + AddendGroup, + FigureGroup, + ImageOption, + PictureAsset, + SumWithImageReferenceProblem, +) + + +def sum_with_image_reference( + first_quantity: int, + second_quantity: int, + first_description: str, + second_description: str, + common_object: str, + first_picture: dict, + second_picture: dict, + option_count: int = 3, + min_option_quantity: int = 1, + max_option_quantity: int = 20, + seed: int | None = None, +) -> dict: + """Generate an addition question with image answer options.""" + if option_count < 2: + raise ValueError("option_count must be at least 2") + + total = first_quantity + second_quantity + option_values = list(range(max(2, min_option_quantity), max_option_quantity + 1)) + + if total not in option_values: + raise ValueError("option quantity range must include the correct total") + + distractor_values = [value for value in option_values if value != total] + if len(distractor_values) < option_count - 1: + raise ValueError("option quantity range must contain enough distractor values") + + rng = random.Random(seed) + selected_values = rng.sample(distractor_values, option_count - 1) + [total] + rng.shuffle(selected_values) + first_option_picture = PictureAsset.model_validate(first_picture) + second_option_picture = PictureAsset.model_validate(second_picture) + question = ( + f"Hay {first_quantity} {first_description} y " + f"{second_quantity} {second_description}, " + f"¿cuántas {common_object} hay en total?" + ) + options: list[ImageOption] = [] + + for index, quantity in enumerate(selected_values): + if quantity == total: + option_first_quantity = first_quantity + option_second_quantity = second_quantity + else: + option_first_quantity = rng.randint(1, quantity - 1) + option_second_quantity = quantity - option_first_quantity + + options.append( + ImageOption( + position=index + 1, + quantity=quantity, + first_group=FigureGroup( + quantity=option_first_quantity, + description=first_description, + picture=first_option_picture, + ), + second_group=FigureGroup( + quantity=option_second_quantity, + description=second_description, + picture=second_option_picture, + ), + is_correct=quantity == total, + ) + ) + + problem = SumWithImageReferenceProblem( + question=question, + first_group=AddendGroup( + quantity=first_quantity, + description=first_description, + ), + second_group=AddendGroup( + quantity=second_quantity, + description=second_description, + ), + common_object=common_object, + total=total, + options=options, + ) + + return problem.model_dump() diff --git a/app/routers/grade_1.py b/app/routers/grade_1.py index c3ec5c3..139817a 100644 --- a/app/routers/grade_1.py +++ b/app/routers/grade_1.py @@ -6,6 +6,7 @@ from app.problems.grade_1.compose_and_decompose_numbers import ( from app.problems.grade_1.join_pictures_with_quantity import ( join_pictures_with_quantity, ) +from app.problems.grade_1.sum_with_image_reference import sum_with_image_reference 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, @@ -15,6 +16,10 @@ from app.schemas.grade_1.join_pictures_with_quantity import ( JoinPicturesWithQuantityProblem, JoinPicturesWithQuantityRequest, ) +from app.schemas.grade_1.sum_with_image_reference import ( + SumWithImageReferenceProblem, + SumWithImageReferenceRequest, +) from app.schemas.grade_1.where_are_more_items import ( WhereAreMoreItemsProblem, WhereAreMoreItemsRequest, @@ -62,6 +67,31 @@ def create_join_pictures_with_quantity_problem( raise HTTPException(status_code=400, detail=str(exc)) from exc +@router.post( + "/sum_with_image_reference", + response_model=SumWithImageReferenceProblem, +) +def create_sum_with_image_reference_problem( + request: SumWithImageReferenceRequest, +) -> dict: + try: + return sum_with_image_reference( + first_quantity=request.first_quantity, + second_quantity=request.second_quantity, + first_description=request.first_description, + second_description=request.second_description, + common_object=request.common_object, + first_picture=request.first_picture.model_dump(), + second_picture=request.second_picture.model_dump(), + option_count=request.option_count, + min_option_quantity=request.min_option_quantity, + max_option_quantity=request.max_option_quantity, + seed=request.seed, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @router.post( "/where_are_more_items", response_model=WhereAreMoreItemsProblem, diff --git a/app/schemas/grade_1/sum_with_image_reference.py b/app/schemas/grade_1/sum_with_image_reference.py new file mode 100644 index 0000000..fba84b6 --- /dev/null +++ b/app/schemas/grade_1/sum_with_image_reference.py @@ -0,0 +1,51 @@ +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 AddendGroup(BaseModel): + quantity: PositiveInt + description: str = Field(min_length=1) + + +class FigureGroup(BaseModel): + quantity: PositiveInt + description: str = Field(min_length=1) + picture: PictureAsset + + +class ImageOption(BaseModel): + position: PositiveInt + quantity: PositiveInt + first_group: FigureGroup + second_group: FigureGroup + is_correct: bool + has_answer_box: bool = True + + +class SumWithImageReferenceProblem(BaseModel): + question: str + instructions: str = "Escoge la imagen en la próxima página." + first_group: AddendGroup + second_group: AddendGroup + common_object: str = Field(min_length=1) + total: PositiveInt + options: list[ImageOption] + + +class SumWithImageReferenceRequest(BaseModel): + first_quantity: PositiveInt + second_quantity: PositiveInt + first_description: str = Field(min_length=1) + second_description: str = Field(min_length=1) + common_object: str = Field(min_length=1) + first_picture: PictureAsset + second_picture: PictureAsset + option_count: PositiveInt = 3 + min_option_quantity: PositiveInt = 1 + max_option_quantity: PositiveInt = 20 + seed: int | None = None diff --git a/tests/test_sum_with_image_reference_endpoint.py b/tests/test_sum_with_image_reference_endpoint.py new file mode 100644 index 0000000..7f6f46c --- /dev/null +++ b/tests/test_sum_with_image_reference_endpoint.py @@ -0,0 +1,88 @@ +import unittest + +from fastapi.testclient import TestClient + +from app.main import create_app + + +class SumWithImageReferenceEndpointTest(unittest.TestCase): + def setUp(self) -> None: + self.client = TestClient(create_app()) + self.first_picture = { + "id": "red-flower", + "name": "Red flower", + "image_path": "/images/red-flower.png", + } + self.second_picture = { + "id": "white-flower", + "name": "White flower", + "image_path": "/images/white-flower.png", + } + + def test_creates_problem_with_one_correct_option(self) -> None: + response = self.client.post( + "/math/grade_1/sum_with_image_reference", + json={ + "first_quantity": 5, + "second_quantity": 4, + "first_description": "flores rojas", + "second_description": "flores blancas", + "common_object": "flores", + "first_picture": self.first_picture, + "second_picture": self.second_picture, + "option_count": 3, + "seed": 1, + }, + ) + + self.assertEqual(response.status_code, 200) + problem = response.json() + correct_options = [option for option in problem["options"] if option["is_correct"]] + + self.assertEqual( + problem["question"], + "Hay 5 flores rojas y 4 flores blancas, ¿cuántas flores hay en total?", + ) + self.assertEqual(problem["instructions"], "Escoge la imagen en la próxima página.") + self.assertEqual(problem["total"], 9) + self.assertEqual(len(problem["options"]), 3) + self.assertEqual(len(correct_options), 1) + self.assertEqual(correct_options[0]["quantity"], 9) + self.assertEqual(correct_options[0]["first_group"]["quantity"], 5) + self.assertEqual(correct_options[0]["second_group"]["quantity"], 4) + + for option in problem["options"]: + first_group = option["first_group"] + second_group = option["second_group"] + + self.assertEqual(option["quantity"], first_group["quantity"] + second_group["quantity"]) + self.assertEqual(first_group["description"], "flores rojas") + self.assertEqual(second_group["description"], "flores blancas") + self.assertEqual(first_group["picture"], self.first_picture) + self.assertEqual(second_group["picture"], self.second_picture) + + def test_returns_bad_request_when_total_is_outside_option_range(self) -> None: + response = self.client.post( + "/math/grade_1/sum_with_image_reference", + json={ + "first_quantity": 5, + "second_quantity": 4, + "first_description": "flores rojas", + "second_description": "flores blancas", + "common_object": "flores", + "first_picture": self.first_picture, + "second_picture": self.second_picture, + "min_option_quantity": 1, + "max_option_quantity": 8, + }, + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual( + response.json(), + {"detail": "option quantity range must include the correct total"}, + ) + + +if __name__ == "__main__": + unittest.main()