diff --git a/ENDPOINTS-EXAMPLE.md b/ENDPOINTS-EXAMPLE.md index e4bea12..2f84c6a 100644 --- a/ENDPOINTS-EXAMPLE.md +++ b/ENDPOINTS-EXAMPLE.md @@ -4,6 +4,21 @@ Test quick examples for each endpoint: ## Grade 1 +### Join corresponding sums + +```bash +curl -X POST "http://127.0.0.1:8000/math/grade_1/join_corresponding_sums" \ + -H "Content-Type: application/json" \ + -d '{ + "pair_count": 3, + "min_sum": 2, + "max_sum": 10, + "min_addend": 1, + "max_addend": 9, + "seed": 1 + }' +``` + ### Join pictures with quantity ```bash diff --git a/app/problems/grade_1/__init__.py b/app/problems/grade_1/__init__.py index fe0c1c8..a4d8adb 100644 --- a/app/problems/grade_1/__init__.py +++ b/app/problems/grade_1/__init__.py @@ -1,6 +1,7 @@ from app.problems.grade_1.compose_and_decompose_numbers import ( compose_and_decompose_numbers, ) +from app.problems.grade_1.join_corresponding_sums import join_corresponding_sums from app.problems.grade_1.join_pictures_with_quantity import ( join_pictures_with_quantity, ) @@ -9,6 +10,7 @@ from app.problems.grade_1.where_are_more_items import where_are_more_items __all__ = [ "compose_and_decompose_numbers", + "join_corresponding_sums", "join_pictures_with_quantity", "sum_with_image_reference", "where_are_more_items", diff --git a/app/problems/grade_1/images_for_reference/join-corresponding-sums.png b/app/problems/grade_1/images_for_reference/join-corresponding-sums.png new file mode 100644 index 0000000..3553fa6 Binary files /dev/null and b/app/problems/grade_1/images_for_reference/join-corresponding-sums.png differ diff --git a/app/problems/grade_1/images_for_reference/join_pictures_with_quantity.png b/app/problems/grade_1/images_for_reference/join-pictures-with-quantity.png similarity index 100% rename from app/problems/grade_1/images_for_reference/join_pictures_with_quantity.png rename to app/problems/grade_1/images_for_reference/join-pictures-with-quantity.png diff --git a/app/problems/grade_1/join_corresponding_sums.py b/app/problems/grade_1/join_corresponding_sums.py new file mode 100644 index 0000000..0d6ed69 --- /dev/null +++ b/app/problems/grade_1/join_corresponding_sums.py @@ -0,0 +1,96 @@ +import random + +from app.schemas.grade_1.join_corresponding_sums import ( + AdditionExpression, + JoinCorrespondingSumsProblem, + SumConnection, +) + + +def join_corresponding_sums( + pair_count: int = 3, + min_sum: int = 2, + max_sum: int = 10, + min_addend: int = 1, + max_addend: int = 9, + seed: int | None = None, +) -> dict: + """Generate addition expressions that students connect by equal sums.""" + if pair_count < 1: + raise ValueError("pair_count must be at least 1") + if min_sum > max_sum: + raise ValueError("min_sum must be less than or equal to max_sum") + if min_addend > max_addend: + raise ValueError("min_addend must be less than or equal to max_addend") + + expressions_by_total: dict[int, list[tuple[int, int]]] = {} + for total in range(min_sum, max_sum + 1): + expressions = [] + for first_addend in range(min_addend, max_addend + 1): + second_addend = total - first_addend + if min_addend <= second_addend <= max_addend: + expressions.append((first_addend, second_addend)) + + if len(expressions) >= 2: + expressions_by_total[total] = expressions + + available_totals = list(expressions_by_total) + if len(available_totals) < pair_count: + raise ValueError("sum and addend ranges must contain enough matchable sums") + + rng = random.Random(seed) + selected_totals = rng.sample(available_totals, pair_count) + left_items: list[dict] = [] + right_items: list[dict] = [] + + for index, total in enumerate(selected_totals, start=1): + left_expression, right_expression = rng.sample(expressions_by_total[total], 2) + match_id = f"sum-{total}" + left_items.append( + { + "first_addend": left_expression[0], + "second_addend": left_expression[1], + "total": total, + "match_id": match_id, + } + ) + right_items.append( + { + "first_addend": right_expression[0], + "second_addend": right_expression[1], + "total": total, + "match_id": match_id, + } + ) + + rng.shuffle(left_items) + rng.shuffle(right_items) + + left_expressions = [ + AdditionExpression(position=index + 1, **item) + for index, item in enumerate(left_items) + ] + right_expressions = [ + AdditionExpression(position=index + 1, **item) + for index, item in enumerate(right_items) + ] + right_positions_by_match_id = { + expression.match_id: expression.position for expression in right_expressions + } + answer_key = [ + SumConnection( + match_id=expression.match_id, + total=expression.total, + left_position=expression.position, + right_position=right_positions_by_match_id[expression.match_id], + ) + for expression in left_expressions + ] + + problem = JoinCorrespondingSumsProblem( + left_expressions=left_expressions, + right_expressions=right_expressions, + answer_key=answer_key, + ) + + return problem.model_dump() diff --git a/app/routers/grade_1.py b/app/routers/grade_1.py index 34ad5ae..6ef1ab2 100644 --- a/app/routers/grade_1.py +++ b/app/routers/grade_1.py @@ -2,6 +2,7 @@ from fastapi import APIRouter, HTTPException from app.problems.grade_1 import ( compose_and_decompose_numbers, + join_corresponding_sums, join_pictures_with_quantity, sum_with_image_reference, where_are_more_items, @@ -9,6 +10,8 @@ from app.problems.grade_1 import ( from app.schemas.grade_1 import ( ComposeAndDecomposeNumbersProblem, ComposeAndDecomposeNumbersRequest, + JoinCorrespondingSumsProblem, + JoinCorrespondingSumsRequest, JoinPicturesWithQuantityProblem, JoinPicturesWithQuantityRequest, SumWithImageReferenceProblem, @@ -20,6 +23,26 @@ from app.schemas.grade_1 import ( router = APIRouter(prefix="/grade_1", tags=["Grade 1"]) +@router.post( + "/join_corresponding_sums", + response_model=JoinCorrespondingSumsProblem, +) +def create_join_corresponding_sums_problem( + request: JoinCorrespondingSumsRequest, +) -> dict: + try: + return join_corresponding_sums( + pair_count=request.pair_count, + min_sum=request.min_sum, + max_sum=request.max_sum, + min_addend=request.min_addend, + max_addend=request.max_addend, + seed=request.seed, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @router.post( "/compose_and_decompose_numbers", response_model=ComposeAndDecomposeNumbersProblem, diff --git a/app/schemas/grade_1/__init__.py b/app/schemas/grade_1/__init__.py index d6b89cd..1cfba8a 100644 --- a/app/schemas/grade_1/__init__.py +++ b/app/schemas/grade_1/__init__.py @@ -2,6 +2,10 @@ from app.schemas.grade_1.compose_and_decompose_numbers import ( ComposeAndDecomposeNumbersProblem, ComposeAndDecomposeNumbersRequest, ) +from app.schemas.grade_1.join_corresponding_sums import ( + JoinCorrespondingSumsProblem, + JoinCorrespondingSumsRequest, +) from app.schemas.grade_1.join_pictures_with_quantity import ( JoinPicturesWithQuantityProblem, JoinPicturesWithQuantityRequest, @@ -18,6 +22,8 @@ from app.schemas.grade_1.where_are_more_items import ( __all__ = [ "ComposeAndDecomposeNumbersProblem", "ComposeAndDecomposeNumbersRequest", + "JoinCorrespondingSumsProblem", + "JoinCorrespondingSumsRequest", "JoinPicturesWithQuantityProblem", "JoinPicturesWithQuantityRequest", "SumWithImageReferenceProblem", diff --git a/app/schemas/grade_1/join_corresponding_sums.py b/app/schemas/grade_1/join_corresponding_sums.py new file mode 100644 index 0000000..9d4d07d --- /dev/null +++ b/app/schemas/grade_1/join_corresponding_sums.py @@ -0,0 +1,32 @@ +from pydantic import BaseModel, Field, PositiveInt + + +class AdditionExpression(BaseModel): + position: PositiveInt + first_addend: PositiveInt + second_addend: PositiveInt + total: PositiveInt + match_id: str = Field(min_length=1) + + +class SumConnection(BaseModel): + match_id: str = Field(min_length=1) + total: PositiveInt + left_position: PositiveInt + right_position: PositiveInt + + +class JoinCorrespondingSumsProblem(BaseModel): + instructions: str = "Conecta." + left_expressions: list[AdditionExpression] + right_expressions: list[AdditionExpression] + answer_key: list[SumConnection] + + +class JoinCorrespondingSumsRequest(BaseModel): + pair_count: PositiveInt = 3 + min_sum: PositiveInt = 2 + max_sum: PositiveInt = 10 + min_addend: PositiveInt = 1 + max_addend: PositiveInt = 9 + seed: int | None = None diff --git a/tests/test_join_corresponding_sums_endpoint.py b/tests/test_join_corresponding_sums_endpoint.py new file mode 100644 index 0000000..f194504 --- /dev/null +++ b/tests/test_join_corresponding_sums_endpoint.py @@ -0,0 +1,64 @@ +import unittest + +from fastapi.testclient import TestClient + +from app.main import create_app + + +class JoinCorrespondingSumsEndpointTest(unittest.TestCase): + def setUp(self) -> None: + self.client = TestClient(create_app()) + + def test_creates_problem_with_matching_sums(self) -> None: + response = self.client.post( + "/math/grade_1/join_corresponding_sums", + json={"pair_count": 3, "seed": 1}, + ) + + self.assertEqual(response.status_code, 200) + problem = response.json() + + self.assertEqual(problem["instructions"], "Conecta.") + self.assertEqual(len(problem["left_expressions"]), 3) + self.assertEqual(len(problem["right_expressions"]), 3) + self.assertEqual(len(problem["answer_key"]), 3) + + left_by_position = { + expression["position"]: expression + for expression in problem["left_expressions"] + } + right_by_position = { + expression["position"]: expression + for expression in problem["right_expressions"] + } + + for expression in problem["left_expressions"] + problem["right_expressions"]: + self.assertEqual( + expression["first_addend"] + expression["second_addend"], + expression["total"], + ) + + for connection in problem["answer_key"]: + left_expression = left_by_position[connection["left_position"]] + right_expression = right_by_position[connection["right_position"]] + + self.assertEqual(left_expression["total"], right_expression["total"]) + self.assertEqual(left_expression["total"], connection["total"]) + self.assertEqual(left_expression["match_id"], right_expression["match_id"]) + self.assertEqual(left_expression["match_id"], connection["match_id"]) + + def test_returns_bad_request_for_impossible_ranges(self) -> None: + response = self.client.post( + "/math/grade_1/join_corresponding_sums", + json={"pair_count": 3, "min_sum": 2, "max_sum": 2}, + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual( + response.json(), + {"detail": "sum and addend ranges must contain enough matchable sums"}, + ) + + +if __name__ == "__main__": + unittest.main()