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, ", ") }