feat: add join-corresponding-sums
This commit is contained in:
@@ -4,6 +4,21 @@ Test quick examples for each endpoint:
|
|||||||
|
|
||||||
## Grade 1
|
## 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
|
### Join pictures with quantity
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from app.problems.grade_1.compose_and_decompose_numbers import (
|
from app.problems.grade_1.compose_and_decompose_numbers import (
|
||||||
compose_and_decompose_numbers,
|
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 (
|
from app.problems.grade_1.join_pictures_with_quantity import (
|
||||||
join_pictures_with_quantity,
|
join_pictures_with_quantity,
|
||||||
)
|
)
|
||||||
@@ -9,6 +10,7 @@ from app.problems.grade_1.where_are_more_items import where_are_more_items
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"compose_and_decompose_numbers",
|
"compose_and_decompose_numbers",
|
||||||
|
"join_corresponding_sums",
|
||||||
"join_pictures_with_quantity",
|
"join_pictures_with_quantity",
|
||||||
"sum_with_image_reference",
|
"sum_with_image_reference",
|
||||||
"where_are_more_items",
|
"where_are_more_items",
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 411 KiB After Width: | Height: | Size: 411 KiB |
96
app/problems/grade_1/join_corresponding_sums.py
Normal file
96
app/problems/grade_1/join_corresponding_sums.py
Normal file
@@ -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()
|
||||||
@@ -2,6 +2,7 @@ from fastapi import APIRouter, HTTPException
|
|||||||
|
|
||||||
from app.problems.grade_1 import (
|
from app.problems.grade_1 import (
|
||||||
compose_and_decompose_numbers,
|
compose_and_decompose_numbers,
|
||||||
|
join_corresponding_sums,
|
||||||
join_pictures_with_quantity,
|
join_pictures_with_quantity,
|
||||||
sum_with_image_reference,
|
sum_with_image_reference,
|
||||||
where_are_more_items,
|
where_are_more_items,
|
||||||
@@ -9,6 +10,8 @@ from app.problems.grade_1 import (
|
|||||||
from app.schemas.grade_1 import (
|
from app.schemas.grade_1 import (
|
||||||
ComposeAndDecomposeNumbersProblem,
|
ComposeAndDecomposeNumbersProblem,
|
||||||
ComposeAndDecomposeNumbersRequest,
|
ComposeAndDecomposeNumbersRequest,
|
||||||
|
JoinCorrespondingSumsProblem,
|
||||||
|
JoinCorrespondingSumsRequest,
|
||||||
JoinPicturesWithQuantityProblem,
|
JoinPicturesWithQuantityProblem,
|
||||||
JoinPicturesWithQuantityRequest,
|
JoinPicturesWithQuantityRequest,
|
||||||
SumWithImageReferenceProblem,
|
SumWithImageReferenceProblem,
|
||||||
@@ -20,6 +23,26 @@ from app.schemas.grade_1 import (
|
|||||||
router = APIRouter(prefix="/grade_1", tags=["Grade 1"])
|
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(
|
@router.post(
|
||||||
"/compose_and_decompose_numbers",
|
"/compose_and_decompose_numbers",
|
||||||
response_model=ComposeAndDecomposeNumbersProblem,
|
response_model=ComposeAndDecomposeNumbersProblem,
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ from app.schemas.grade_1.compose_and_decompose_numbers import (
|
|||||||
ComposeAndDecomposeNumbersProblem,
|
ComposeAndDecomposeNumbersProblem,
|
||||||
ComposeAndDecomposeNumbersRequest,
|
ComposeAndDecomposeNumbersRequest,
|
||||||
)
|
)
|
||||||
|
from app.schemas.grade_1.join_corresponding_sums import (
|
||||||
|
JoinCorrespondingSumsProblem,
|
||||||
|
JoinCorrespondingSumsRequest,
|
||||||
|
)
|
||||||
from app.schemas.grade_1.join_pictures_with_quantity import (
|
from app.schemas.grade_1.join_pictures_with_quantity import (
|
||||||
JoinPicturesWithQuantityProblem,
|
JoinPicturesWithQuantityProblem,
|
||||||
JoinPicturesWithQuantityRequest,
|
JoinPicturesWithQuantityRequest,
|
||||||
@@ -18,6 +22,8 @@ from app.schemas.grade_1.where_are_more_items import (
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
"ComposeAndDecomposeNumbersProblem",
|
"ComposeAndDecomposeNumbersProblem",
|
||||||
"ComposeAndDecomposeNumbersRequest",
|
"ComposeAndDecomposeNumbersRequest",
|
||||||
|
"JoinCorrespondingSumsProblem",
|
||||||
|
"JoinCorrespondingSumsRequest",
|
||||||
"JoinPicturesWithQuantityProblem",
|
"JoinPicturesWithQuantityProblem",
|
||||||
"JoinPicturesWithQuantityRequest",
|
"JoinPicturesWithQuantityRequest",
|
||||||
"SumWithImageReferenceProblem",
|
"SumWithImageReferenceProblem",
|
||||||
|
|||||||
32
app/schemas/grade_1/join_corresponding_sums.py
Normal file
32
app/schemas/grade_1/join_corresponding_sums.py
Normal file
@@ -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
|
||||||
64
tests/test_join_corresponding_sums_endpoint.py
Normal file
64
tests/test_join_corresponding_sums_endpoint.py
Normal file
@@ -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()
|
||||||
Reference in New Issue
Block a user