Boost Azure Demo
This commit is contained in:
634
Backend/internal/questiongen/service.go
Normal file
634
Backend/internal/questiongen/service.go
Normal file
@@ -0,0 +1,634 @@
|
||||
package questiongen
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"boostai-backend/internal/sqlc"
|
||||
)
|
||||
|
||||
type Service struct{}
|
||||
|
||||
type GenerateParams struct {
|
||||
Topic sqlc.QuestionTopic
|
||||
Difficulty sqlc.QuestionDifficulty
|
||||
Count int
|
||||
Seed int64
|
||||
}
|
||||
|
||||
type GeneratedQuestion struct {
|
||||
Title string
|
||||
Prompt string
|
||||
CorrectAnswer string
|
||||
WorkedSolution []string
|
||||
Tags []string
|
||||
}
|
||||
|
||||
func NewService() *Service {
|
||||
return &Service{}
|
||||
}
|
||||
|
||||
func (s *Service) Generate(params GenerateParams) ([]GeneratedQuestion, int64, error) {
|
||||
count := params.Count
|
||||
if count <= 0 {
|
||||
count = 1
|
||||
}
|
||||
|
||||
seed := params.Seed
|
||||
if seed == 0 {
|
||||
seed = time.Now().UnixNano()
|
||||
}
|
||||
|
||||
rng := rand.New(rand.NewSource(seed))
|
||||
items := make([]GeneratedQuestion, 0, count)
|
||||
for i := 0; i < count; i++ {
|
||||
question, err := s.generateOne(rng, params.Topic, params.Difficulty)
|
||||
if err != nil {
|
||||
return nil, seed, err
|
||||
}
|
||||
items = append(items, question)
|
||||
}
|
||||
|
||||
return items, seed, nil
|
||||
}
|
||||
|
||||
func (s *Service) generateOne(rng *rand.Rand, topic sqlc.QuestionTopic, difficulty sqlc.QuestionDifficulty) (GeneratedQuestion, error) {
|
||||
switch topic {
|
||||
case sqlc.QuestionTopicPlaceValue:
|
||||
return generatePlaceValueQuestion(rng, difficulty), nil
|
||||
case sqlc.QuestionTopicArithmetic:
|
||||
return generateArithmeticQuestion(rng, difficulty), nil
|
||||
case sqlc.QuestionTopicNegativeNumbers:
|
||||
return generateNegativeNumbersQuestion(rng, difficulty), nil
|
||||
case sqlc.QuestionTopicBidmas:
|
||||
return generateBidmasQuestion(rng, difficulty), nil
|
||||
case sqlc.QuestionTopicFractions:
|
||||
return generateFractionsQuestion(rng, difficulty), nil
|
||||
case sqlc.QuestionTopicAlgebra:
|
||||
return generateAlgebraQuestion(rng, difficulty), nil
|
||||
case sqlc.QuestionTopicGeometry:
|
||||
return generateGeometryQuestion(rng, difficulty), nil
|
||||
case sqlc.QuestionTopicData:
|
||||
return generateDataQuestion(rng, difficulty), nil
|
||||
default:
|
||||
return GeneratedQuestion{}, fmt.Errorf("unsupported topic: %s", topic)
|
||||
}
|
||||
}
|
||||
|
||||
// Future word_problem work should not just bolt a `word_problem` tag onto an already-built
|
||||
// abstract question. Each topic generator should eventually expose dedicated word-problem
|
||||
// template families so the RNG chooses both the maths structure and a fitting real-world context
|
||||
// together. That will keep prompts, answers, and worked steps consistent instead of doing a late
|
||||
// text rewrite after the numbers are chosen.
|
||||
func buildGeneratedTags(topic sqlc.QuestionTopic, difficulty sqlc.QuestionDifficulty, extra ...string) []string {
|
||||
tags := []string{string(topic), string(difficulty), "rng_generated"}
|
||||
tags = append(tags, extra...)
|
||||
|
||||
unique := make(map[string]struct{}, len(tags))
|
||||
normalized := make([]string, 0, len(tags))
|
||||
for _, tag := range tags {
|
||||
value := strings.ToLower(strings.TrimSpace(tag))
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := unique[value]; exists {
|
||||
continue
|
||||
}
|
||||
unique[value] = struct{}{}
|
||||
normalized = append(normalized, value)
|
||||
}
|
||||
sort.Strings(normalized)
|
||||
return normalized
|
||||
}
|
||||
|
||||
func generatePlaceValueQuestion(rng *rand.Rand, difficulty sqlc.QuestionDifficulty) GeneratedQuestion {
|
||||
var digits, targetIndex int
|
||||
switch difficulty {
|
||||
case sqlc.QuestionDifficultyEasy:
|
||||
digits = 2
|
||||
targetIndex = randomInt(rng, 0, 1)
|
||||
case sqlc.QuestionDifficultyMedium:
|
||||
digits = 3
|
||||
targetIndex = randomInt(rng, 0, 2)
|
||||
default:
|
||||
digits = randomInt(rng, 4, 5)
|
||||
targetIndex = randomInt(rng, 1, digits-1)
|
||||
}
|
||||
|
||||
numberDigits := make([]int, digits)
|
||||
numberDigits[0] = randomInt(rng, 1, 9)
|
||||
for i := 1; i < digits; i++ {
|
||||
numberDigits[i] = randomInt(rng, 0, 9)
|
||||
}
|
||||
number := digitsToInt(numberDigits)
|
||||
digit := numberDigits[targetIndex]
|
||||
placePower := digits - targetIndex - 1
|
||||
placeValue := digit
|
||||
for i := 0; i < placePower; i++ {
|
||||
placeValue *= 10
|
||||
}
|
||||
|
||||
placeName := placeNameFromPower(placePower)
|
||||
prompt := fmt.Sprintf("What is the value of the digit %d in %d?", digit, number)
|
||||
return GeneratedQuestion{
|
||||
Title: fmt.Sprintf("%s Place Value", strings.Title(string(difficulty))),
|
||||
Prompt: prompt,
|
||||
CorrectAnswer: fmt.Sprintf("%d", placeValue),
|
||||
WorkedSolution: []string{
|
||||
fmt.Sprintf("In %d, the digit %d is in the %s place.", number, digit, placeName),
|
||||
fmt.Sprintf("So its value is %d.", placeValue),
|
||||
},
|
||||
Tags: buildGeneratedTags(sqlc.QuestionTopicPlaceValue, difficulty, placeName),
|
||||
}
|
||||
}
|
||||
|
||||
func generateArithmeticQuestion(rng *rand.Rand, difficulty sqlc.QuestionDifficulty) GeneratedQuestion {
|
||||
if difficulty == sqlc.QuestionDifficultyEasy {
|
||||
a := randomInt(rng, 1, 9)
|
||||
b := randomInt(rng, 1, 9)
|
||||
if rng.Intn(2) == 0 {
|
||||
return GeneratedQuestion{
|
||||
Title: "Easy Addition",
|
||||
Prompt: fmt.Sprintf("Calculate %d + %d.", a, b),
|
||||
CorrectAnswer: fmt.Sprintf("%d", a+b),
|
||||
WorkedSolution: []string{
|
||||
fmt.Sprintf("Add the ones: %d + %d = %d.", a, b, a+b),
|
||||
},
|
||||
Tags: buildGeneratedTags(sqlc.QuestionTopicArithmetic, difficulty, "addition", "single_digit"),
|
||||
}
|
||||
}
|
||||
|
||||
if a < b {
|
||||
a, b = b, a
|
||||
}
|
||||
return GeneratedQuestion{
|
||||
Title: "Easy Subtraction",
|
||||
Prompt: fmt.Sprintf("Calculate %d - %d.", a, b),
|
||||
CorrectAnswer: fmt.Sprintf("%d", a-b),
|
||||
WorkedSolution: []string{
|
||||
fmt.Sprintf("Subtract %d from %d.", b, a),
|
||||
fmt.Sprintf("%d - %d = %d.", a, b, a-b),
|
||||
},
|
||||
Tags: buildGeneratedTags(sqlc.QuestionTopicArithmetic, difficulty, "subtraction", "single_digit"),
|
||||
}
|
||||
}
|
||||
|
||||
if difficulty == sqlc.QuestionDifficultyMedium {
|
||||
if rng.Intn(2) == 0 {
|
||||
a := randomInt(rng, 10, 99)
|
||||
b := randomInt(rng, 10, 99)
|
||||
return GeneratedQuestion{
|
||||
Title: "Medium Addition",
|
||||
Prompt: fmt.Sprintf("Work out %d + %d.", a, b),
|
||||
CorrectAnswer: fmt.Sprintf("%d", a+b),
|
||||
WorkedSolution: []string{
|
||||
fmt.Sprintf("Add the numbers together: %d + %d = %d.", a, b, a+b),
|
||||
},
|
||||
Tags: buildGeneratedTags(sqlc.QuestionTopicArithmetic, difficulty, "addition", "two_digit"),
|
||||
}
|
||||
}
|
||||
|
||||
a := randomInt(rng, 2, 12)
|
||||
b := randomInt(rng, 2, 12)
|
||||
return GeneratedQuestion{
|
||||
Title: "Medium Multiplication",
|
||||
Prompt: fmt.Sprintf("Calculate %d × %d.", a, b),
|
||||
CorrectAnswer: fmt.Sprintf("%d", a*b),
|
||||
WorkedSolution: []string{
|
||||
fmt.Sprintf("Use multiplication facts: %d × %d = %d.", a, b, a*b),
|
||||
},
|
||||
Tags: buildGeneratedTags(sqlc.QuestionTopicArithmetic, difficulty, "multiplication", "times_tables"),
|
||||
}
|
||||
}
|
||||
|
||||
if rng.Intn(2) == 0 {
|
||||
divisor := randomInt(rng, 3, 12)
|
||||
quotient := randomInt(rng, 4, 12)
|
||||
dividend := divisor * quotient
|
||||
return GeneratedQuestion{
|
||||
Title: "Hard Division",
|
||||
Prompt: fmt.Sprintf("Calculate %d ÷ %d.", dividend, divisor),
|
||||
CorrectAnswer: fmt.Sprintf("%d", quotient),
|
||||
WorkedSolution: []string{
|
||||
fmt.Sprintf("Use the inverse of multiplication: %d × %d = %d.", divisor, quotient, dividend),
|
||||
fmt.Sprintf("So %d ÷ %d = %d.", dividend, divisor, quotient),
|
||||
},
|
||||
Tags: buildGeneratedTags(sqlc.QuestionTopicArithmetic, difficulty, "division", "inverse_operations"),
|
||||
}
|
||||
}
|
||||
|
||||
a := randomInt(rng, 20, 99)
|
||||
b := randomInt(rng, 11, 49)
|
||||
return GeneratedQuestion{
|
||||
Title: "Hard Subtraction",
|
||||
Prompt: fmt.Sprintf("Work out %d - %d.", a, b),
|
||||
CorrectAnswer: fmt.Sprintf("%d", a-b),
|
||||
WorkedSolution: []string{
|
||||
fmt.Sprintf("Subtract %d from %d carefully, using column subtraction if needed.", b, a),
|
||||
fmt.Sprintf("%d - %d = %d.", a, b, a-b),
|
||||
},
|
||||
Tags: buildGeneratedTags(sqlc.QuestionTopicArithmetic, difficulty, "subtraction", "column_method"),
|
||||
}
|
||||
}
|
||||
|
||||
func generateNegativeNumbersQuestion(rng *rand.Rand, difficulty sqlc.QuestionDifficulty) GeneratedQuestion {
|
||||
if difficulty == sqlc.QuestionDifficultyEasy {
|
||||
a := randomInt(rng, -9, 9)
|
||||
b := randomInt(rng, -9, 9)
|
||||
result := a + b
|
||||
return GeneratedQuestion{
|
||||
Title: "Easy Negative Numbers",
|
||||
Prompt: fmt.Sprintf("Calculate %d + %d.", a, b),
|
||||
CorrectAnswer: fmt.Sprintf("%d", result),
|
||||
WorkedSolution: []string{
|
||||
fmt.Sprintf("Start at %d on the number line.", a),
|
||||
fmt.Sprintf("Move %d steps to get %d.", b, result),
|
||||
},
|
||||
Tags: buildGeneratedTags(sqlc.QuestionTopicNegativeNumbers, difficulty, "addition"),
|
||||
}
|
||||
}
|
||||
|
||||
if difficulty == sqlc.QuestionDifficultyMedium {
|
||||
a := randomInt(rng, -20, 20)
|
||||
b := randomInt(rng, -20, 20)
|
||||
result := a - b
|
||||
return GeneratedQuestion{
|
||||
Title: "Medium Negative Numbers",
|
||||
Prompt: fmt.Sprintf("Calculate %d - (%d).", a, b),
|
||||
CorrectAnswer: fmt.Sprintf("%d", result),
|
||||
WorkedSolution: []string{
|
||||
fmt.Sprintf("Subtracting %d is the same as adding %d.", b, -b),
|
||||
fmt.Sprintf("So %d - (%d) = %d + %d = %d.", a, b, a, -b, result),
|
||||
},
|
||||
Tags: buildGeneratedTags(sqlc.QuestionTopicNegativeNumbers, difficulty, "subtraction"),
|
||||
}
|
||||
}
|
||||
|
||||
a := randomInt(rng, -30, 30)
|
||||
b := randomInt(rng, -30, 30)
|
||||
c := randomInt(rng, -15, 15)
|
||||
result := a - b + c
|
||||
prompt := fmt.Sprintf("Calculate %d - (%d) + %d.", a, b, c)
|
||||
return GeneratedQuestion{
|
||||
Title: "Hard Negative Numbers",
|
||||
Prompt: prompt,
|
||||
CorrectAnswer: fmt.Sprintf("%d", result),
|
||||
WorkedSolution: []string{
|
||||
fmt.Sprintf("First change subtraction of a negative: %d - (%d) = %d + %d.", a, b, a, -b),
|
||||
fmt.Sprintf("Then add %d to get %d.", c, result),
|
||||
},
|
||||
Tags: buildGeneratedTags(sqlc.QuestionTopicNegativeNumbers, difficulty, "multi_step"),
|
||||
}
|
||||
}
|
||||
|
||||
func generateBidmasQuestion(rng *rand.Rand, difficulty sqlc.QuestionDifficulty) GeneratedQuestion {
|
||||
if difficulty == sqlc.QuestionDifficultyEasy {
|
||||
a := randomInt(rng, 1, 20)
|
||||
b := randomInt(rng, 2, 9)
|
||||
c := randomInt(rng, 2, 9)
|
||||
result := a + b*c
|
||||
return GeneratedQuestion{
|
||||
Title: "Easy BIDMAS",
|
||||
Prompt: fmt.Sprintf("Work out %d + %d × %d.", a, b, c),
|
||||
CorrectAnswer: fmt.Sprintf("%d", result),
|
||||
WorkedSolution: []string{
|
||||
fmt.Sprintf("Do multiplication first: %d × %d = %d.", b, c, b*c),
|
||||
fmt.Sprintf("Then add %d + %d = %d.", a, b*c, result),
|
||||
},
|
||||
Tags: buildGeneratedTags(sqlc.QuestionTopicBidmas, difficulty, "order_of_operations"),
|
||||
}
|
||||
}
|
||||
|
||||
if difficulty == sqlc.QuestionDifficultyMedium {
|
||||
a := randomInt(rng, 2, 12)
|
||||
b := randomInt(rng, 3, 12)
|
||||
c := randomInt(rng, 2, 10)
|
||||
result := (a + b) * c
|
||||
return GeneratedQuestion{
|
||||
Title: "Medium BIDMAS",
|
||||
Prompt: fmt.Sprintf("Work out (%d + %d) × %d.", a, b, c),
|
||||
CorrectAnswer: fmt.Sprintf("%d", result),
|
||||
WorkedSolution: []string{
|
||||
fmt.Sprintf("Work inside brackets first: %d + %d = %d.", a, b, a+b),
|
||||
fmt.Sprintf("Then multiply %d × %d = %d.", a+b, c, result),
|
||||
},
|
||||
Tags: buildGeneratedTags(sqlc.QuestionTopicBidmas, difficulty, "brackets"),
|
||||
}
|
||||
}
|
||||
|
||||
a := randomInt(rng, 2, 12)
|
||||
b := randomInt(rng, 2, 6)
|
||||
c := randomInt(rng, 2, 12)
|
||||
d := randomInt(rng, 2, 5)
|
||||
left := a * b
|
||||
right := c * d
|
||||
result := left + right
|
||||
return GeneratedQuestion{
|
||||
Title: "Hard BIDMAS",
|
||||
Prompt: fmt.Sprintf("Work out %d × %d + %d × %d.", a, b, c, d),
|
||||
CorrectAnswer: fmt.Sprintf("%d", result),
|
||||
WorkedSolution: []string{
|
||||
fmt.Sprintf("Complete each multiplication first: %d × %d = %d and %d × %d = %d.", a, b, left, c, d, right),
|
||||
fmt.Sprintf("Then add %d + %d = %d.", left, right, result),
|
||||
},
|
||||
Tags: buildGeneratedTags(sqlc.QuestionTopicBidmas, difficulty, "multiple_operations"),
|
||||
}
|
||||
}
|
||||
|
||||
func generateFractionsQuestion(rng *rand.Rand, difficulty sqlc.QuestionDifficulty) GeneratedQuestion {
|
||||
if difficulty == sqlc.QuestionDifficultyEasy {
|
||||
denominator := randomInt(rng, 2, 9)
|
||||
numerator := randomInt(rng, 1, denominator-1)
|
||||
maxMultiplier := 9 / denominator
|
||||
if maxMultiplier < 1 {
|
||||
maxMultiplier = 1
|
||||
}
|
||||
multiplier := randomInt(rng, 1, maxMultiplier)
|
||||
prompt := fmt.Sprintf("What is %d/%d of %d?", numerator, denominator, denominator*multiplier)
|
||||
answer := numerator * multiplier
|
||||
return GeneratedQuestion{
|
||||
Title: "Easy Fractions",
|
||||
Prompt: prompt,
|
||||
CorrectAnswer: fmt.Sprintf("%d", answer),
|
||||
WorkedSolution: []string{
|
||||
fmt.Sprintf("Find one part first: %d ÷ %d = %d.", denominator*multiplier, denominator, multiplier),
|
||||
fmt.Sprintf("Then take %d parts: %d × %d = %d.", numerator, multiplier, numerator, answer),
|
||||
},
|
||||
Tags: buildGeneratedTags(sqlc.QuestionTopicFractions, difficulty, "single_digit", "fraction_of_amount"),
|
||||
}
|
||||
}
|
||||
|
||||
if difficulty == sqlc.QuestionDifficultyMedium {
|
||||
denominator := randomInt(rng, 3, 10)
|
||||
a := randomInt(rng, 1, denominator-1)
|
||||
b := randomInt(rng, 1, denominator-1)
|
||||
resultN, resultD := simplifyFraction(a+b, denominator)
|
||||
return GeneratedQuestion{
|
||||
Title: "Medium Fractions",
|
||||
Prompt: fmt.Sprintf("Work out %d/%d + %d/%d. Give your answer in simplest form.", a, denominator, b, denominator),
|
||||
CorrectAnswer: formatFraction(resultN, resultD),
|
||||
WorkedSolution: []string{
|
||||
fmt.Sprintf("The denominators are the same, so add the numerators: %d + %d = %d.", a, b, a+b),
|
||||
fmt.Sprintf("This gives %d/%d.", a+b, denominator),
|
||||
fmt.Sprintf("Simplify to %s.", formatFraction(resultN, resultD)),
|
||||
},
|
||||
Tags: buildGeneratedTags(sqlc.QuestionTopicFractions, difficulty, "addition", "simplify"),
|
||||
}
|
||||
}
|
||||
|
||||
aN := randomInt(rng, 1, 8)
|
||||
aD := randomInt(rng, 2, 9)
|
||||
bN := randomInt(rng, 1, 8)
|
||||
bD := randomInt(rng, 2, 9)
|
||||
resultN, resultD := simplifyFraction(aN*bN, aD*bD)
|
||||
return GeneratedQuestion{
|
||||
Title: "Hard Fractions",
|
||||
Prompt: fmt.Sprintf("Work out %d/%d × %d/%d. Give your answer in simplest form.", aN, aD, bN, bD),
|
||||
CorrectAnswer: formatFraction(resultN, resultD),
|
||||
WorkedSolution: []string{
|
||||
fmt.Sprintf("Multiply the numerators: %d × %d = %d.", aN, bN, aN*bN),
|
||||
fmt.Sprintf("Multiply the denominators: %d × %d = %d.", aD, bD, aD*bD),
|
||||
fmt.Sprintf("This gives %d/%d, which simplifies to %s.", aN*bN, aD*bD, formatFraction(resultN, resultD)),
|
||||
},
|
||||
Tags: buildGeneratedTags(sqlc.QuestionTopicFractions, difficulty, "multiplication", "simplify"),
|
||||
}
|
||||
}
|
||||
|
||||
func generateAlgebraQuestion(rng *rand.Rand, difficulty sqlc.QuestionDifficulty) GeneratedQuestion {
|
||||
if difficulty == sqlc.QuestionDifficultyEasy {
|
||||
x := randomInt(rng, 1, 12)
|
||||
a := randomInt(rng, 1, 12)
|
||||
b := x + a
|
||||
return GeneratedQuestion{
|
||||
Title: "Easy Algebra",
|
||||
Prompt: fmt.Sprintf("Solve x + %d = %d.", a, b),
|
||||
CorrectAnswer: fmt.Sprintf("x = %d", x),
|
||||
WorkedSolution: []string{
|
||||
fmt.Sprintf("Subtract %d from both sides.", a),
|
||||
fmt.Sprintf("x = %d - %d = %d.", b, a, x),
|
||||
},
|
||||
Tags: buildGeneratedTags(sqlc.QuestionTopicAlgebra, difficulty, "one_step"),
|
||||
}
|
||||
}
|
||||
|
||||
if difficulty == sqlc.QuestionDifficultyMedium {
|
||||
x := randomInt(rng, 2, 12)
|
||||
a := randomInt(rng, 2, 9)
|
||||
b := randomInt(rng, 1, 12)
|
||||
c := a*x + b
|
||||
return GeneratedQuestion{
|
||||
Title: "Medium Algebra",
|
||||
Prompt: fmt.Sprintf("Solve %dx + %d = %d.", a, b, c),
|
||||
CorrectAnswer: fmt.Sprintf("x = %d", x),
|
||||
WorkedSolution: []string{
|
||||
fmt.Sprintf("Subtract %d from both sides to get %dx = %d.", b, a, c-b),
|
||||
fmt.Sprintf("Divide both sides by %d, so x = %d.", a, x),
|
||||
},
|
||||
Tags: buildGeneratedTags(sqlc.QuestionTopicAlgebra, difficulty, "two_step"),
|
||||
}
|
||||
}
|
||||
|
||||
x := randomInt(rng, -6, 12)
|
||||
a := randomInt(rng, 2, 6)
|
||||
b := randomInt(rng, 1, 8)
|
||||
c := randomInt(rng, 2, 6)
|
||||
d := a*(x+b) - c
|
||||
return GeneratedQuestion{
|
||||
Title: "Hard Algebra",
|
||||
Prompt: fmt.Sprintf("Solve %d(x + %d) - %d = %d.", a, b, c, d),
|
||||
CorrectAnswer: fmt.Sprintf("x = %d", x),
|
||||
WorkedSolution: []string{
|
||||
fmt.Sprintf("Add %d to both sides: %d(x + %d) = %d.", c, a, b, d+c),
|
||||
fmt.Sprintf("Divide by %d: x + %d = %d.", a, b, x+b),
|
||||
fmt.Sprintf("Subtract %d, so x = %d.", b, x),
|
||||
},
|
||||
Tags: buildGeneratedTags(sqlc.QuestionTopicAlgebra, difficulty, "brackets", "multi_step"),
|
||||
}
|
||||
}
|
||||
|
||||
func generateGeometryQuestion(rng *rand.Rand, difficulty sqlc.QuestionDifficulty) GeneratedQuestion {
|
||||
if difficulty == sqlc.QuestionDifficultyEasy {
|
||||
side := randomInt(rng, 2, 9)
|
||||
perimeter := side * 4
|
||||
return GeneratedQuestion{
|
||||
Title: "Easy Geometry",
|
||||
Prompt: fmt.Sprintf("A square has side length %d cm. What is its perimeter?", side),
|
||||
CorrectAnswer: fmt.Sprintf("%d cm", perimeter),
|
||||
WorkedSolution: []string{
|
||||
fmt.Sprintf("A square has 4 equal sides, so calculate 4 × %d.", side),
|
||||
fmt.Sprintf("The perimeter is %d cm.", perimeter),
|
||||
},
|
||||
Tags: buildGeneratedTags(sqlc.QuestionTopicGeometry, difficulty, "perimeter", "square"),
|
||||
}
|
||||
}
|
||||
|
||||
if difficulty == sqlc.QuestionDifficultyMedium {
|
||||
length := randomInt(rng, 4, 15)
|
||||
width := randomInt(rng, 3, 12)
|
||||
area := length * width
|
||||
return GeneratedQuestion{
|
||||
Title: "Medium Geometry",
|
||||
Prompt: fmt.Sprintf("A rectangle has length %d cm and width %d cm. What is its area?", length, width),
|
||||
CorrectAnswer: fmt.Sprintf("%d cm²", area),
|
||||
WorkedSolution: []string{
|
||||
fmt.Sprintf("Area of a rectangle = length × width."),
|
||||
fmt.Sprintf("%d × %d = %d, so the area is %d cm².", length, width, area, area),
|
||||
},
|
||||
Tags: buildGeneratedTags(sqlc.QuestionTopicGeometry, difficulty, "area", "rectangle"),
|
||||
}
|
||||
}
|
||||
|
||||
a := randomInt(rng, 20, 100)
|
||||
b := randomInt(rng, 20, 100)
|
||||
missing := 180 - a - b
|
||||
return GeneratedQuestion{
|
||||
Title: "Hard Geometry",
|
||||
Prompt: fmt.Sprintf("Two angles in a triangle are %d° and %d°. Find the third angle.", a, b),
|
||||
CorrectAnswer: fmt.Sprintf("%d°", missing),
|
||||
WorkedSolution: []string{
|
||||
fmt.Sprintf("Angles in a triangle add to 180°."),
|
||||
fmt.Sprintf("First add the known angles: %d + %d = %d.", a, b, a+b),
|
||||
fmt.Sprintf("Then calculate 180 - %d = %d°.", a+b, missing),
|
||||
},
|
||||
Tags: buildGeneratedTags(sqlc.QuestionTopicGeometry, difficulty, "angles", "triangle"),
|
||||
}
|
||||
}
|
||||
|
||||
func generateDataQuestion(rng *rand.Rand, difficulty sqlc.QuestionDifficulty) GeneratedQuestion {
|
||||
if difficulty == sqlc.QuestionDifficultyEasy {
|
||||
values := sortedRandomValues(rng, 5, 1, 9)
|
||||
median := values[len(values)/2]
|
||||
return GeneratedQuestion{
|
||||
Title: "Easy Data",
|
||||
Prompt: fmt.Sprintf("Find the median of these numbers: %s.", joinInts(values)),
|
||||
CorrectAnswer: fmt.Sprintf("%d", median),
|
||||
WorkedSolution: []string{
|
||||
fmt.Sprintf("Put the numbers in order: %s.", joinInts(values)),
|
||||
fmt.Sprintf("The middle value is %d, so the median is %d.", median, median),
|
||||
},
|
||||
Tags: buildGeneratedTags(sqlc.QuestionTopicData, difficulty, "median"),
|
||||
}
|
||||
}
|
||||
|
||||
if difficulty == sqlc.QuestionDifficultyMedium {
|
||||
values := sortedRandomValues(rng, 5, 2, 20)
|
||||
sum := 0
|
||||
for _, value := range values {
|
||||
sum += value
|
||||
}
|
||||
mean := sum / len(values)
|
||||
adjustment := sum % len(values)
|
||||
if adjustment != 0 {
|
||||
values[len(values)-1] += len(values) - adjustment
|
||||
sort.Ints(values)
|
||||
sum = 0
|
||||
for _, value := range values {
|
||||
sum += value
|
||||
}
|
||||
mean = sum / len(values)
|
||||
}
|
||||
return GeneratedQuestion{
|
||||
Title: "Medium Data",
|
||||
Prompt: fmt.Sprintf("Find the mean of these numbers: %s.", joinInts(values)),
|
||||
CorrectAnswer: fmt.Sprintf("%d", mean),
|
||||
WorkedSolution: []string{
|
||||
fmt.Sprintf("Add the numbers: the total is %d.", sum),
|
||||
fmt.Sprintf("Divide by %d: %d ÷ %d = %d.", len(values), sum, len(values), mean),
|
||||
},
|
||||
Tags: buildGeneratedTags(sqlc.QuestionTopicData, difficulty, "mean"),
|
||||
}
|
||||
}
|
||||
|
||||
values := sortedRandomValues(rng, 6, 5, 30)
|
||||
modeIndex := randomInt(rng, 0, len(values)-1)
|
||||
modeValue := values[modeIndex]
|
||||
values = append(values, modeValue)
|
||||
sort.Ints(values)
|
||||
return GeneratedQuestion{
|
||||
Title: "Hard Data",
|
||||
Prompt: fmt.Sprintf("Find the mode of these numbers: %s.", joinInts(values)),
|
||||
CorrectAnswer: fmt.Sprintf("%d", modeValue),
|
||||
WorkedSolution: []string{
|
||||
fmt.Sprintf("The mode is the value that appears most often."),
|
||||
fmt.Sprintf("%d appears more than any other value, so the mode is %d.", modeValue, modeValue),
|
||||
},
|
||||
Tags: buildGeneratedTags(sqlc.QuestionTopicData, difficulty, "mode"),
|
||||
}
|
||||
}
|
||||
|
||||
func randomInt(rng *rand.Rand, min, max int) int {
|
||||
if max <= min {
|
||||
return min
|
||||
}
|
||||
return min + rng.Intn(max-min+1)
|
||||
}
|
||||
|
||||
func digitsToInt(digits []int) int {
|
||||
value := 0
|
||||
for _, digit := range digits {
|
||||
value = value*10 + digit
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func placeNameFromPower(power int) string {
|
||||
switch power {
|
||||
case 0:
|
||||
return "ones"
|
||||
case 1:
|
||||
return "tens"
|
||||
case 2:
|
||||
return "hundreds"
|
||||
case 3:
|
||||
return "thousands"
|
||||
case 4:
|
||||
return "ten-thousands"
|
||||
default:
|
||||
return "place"
|
||||
}
|
||||
}
|
||||
|
||||
func gcd(a, b int) int {
|
||||
for b != 0 {
|
||||
a, b = b, a%b
|
||||
}
|
||||
if a < 0 {
|
||||
return -a
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func simplifyFraction(numerator, denominator int) (int, int) {
|
||||
if denominator == 0 {
|
||||
return numerator, denominator
|
||||
}
|
||||
divisor := gcd(numerator, denominator)
|
||||
return numerator / divisor, denominator / divisor
|
||||
}
|
||||
|
||||
func formatFraction(numerator, denominator int) string {
|
||||
if denominator == 1 {
|
||||
return fmt.Sprintf("%d", numerator)
|
||||
}
|
||||
return fmt.Sprintf("%d/%d", numerator, denominator)
|
||||
}
|
||||
|
||||
func sortedRandomValues(rng *rand.Rand, count, min, max int) []int {
|
||||
values := make([]int, count)
|
||||
for i := 0; i < count; i++ {
|
||||
values[i] = randomInt(rng, min, max)
|
||||
}
|
||||
sort.Ints(values)
|
||||
return values
|
||||
}
|
||||
|
||||
func joinInts(values []int) string {
|
||||
parts := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
parts = append(parts, fmt.Sprintf("%d", value))
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
175
Backend/internal/questiongen/service_test.go
Normal file
175
Backend/internal/questiongen/service_test.go
Normal file
@@ -0,0 +1,175 @@
|
||||
package questiongen
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"boostai-backend/internal/sqlc"
|
||||
)
|
||||
|
||||
func TestServiceGenerateDeterministicWithSeed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
service := NewService()
|
||||
params := GenerateParams{
|
||||
Topic: sqlc.QuestionTopicFractions,
|
||||
Difficulty: sqlc.QuestionDifficultyMedium,
|
||||
Count: 3,
|
||||
Seed: 123456,
|
||||
}
|
||||
|
||||
first, firstSeed, err := service.Generate(params)
|
||||
if err != nil {
|
||||
t.Fatalf("first generate returned error: %v", err)
|
||||
}
|
||||
|
||||
second, secondSeed, err := service.Generate(params)
|
||||
if err != nil {
|
||||
t.Fatalf("second generate returned error: %v", err)
|
||||
}
|
||||
|
||||
if firstSeed != params.Seed || secondSeed != params.Seed {
|
||||
t.Fatalf("expected seed %d to be reused, got %d and %d", params.Seed, firstSeed, secondSeed)
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(first, second) {
|
||||
t.Fatalf("expected deterministic output for identical seed\nfirst: %#v\nsecond: %#v", first, second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceGenerateSupportsAllTopicsAndDifficulties(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
service := NewService()
|
||||
topics := []sqlc.QuestionTopic{
|
||||
sqlc.QuestionTopicPlaceValue,
|
||||
sqlc.QuestionTopicArithmetic,
|
||||
sqlc.QuestionTopicNegativeNumbers,
|
||||
sqlc.QuestionTopicBidmas,
|
||||
sqlc.QuestionTopicFractions,
|
||||
sqlc.QuestionTopicAlgebra,
|
||||
sqlc.QuestionTopicGeometry,
|
||||
sqlc.QuestionTopicData,
|
||||
}
|
||||
difficulties := []sqlc.QuestionDifficulty{
|
||||
sqlc.QuestionDifficultyEasy,
|
||||
sqlc.QuestionDifficultyMedium,
|
||||
sqlc.QuestionDifficultyHard,
|
||||
}
|
||||
|
||||
for _, topic := range topics {
|
||||
topic := topic
|
||||
for _, difficulty := range difficulties {
|
||||
difficulty := difficulty
|
||||
t.Run(string(topic)+"_"+string(difficulty), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
items, usedSeed, err := service.Generate(GenerateParams{
|
||||
Topic: topic,
|
||||
Difficulty: difficulty,
|
||||
Count: 2,
|
||||
Seed: 99,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("generate returned error: %v", err)
|
||||
}
|
||||
if usedSeed != 99 {
|
||||
t.Fatalf("expected used seed 99, got %d", usedSeed)
|
||||
}
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("expected 2 generated questions, got %d", len(items))
|
||||
}
|
||||
|
||||
for i, item := range items {
|
||||
if item.Title == "" {
|
||||
t.Fatalf("item %d: title should not be empty", i)
|
||||
}
|
||||
if item.Prompt == "" {
|
||||
t.Fatalf("item %d: prompt should not be empty", i)
|
||||
}
|
||||
if item.CorrectAnswer == "" {
|
||||
t.Fatalf("item %d: correct answer should not be empty", i)
|
||||
}
|
||||
if len(item.WorkedSolution) == 0 {
|
||||
t.Fatalf("item %d: worked solution should not be empty", i)
|
||||
}
|
||||
assertContainsTag(t, item.Tags, string(topic))
|
||||
assertContainsTag(t, item.Tags, string(difficulty))
|
||||
assertContainsTag(t, item.Tags, "rng_generated")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFractionsEasyUsesSingleDigitPromptValues(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
service := NewService()
|
||||
items, _, err := service.Generate(GenerateParams{
|
||||
Topic: sqlc.QuestionTopicFractions,
|
||||
Difficulty: sqlc.QuestionDifficultyEasy,
|
||||
Count: 20,
|
||||
Seed: 20260522,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("generate returned error: %v", err)
|
||||
}
|
||||
|
||||
for i, item := range items {
|
||||
values := extractIntegers(item.Prompt)
|
||||
if len(values) != 3 {
|
||||
t.Fatalf("item %d: expected 3 integers in prompt, got %v from %q", i, values, item.Prompt)
|
||||
}
|
||||
|
||||
for _, value := range values {
|
||||
if value < 0 || value > 9 {
|
||||
t.Fatalf("item %d: expected easy fraction prompt values to be single-digit, got %d in %q", i, value, item.Prompt)
|
||||
}
|
||||
}
|
||||
|
||||
assertContainsTag(t, item.Tags, "single_digit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceGenerateRejectsUnsupportedTopic(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
service := NewService()
|
||||
_, _, err := service.Generate(GenerateParams{
|
||||
Topic: sqlc.QuestionTopic("unsupported_topic"),
|
||||
Difficulty: sqlc.QuestionDifficultyEasy,
|
||||
Count: 1,
|
||||
Seed: 1,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected unsupported topic to return an error")
|
||||
}
|
||||
}
|
||||
|
||||
func assertContainsTag(t *testing.T, tags []string, want string) {
|
||||
t.Helper()
|
||||
for _, tag := range tags {
|
||||
if tag == want {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("expected tags %v to contain %q", tags, want)
|
||||
}
|
||||
|
||||
var integerPattern = regexp.MustCompile(`-?\d+`)
|
||||
|
||||
func extractIntegers(input string) []int {
|
||||
matches := integerPattern.FindAllString(input, -1)
|
||||
values := make([]int, 0, len(matches))
|
||||
for _, match := range matches {
|
||||
value, err := strconv.Atoi(match)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
values = append(values, value)
|
||||
}
|
||||
return values
|
||||
}
|
||||
Reference in New Issue
Block a user