Last active
May 12, 2026 16:51
-
-
Save basperheim/64264f9c3e1298aae4e23f41c000e8a0 to your computer and use it in GitHub Desktop.
Pre-Calculus Tutoring Python Script
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| from __future__ import annotations | |
| import random | |
| import sys | |
| from dataclasses import dataclass | |
| from decimal import Decimal, InvalidOperation, ROUND_HALF_UP | |
| GREEN = "\033[92m" | |
| RED = "\033[91m" | |
| CYAN = "\033[96m" | |
| YELLOW = "\033[93m" | |
| MAGENTA = "\033[95m" | |
| RESET = "\033[0m" | |
| DECIMAL_PLACES = Decimal("0.01") | |
| @dataclass(frozen=True) | |
| class Problem: | |
| topic: str | |
| prompt: str | |
| expected_answer: Decimal | |
| hints: list[str] | |
| explanation: str | |
| @dataclass | |
| class SessionStats: | |
| attempted: int = 0 | |
| solved_without_hints: int = 0 | |
| solved_with_hints: int = 0 | |
| revealed: int = 0 | |
| skipped: int = 0 | |
| def quantize_2(value: Decimal) -> Decimal: | |
| return value.quantize(DECIMAL_PLACES, rounding=ROUND_HALF_UP) | |
| def parse_decimal_input(raw: str) -> Decimal: | |
| return quantize_2(Decimal(raw.strip())) | |
| def decimal_str(value: Decimal) -> str: | |
| value = quantize_2(value) | |
| if value == value.to_integral(): | |
| return str(value.to_integral()) | |
| return format(value, "f").rstrip("0").rstrip(".") | |
| def difficulty_from_argv(argv: list[str]) -> int: | |
| if len(argv) < 2: | |
| return 3 | |
| try: | |
| value = int(argv[1]) | |
| except ValueError: | |
| print(f"{YELLOW}Invalid difficulty. Using default difficulty 3.{RESET}") | |
| return 3 | |
| return max(1, min(5, value)) | |
| def choose_topic() -> str: | |
| print(f"{CYAN}Choose a topic:{RESET}") | |
| print(" 1) Solving linear equations") | |
| print(" 2) Function evaluation") | |
| print(" 3) Function composition") | |
| print(" 4) Mixed") | |
| print() | |
| while True: | |
| choice = input("Enter 1, 2, 3, or 4: ").strip() | |
| if choice == "1": | |
| return "linear_equations" | |
| if choice == "2": | |
| return "function_evaluation" | |
| if choice == "3": | |
| return "function_composition" | |
| if choice == "4": | |
| return "mixed" | |
| print(f"{YELLOW}Please enter 1, 2, 3, or 4.{RESET}") | |
| def choose_problem_count() -> int: | |
| while True: | |
| raw = input("How many problems do you want this session? [default: 5] ").strip() | |
| if raw == "": | |
| return 5 | |
| try: | |
| value = int(raw) | |
| except ValueError: | |
| print(f"{YELLOW}Please enter a whole number.{RESET}") | |
| continue | |
| if value < 1: | |
| print(f"{YELLOW}Please enter at least 1.{RESET}") | |
| continue | |
| return value | |
| def intro_text() -> None: | |
| print(f"{MAGENTA}Algebra & Precalculus Foundations Tutor v2{RESET}") | |
| print("This is a learning tool, not a speed test.") | |
| print("You can answer normally, or type:") | |
| print(" hint -> show the next hint") | |
| print(" explain -> show the full worked solution") | |
| print(" skip -> skip the problem") | |
| print(" quit -> end the session") | |
| print() | |
| def random_nonzero_int(min_value: int, max_value: int) -> int: | |
| while True: | |
| value = random.randint(min_value, max_value) | |
| if value != 0: | |
| return value | |
| def maybe_half_step(difficulty: int, min_value: int, max_value: int) -> Decimal: | |
| if difficulty < 5: | |
| return Decimal(random.randint(min_value, max_value)) | |
| whole_part = random.randint(min_value, max_value) | |
| value = Decimal(str(random.choice([whole_part, whole_part + 0.5]))) | |
| return quantize_2(value) | |
| def format_signed_number(value: int | Decimal) -> str: | |
| number = Decimal(str(value)) | |
| if number >= 0: | |
| return f"+ {decimal_str(number)}" | |
| return f"- {decimal_str(abs(number))}" | |
| def format_linear_expression(a: int | Decimal, b: int | Decimal) -> str: | |
| a_dec = Decimal(str(a)) | |
| b_dec = Decimal(str(b)) | |
| if a_dec == 1: | |
| first = "x" | |
| elif a_dec == -1: | |
| first = "-x" | |
| else: | |
| first = f"{decimal_str(a_dec)}x" | |
| if b_dec == 0: | |
| return first | |
| return f"{first} {format_signed_number(b_dec)}" | |
| def format_quadratic_term(a: int | Decimal) -> str: | |
| a_dec = Decimal(str(a)) | |
| if a_dec == 1: | |
| return "x^2" | |
| if a_dec == -1: | |
| return "-x^2" | |
| return f"{decimal_str(a_dec)}x^2" | |
| def topic_for_round(selected_topic: str) -> str: | |
| if selected_topic != "mixed": | |
| return selected_topic | |
| return random.choice([ | |
| "linear_equations", | |
| "function_evaluation", | |
| "function_composition", | |
| ]) | |
| def build_linear_equation_problem(difficulty: int) -> Problem: | |
| if difficulty == 1: | |
| a = random_nonzero_int(1, 5) | |
| x_value = Decimal(random.randint(-5, 10)) | |
| b = random.randint(-10, 10) | |
| elif difficulty == 2: | |
| a = random_nonzero_int(-6, 6) | |
| x_value = Decimal(random.randint(-8, 12)) | |
| b = random.randint(-12, 12) | |
| elif difficulty == 3: | |
| a = random_nonzero_int(-9, 9) | |
| x_value = Decimal(random.randint(-12, 15)) | |
| b = random.randint(-20, 20) | |
| elif difficulty == 4: | |
| a = random_nonzero_int(-12, 12) | |
| x_value = Decimal(random.randint(-15, 20)) | |
| b = random.randint(-25, 25) | |
| else: | |
| a = random_nonzero_int(-12, 12) | |
| x_value = maybe_half_step(difficulty, -10, 15) | |
| b = random.randint(-25, 25) | |
| a_dec = Decimal(str(a)) | |
| b_dec = Decimal(str(b)) | |
| c_dec = quantize_2(a_dec * x_value + b_dec) | |
| left_side = format_linear_expression(a, b) | |
| prompt = ( | |
| "Solve for x:\n" | |
| f" {left_side} = {decimal_str(c_dec)}\n" | |
| "Enter only the value of x." | |
| ) | |
| isolated_right = quantize_2(c_dec - b_dec) | |
| hints = [ | |
| ( | |
| "Hint 1:\n" | |
| "Your goal is to isolate x.\n" | |
| "First undo the constant term attached to the x-term." | |
| ), | |
| ( | |
| "Hint 2:\n" | |
| f"Start with {left_side} = {decimal_str(c_dec)}\n" | |
| f"Undo {b:+d} by doing the opposite operation on both sides.\n" | |
| f"That gives: {decimal_str(a_dec)}x = {decimal_str(isolated_right)}" | |
| ), | |
| ( | |
| "Hint 3:\n" | |
| f"Now divide both sides by {decimal_str(a_dec)}.\n" | |
| f"So x = {decimal_str(quantize_2(isolated_right / a_dec))}" | |
| ), | |
| ] | |
| explanation = ( | |
| "Worked solution:\n" | |
| f"1) Start with {left_side} = {decimal_str(c_dec)}\n" | |
| f"2) Undo the constant term: {decimal_str(a_dec)}x = {decimal_str(isolated_right)}\n" | |
| f"3) Divide both sides by {decimal_str(a_dec)}\n" | |
| f"4) x = {decimal_str(x_value)}" | |
| ) | |
| return Problem( | |
| topic="Solving linear equations", | |
| prompt=prompt, | |
| expected_answer=quantize_2(x_value), | |
| hints=hints, | |
| explanation=explanation, | |
| ) | |
| def build_function_evaluation_problem(difficulty: int) -> Problem: | |
| function_name = random.choice(["f", "g", "h"]) | |
| if difficulty <= 2: | |
| form = random.choice(["linear", "square"]) | |
| elif difficulty == 3: | |
| form = random.choice(["linear", "square", "two_term"]) | |
| else: | |
| form = random.choice(["linear", "square", "two_term", "shifted_square"]) | |
| if difficulty < 5: | |
| x_value = Decimal(random.randint(-6, 8)) | |
| else: | |
| x_value = maybe_half_step(difficulty, -6, 8) | |
| if form == "linear": | |
| a = random_nonzero_int(-6, 6) | |
| b = random.randint(-10, 10) | |
| a_dec = Decimal(str(a)) | |
| b_dec = Decimal(str(b)) | |
| expr = format_linear_expression(a, b) | |
| answer = quantize_2(a_dec * x_value + b_dec) | |
| prompt = ( | |
| "Evaluate the function:\n" | |
| f" {function_name}(x) = {expr}\n" | |
| f"Find {function_name}({decimal_str(x_value)})\n" | |
| "Enter only the final number." | |
| ) | |
| hints = [ | |
| ( | |
| "Hint 1:\n" | |
| f"Substitute {decimal_str(x_value)} everywhere you see x.\n" | |
| "Treat the entire input as the value of x." | |
| ), | |
| ( | |
| "Hint 2:\n" | |
| f"After substitution, you get:\n" | |
| f" {decimal_str(a_dec)}({decimal_str(x_value)}) " | |
| f"{format_signed_number(b_dec)}" | |
| ), | |
| ( | |
| "Hint 3:\n" | |
| f"Multiply first: {decimal_str(a_dec)} × {decimal_str(x_value)}" | |
| f" = {decimal_str(quantize_2(a_dec * x_value))}\n" | |
| "Then finish the addition or subtraction." | |
| ), | |
| ] | |
| explanation = ( | |
| "Worked solution:\n" | |
| f"1) Substitute {decimal_str(x_value)} for x\n" | |
| f"2) {function_name}({decimal_str(x_value)}) = {decimal_str(a_dec)}({decimal_str(x_value)}) " | |
| f"{format_signed_number(b_dec)}\n" | |
| f"3) Multiply: {decimal_str(a_dec)}({decimal_str(x_value)}) = " | |
| f"{decimal_str(quantize_2(a_dec * x_value))}\n" | |
| f"4) Finish the arithmetic\n" | |
| f"5) {function_name}({decimal_str(x_value)}) = {decimal_str(answer)}" | |
| ) | |
| elif form == "square": | |
| a = random_nonzero_int(-4, 4) | |
| b = random.randint(-10, 10) | |
| a_dec = Decimal(str(a)) | |
| b_dec = Decimal(str(b)) | |
| squared = quantize_2(x_value * x_value) | |
| answer = quantize_2(a_dec * squared + b_dec) | |
| expr = format_quadratic_term(a) | |
| if b != 0: | |
| expr = f"{expr} {format_signed_number(b_dec)}" | |
| prompt = ( | |
| "Evaluate the function:\n" | |
| f" {function_name}(x) = {expr}\n" | |
| f"Find {function_name}({decimal_str(x_value)})\n" | |
| "Enter only the final number." | |
| ) | |
| hints = [ | |
| ( | |
| "Hint 1:\n" | |
| "Substitute carefully, especially if the input is negative.\n" | |
| "Square the input before applying the outer coefficient." | |
| ), | |
| ( | |
| "Hint 2:\n" | |
| f"First compute ({decimal_str(x_value)})^2 = {decimal_str(squared)}" | |
| ), | |
| ( | |
| "Hint 3:\n" | |
| f"Now multiply by {decimal_str(a_dec)} and then apply " | |
| f"{format_signed_number(b_dec)}." | |
| ), | |
| ] | |
| explanation = ( | |
| "Worked solution:\n" | |
| f"1) Substitute {decimal_str(x_value)} for x\n" | |
| f"2) Compute the square: ({decimal_str(x_value)})^2 = {decimal_str(squared)}\n" | |
| f"3) Multiply by {decimal_str(a_dec)} to get {decimal_str(quantize_2(a_dec * squared))}\n" | |
| f"4) Apply the constant term\n" | |
| f"5) {function_name}({decimal_str(x_value)}) = {decimal_str(answer)}" | |
| ) | |
| elif form == "two_term": | |
| a = random_nonzero_int(-4, 4) | |
| b = random_nonzero_int(-6, 6) | |
| a_dec = Decimal(str(a)) | |
| b_dec = Decimal(str(b)) | |
| squared = quantize_2(x_value * x_value) | |
| first_term = quantize_2(a_dec * squared) | |
| second_term = quantize_2(b_dec * x_value) | |
| answer = quantize_2(first_term + second_term) | |
| expr = f"{format_quadratic_term(a)} {format_signed_number(b_dec)}x" | |
| prompt = ( | |
| "Evaluate the function:\n" | |
| f" {function_name}(x) = {expr}\n" | |
| f"Find {function_name}({decimal_str(x_value)})\n" | |
| "Enter only the final number." | |
| ) | |
| hints = [ | |
| ( | |
| "Hint 1:\n" | |
| "Substitute the input for x in both terms.\n" | |
| "Then compute each term separately before combining them." | |
| ), | |
| ( | |
| "Hint 2:\n" | |
| f"First term: {decimal_str(a_dec)}({decimal_str(x_value)})^2\n" | |
| f"Second term: {decimal_str(b_dec)}({decimal_str(x_value)})" | |
| ), | |
| ( | |
| "Hint 3:\n" | |
| f"({decimal_str(x_value)})^2 = {decimal_str(squared)}\n" | |
| f"First term = {decimal_str(first_term)}\n" | |
| f"Second term = {decimal_str(second_term)}" | |
| ), | |
| ] | |
| explanation = ( | |
| "Worked solution:\n" | |
| f"1) Substitute {decimal_str(x_value)} for x in both terms\n" | |
| f"2) Compute x^2: ({decimal_str(x_value)})^2 = {decimal_str(squared)}\n" | |
| f"3) First term = {decimal_str(first_term)}\n" | |
| f"4) Second term = {decimal_str(second_term)}\n" | |
| f"5) Add them together\n" | |
| f"6) {function_name}({decimal_str(x_value)}) = {decimal_str(answer)}" | |
| ) | |
| else: | |
| shift = random_nonzero_int(-6, 6) | |
| shift_dec = Decimal(str(shift)) | |
| inside = quantize_2(x_value + shift_dec) | |
| answer = quantize_2(inside * inside) | |
| if shift > 0: | |
| expr = f"(x + {shift})^2" | |
| else: | |
| expr = f"(x - {abs(shift)})^2" | |
| prompt = ( | |
| "Evaluate the function:\n" | |
| f" {function_name}(x) = {expr}\n" | |
| f"Find {function_name}({decimal_str(x_value)})\n" | |
| "Enter only the final number." | |
| ) | |
| hints = [ | |
| ( | |
| "Hint 1:\n" | |
| "Do the parentheses first.\n" | |
| "This is not the same thing as x^2 plus or minus something." | |
| ), | |
| ( | |
| "Hint 2:\n" | |
| f"Inside the parentheses:\n" | |
| f" {decimal_str(x_value)} {format_signed_number(shift_dec)} = {decimal_str(inside)}" | |
| ), | |
| ( | |
| "Hint 3:\n" | |
| f"Now square the result:\n" | |
| f" ({decimal_str(inside)})^2" | |
| ), | |
| ] | |
| explanation = ( | |
| "Worked solution:\n" | |
| f"1) Evaluate the parentheses first\n" | |
| f"2) {decimal_str(x_value)} {format_signed_number(shift_dec)} = {decimal_str(inside)}\n" | |
| f"3) Square that result\n" | |
| f"4) {function_name}({decimal_str(x_value)}) = {decimal_str(answer)}" | |
| ) | |
| return Problem( | |
| topic="Function evaluation", | |
| prompt=prompt, | |
| expected_answer=answer, | |
| hints=hints, | |
| explanation=explanation, | |
| ) | |
| def build_function_composition_problem(difficulty: int) -> Problem: | |
| f_name, g_name = random.sample(["f", "g", "h"], 2) | |
| x_value = Decimal(random.randint(-5, 6)) | |
| f_a = random_nonzero_int(-5, 5) | |
| f_b = random.randint(-8, 8) | |
| g_a = random_nonzero_int(-5, 5) | |
| g_b = random.randint(-8, 8) | |
| f_a_dec = Decimal(str(f_a)) | |
| f_b_dec = Decimal(str(f_b)) | |
| g_a_dec = Decimal(str(g_a)) | |
| g_b_dec = Decimal(str(g_b)) | |
| f_expr = format_linear_expression(f_a, f_b) | |
| g_expr = format_linear_expression(g_a, g_b) | |
| if difficulty <= 2: | |
| outer_name = f_name | |
| inner_name = g_name | |
| outer_a = f_a_dec | |
| outer_b = f_b_dec | |
| inner_a = g_a_dec | |
| inner_b = g_b_dec | |
| outer_expr = f_expr | |
| inner_expr = g_expr | |
| else: | |
| # Sometimes swap order to keep it from feeling too canned. | |
| if random.random() < 0.5: | |
| outer_name = f_name | |
| inner_name = g_name | |
| outer_a = f_a_dec | |
| outer_b = f_b_dec | |
| inner_a = g_a_dec | |
| inner_b = g_b_dec | |
| outer_expr = f_expr | |
| inner_expr = g_expr | |
| else: | |
| outer_name = g_name | |
| inner_name = f_name | |
| outer_a = g_a_dec | |
| outer_b = g_b_dec | |
| inner_a = f_a_dec | |
| inner_b = f_b_dec | |
| outer_expr = g_expr | |
| inner_expr = f_expr | |
| inner_value = quantize_2(inner_a * x_value + inner_b) | |
| answer = quantize_2(outer_a * inner_value + outer_b) | |
| prompt = ( | |
| "Evaluate the composition:\n" | |
| f" {f_name}(x) = {f_expr}\n" | |
| f" {g_name}(x) = {g_expr}\n" | |
| f"Find {outer_name}({inner_name}({decimal_str(x_value)}))\n" | |
| "Enter only the final number." | |
| ) | |
| hints = [ | |
| ( | |
| "Hint 1:\n" | |
| "Work from the inside out.\n" | |
| f"First compute {inner_name}({decimal_str(x_value)}), then plug that result into {outer_name}." | |
| ), | |
| ( | |
| "Hint 2:\n" | |
| f"{inner_name}(x) = {inner_expr}\n" | |
| f"So start with {inner_name}({decimal_str(x_value)})." | |
| ), | |
| ( | |
| "Hint 3:\n" | |
| f"{inner_name}({decimal_str(x_value)}) = {decimal_str(inner_value)}\n" | |
| f"Now compute {outer_name}({decimal_str(inner_value)}) using {outer_name}(x) = {outer_expr}." | |
| ), | |
| ] | |
| explanation = ( | |
| "Worked solution:\n" | |
| f"1) Compute the inside function first:\n" | |
| f" {inner_name}({decimal_str(x_value)}) = {decimal_str(inner_value)}\n" | |
| f"2) Now plug that into the outside function:\n" | |
| f" {outer_name}({decimal_str(inner_value)})\n" | |
| f"3) Evaluate it\n" | |
| f"4) {outer_name}({inner_name}({decimal_str(x_value)})) = {decimal_str(answer)}" | |
| ) | |
| return Problem( | |
| topic="Function composition", | |
| prompt=prompt, | |
| expected_answer=answer, | |
| hints=hints, | |
| explanation=explanation, | |
| ) | |
| def generate_problem(selected_topic: str, difficulty: int) -> Problem: | |
| actual_topic = topic_for_round(selected_topic) | |
| if actual_topic == "linear_equations": | |
| return build_linear_equation_problem(difficulty) | |
| if actual_topic == "function_evaluation": | |
| return build_function_evaluation_problem(difficulty) | |
| return build_function_composition_problem(difficulty) | |
| def run_problem(problem: Problem, stats: SessionStats) -> bool: | |
| stats.attempted += 1 | |
| shown_hint_count = 0 | |
| used_hint = False | |
| print(f"{CYAN}{problem.topic}{RESET}") | |
| print(problem.prompt) | |
| print() | |
| while True: | |
| raw = input("> ").strip() | |
| if raw.lower() == "quit": | |
| return False | |
| if raw.lower() == "skip": | |
| stats.skipped += 1 | |
| print(f"{YELLOW}Skipped.{RESET}") | |
| print(f"The correct answer was {decimal_str(problem.expected_answer)}.") | |
| print(problem.explanation) | |
| print() | |
| return True | |
| if raw.lower() == "hint": | |
| if shown_hint_count < len(problem.hints): | |
| print(problem.hints[shown_hint_count]) | |
| shown_hint_count += 1 | |
| used_hint = True | |
| else: | |
| print(f"{YELLOW}No more hints left for this problem.{RESET}") | |
| print() | |
| continue | |
| if raw.lower() == "explain": | |
| stats.revealed += 1 | |
| print(problem.explanation) | |
| print(f"The correct answer was {decimal_str(problem.expected_answer)}.") | |
| print() | |
| return True | |
| try: | |
| user_answer = parse_decimal_input(raw) | |
| except InvalidOperation: | |
| print( | |
| f"{YELLOW}Please enter a valid number, or type hint / explain / skip / quit.{RESET}" | |
| ) | |
| continue | |
| if user_answer == problem.expected_answer: | |
| if used_hint: | |
| stats.solved_with_hints += 1 | |
| print(f"{GREEN}Correct.{RESET} You got it with help.") | |
| else: | |
| stats.solved_without_hints += 1 | |
| print(f"{GREEN}Correct!{RESET}") | |
| print(problem.explanation) | |
| print() | |
| return True | |
| print(f"{RED}Not quite.{RESET}") | |
| if shown_hint_count == 0: | |
| print("Type 'hint' for guidance, or try again.") | |
| else: | |
| print("Use the hint and try the next step carefully.") | |
| print() | |
| def print_summary(stats: SessionStats) -> None: | |
| solved_total = stats.solved_without_hints + stats.solved_with_hints | |
| denominator = stats.attempted if stats.attempted > 0 else 1 | |
| print(f"{MAGENTA}Session Summary{RESET}") | |
| print(f"Problems attempted: {stats.attempted}") | |
| print(f"Solved without hints: {stats.solved_without_hints}") | |
| print(f"Solved with hints: {stats.solved_with_hints}") | |
| print(f"Revealed explanations: {stats.revealed}") | |
| print(f"Skipped: {stats.skipped}") | |
| print(f"Total solved: {solved_total}/{denominator}") | |
| def main() -> None: | |
| random.seed() | |
| difficulty = difficulty_from_argv(sys.argv) | |
| intro_text() | |
| print(f"Difficulty: {difficulty} (1 = easier, 5 = harder)") | |
| topic = choose_topic() | |
| count = choose_problem_count() | |
| print() | |
| stats = SessionStats() | |
| for index in range(1, count + 1): | |
| print(f"{MAGENTA}Problem {index}/{count}{RESET}") | |
| problem = generate_problem(topic, difficulty) | |
| should_continue = run_problem(problem, stats) | |
| if not should_continue: | |
| break | |
| print_summary(stats) | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment