from __future__ import annotations import re from typing import Any, Dict, List, Optional from question_support_loader import question_support_bank GENERIC_MARKERS = { "write the equation clearly and identify the variable.", "undo operations in reverse order.", "keep both sides balanced while isolating the variable.", "understand the problem.", "identify variables.", "set up relationships.", "solve step by step.", "what is being asked?", "what information is given?", "how can you link them mathematically?", } class QuestionFallbackRouter: def _clean(self, text: Optional[str]) -> str: return (text or "").strip() def _listify(self, value: Any) -> List[str]: if value is None: return [] if isinstance(value, list): return [str(v).strip() for v in value if str(v).strip()] if isinstance(value, tuple): return [str(v).strip() for v in value if str(v).strip()] if isinstance(value, str): return [value.strip()] if value.strip() else [] return [] def _dedupe(self, items: List[str]) -> List[str]: seen = set() out: List[str] = [] for item in items: text = self._clean(item) key = text.lower() if text and key not in seen: seen.add(key) out.append(text) return out def _normalize_topic(self, topic: Optional[str], question_text: str) -> str: q = (question_text or "").lower() t = (topic or "").strip().lower() if t and t not in {"general", "unknown", "general_quant", "quant"}: return t if "%" in q or "percent" in q: return "percent" if "probability" in q or "chance" in q or "at random" in q or "odds" in q: return "probability" if "ratio" in q or re.search(r"\d+\s*:\s*\d+", q): return "ratio" if any(k in q for k in ["mean", "median", "standard deviation", "variability", "spread"]): return "statistics" if any(k in q for k in ["remainder", "prime", "divisible", "factor", "multiple"]): return "number_theory" if any(k in q for k in ["rectangle", "triangle", "circle", "perimeter", "area"]): return "geometry" if "=" in q or re.search(r"[xyzabn]", q): return "algebra" return "general" def _extract_equation(self, question_text: str) -> Optional[str]: m = re.search(r"([^?]*=[^?]*)", self._clean(question_text)) return self._clean(m.group(1)) if m else None def _extract_ratio(self, question_text: str) -> Optional[str]: m = re.search(r"(\d+\s*:\s*\d+)", question_text or "") return self._clean(m.group(1)) if m else None def _looks_like_simple_linear(self, question_text: str) -> bool: q = (question_text or "").lower() return bool("=" in q and re.search(r"what is\s+[a-z]", q)) def _pack_looks_generic(self, pack: Dict[str, Any], topic: str) -> bool: if not pack: return True lines: List[str] = [] for key in ["first_step", "hint_1", "hint_2", "hint_3", "common_trap", "concept"]: value = self._clean(pack.get(key)) if value: lines.append(value.lower()) for key in ["hint_ladder", "walkthrough_steps", "method_steps", "method_explanation", "answer_path"]: lines.extend(x.lower() for x in self._listify(pack.get(key))) if not lines: return True meaningful = 0 for line in lines: if line not in GENERIC_MARKERS and len(line.split()) >= 5: meaningful += 1 if pack.get("support_match", {}).get("mode") in {"question_id", "signature_exact", "text_exact"} and meaningful >= 2: return False return meaningful < 3 def _algebra_pack(self, question_text: str) -> Dict[str, Any]: eq = self._extract_equation(question_text) or "the equation" if self._looks_like_simple_linear(question_text): return { "first_step": f"Start with {eq} and undo the outer operation around the variable first.", "hint_1": "Move the constant term on the variable side by doing the opposite operation to both sides.", "hint_2": "Once the variable term is isolated, undo the coefficient on the variable.", "hint_3": "Check your value by substituting it back into the original equation.", "hint_ladder": [ f"Look at {eq} and ask which operation is happening to the variable last.", "Undo the constant attached to the variable side by using the opposite operation on both sides.", "After that, undo the multiplication or division on the variable itself.", "Substitute your candidate value back in to verify it reproduces the original right-hand side.", ], "walkthrough_steps": [ f"Rewrite the equation cleanly: {eq}.", "Undo the addition or subtraction around the variable by applying the opposite operation to both sides.", "Then undo the multiplication or division on the variable.", "Check the result in the original equation, not just the simplified one.", ], "method_steps": [ "Linear equations are usually solved by reversing operations in the opposite order from how they affect the variable.", "Keeping both sides balanced is what lets every step stay equivalent to the original equation.", ], "answer_path": [ "Reverse the outer operation first.", "Then remove the coefficient from the variable.", "Verify by substitution.", ], "common_trap": "Undoing the coefficient before removing the constant on the variable side.", } return { "first_step": f"Rewrite {eq} in a clean algebraic form before manipulating it.", "hint_1": "Decide which quantity is unknown and which relationships are given.", "hint_2": "Set up one equation at a time from those relationships.", "hint_3": "Only simplify after the structure is correct.", "walkthrough_steps": [ "Name the unknown quantity clearly.", "Translate each condition into an equation or constraint.", "Simplify the algebra only after the setup is correct.", ], "method_steps": [ "Algebra questions are easiest when you translate the wording into relationships before calculating.", ], "answer_path": [ "Identify the unknown.", "Build the equation.", "Isolate the target quantity.", ], "common_trap": "Starting calculations before defining the unknown or building the equation correctly.", } def _percent_pack(self, question_text: str) -> Dict[str, Any]: q = question_text.lower() if "increase" in q or "decrease" in q: return { "first_step": "Turn each percent change into its own multiplier before combining anything.", "hint_1": "Use the original amount as a clean base, often 100, unless the question already gives a convenient number.", "hint_2": "Apply the first percentage change to the current amount, not the final amount.", "hint_3": "Apply the second change to the updated amount, then compare with the original only at the end.", "hint_ladder": [ "Treat a percent increase or decrease as multiplication, not simple adding or subtracting percentages.", "Apply the first multiplier to the starting amount.", "Apply the second multiplier to that new amount.", "Compare the final result with the original base only after both changes are done.", ], "walkthrough_steps": [ "Choose an easy original value such as 100 if no starting number is given.", "Convert each percentage change into a multiplier.", "Apply the multipliers in sequence.", "Express the final amount relative to the original amount.", ], "method_steps": [ "Successive percent changes are multiplicative because each new percent acts on the current amount.", "That is why equal increases and decreases do not cancel each other out.", ], "answer_path": [ "Pick a clean base value.", "Apply each change in order.", "Compare final with original.", ], "common_trap": "Adding and subtracting the percentages directly instead of applying them sequentially.", } return { "first_step": "Ask 'percent of what?' before writing any equation.", "hint_1": "Separate the part, the percent, and the base quantity.", "hint_2": "Write the relationship as part = percent × base, or reverse it if the base is unknown.", "hint_3": "Only convert to a final percent form after the relationship is set up correctly.", "walkthrough_steps": [ "Identify the base amount the percent is taken from.", "Write the percent as a decimal or fraction.", "Set up the percent relationship.", "Solve for the requested quantity.", ], "method_steps": [ "Most percent errors come from choosing the wrong base quantity, not from arithmetic.", ], "answer_path": [ "Identify the base quantity.", "Set up the percent relationship.", "Solve for the target.", ], "common_trap": "Using the part as the base or applying the percent to the wrong quantity.", } def _ratio_pack(self, question_text: str) -> Dict[str, Any]: ratio_text = self._extract_ratio(question_text) first = f"Treat {ratio_text} as matching parts of one whole." if ratio_text else "Treat the ratio numbers as parts, not final values." return { "first_step": first, "hint_1": "Represent each ratio part using one shared multiplier such as k.", "hint_2": "Use the given total or condition to find that shared multiplier.", "hint_3": "Substitute back into the exact quantity the question asks for.", "hint_ladder": [ first, "Write each quantity as a ratio part times the same multiplier.", "Use the total or condition to solve for the multiplier.", "Build the requested expression from the actual quantities, not the raw ratio numbers.", ], "walkthrough_steps": [ first, "Assign variables to each ratio part using one multiplier.", "Solve for the multiplier from the given condition.", "Evaluate the requested quantity.", ], "method_steps": [ "Ratio questions simplify when you convert the ratio into actual quantities with one shared multiplier.", ], "answer_path": [ "Write each part with a common multiplier.", "Solve for the multiplier.", "Substitute into the target expression.", ], "common_trap": "Using the raw ratio numbers as actual values instead of scaled parts.", } def _probability_pack(self, question_text: str) -> Dict[str, Any]: q = question_text.lower() pack = { "first_step": "Define exactly what counts as a successful outcome before you count anything.", "hint_1": "Count the favorable outcomes that satisfy the condition.", "hint_2": "Count the total possible outcomes in the sample space.", "hint_3": "Write probability as favorable over total, then simplify only at the end.", "hint_ladder": [ "State the event in plain language: what outcome are you trying to get?", "Count the favorable cases for that event.", "Count the total possible cases in the sample space.", "Build the probability as favorable over total.", ], "walkthrough_steps": [ "Define the event the question cares about.", "Count the favorable cases.", "Count the total possible cases.", "Write the probability as favorable divided by total.", ], "method_steps": [ "Probability becomes much easier once the event and sample space are both explicit.", "Many mistakes come from counting the wrong denominator, not the numerator.", ], "answer_path": [ "Define the event.", "Count favorable outcomes.", "Count total outcomes.", ], "common_trap": "Using the wrong denominator or forgetting outcomes that belong in the sample space.", } if "at least" in q or "at most" in q: pack["hint_ladder"] = [ "Check whether the complement is easier to count than the event you want.", "Count the easier side first if that reduces the work.", "Convert back to the requested event at the end.", "Then write the probability with the correct denominator.", ] return pack def _statistics_pack(self, question_text: str) -> Dict[str, Any]: q = question_text.lower() if any(k in q for k in ["variability", "spread", "standard deviation"]): return { "first_step": "Notice that the question is about spread, not average.", "hint_1": "Compare how far the values sit from the centre of each set.", "hint_2": "A set with values clustered tightly has lower variability than a set spread farther apart.", "hint_3": "Pick the set with the widest spread, not the highest mean.", "hint_ladder": [ "Ignore the mean at first and focus on how spread out the values are.", "Compare the distance of the outer values from the middle of each set.", "The set with the wider spread has greater variability.", ], "walkthrough_steps": [ "Identify the centre of each set mentally or numerically.", "Compare how tightly the values cluster around that centre.", "Choose the set with the larger spread.", ], "method_steps": [ "Variability measures spread, so a dataset can have the same mean as another and still be more variable.", ], "answer_path": [ "Look at spread around the centre.", "Compare clustering versus spread.", ], "common_trap": "Choosing the set with the highest mean instead of the greatest spread.", } return { "first_step": "Identify which statistical measure the question cares about before calculating.", "hint_1": "Decide whether the task is about mean, median, range, spread, or another measure.", "hint_2": "Organise the data cleanly if that makes the measure easier to see.", "hint_3": "Use the exact definition of the requested measure.", "walkthrough_steps": [ "Identify the requested statistic.", "Organise the data.", "Apply the definition of that statistic.", ], "method_steps": [ "Statistics questions are easiest once you know which measure matters.", ], "answer_path": [ "Identify the target statistic.", "Apply its definition.", ], "common_trap": "Using a nearby but different statistical measure.", } def _geometry_pack(self, question_text: str) -> Dict[str, Any]: q = question_text.lower() if "perimeter" in q and "rectangle" in q: return { "first_step": "Start with the perimeter formula for a rectangle: 2L + 2W.", "hint_1": "Substitute the known perimeter and known side length into the formula.", "hint_2": "Isolate the remaining side length after substitution.", "hint_3": "Check the width in the perimeter formula once more.", "walkthrough_steps": [ "Write the perimeter formula.", "Plug in the given perimeter and length.", "Solve for the width.", ], "method_steps": [ "Geometry questions are often formula-matching questions first and algebra questions second.", ], "answer_path": [ "Write the formula.", "Substitute given values.", "Solve for the missing side.", ], "common_trap": "Forgetting that perimeter includes both lengths and both widths.", } return { "first_step": "Identify the shape and the formula that matches it.", "hint_1": "Write the relevant geometry formula before substituting numbers.", "hint_2": "Substitute carefully and keep track of what the question actually asks for.", "hint_3": "Use algebra only after the correct formula is in place.", "walkthrough_steps": [ "Identify the shape.", "Choose the correct formula.", "Substitute values and solve.", ], "method_steps": [ "Most geometry errors come from choosing the wrong formula or solving for the wrong quantity.", ], "answer_path": [ "Match the shape to its formula.", "Substitute the known values.", "Solve for the target quantity.", ], "common_trap": "Using the wrong formula or solving for the wrong dimension.", } def _topic_defaults(self, topic: str, question_text: str, options_text: Optional[List[str]]) -> Dict[str, Any]: topic = self._normalize_topic(topic, question_text) if topic == "algebra": return self._algebra_pack(question_text) if topic == "percent": return self._percent_pack(question_text) if topic == "ratio": return self._ratio_pack(question_text) if topic == "probability": return self._probability_pack(question_text) if topic == "statistics": return self._statistics_pack(question_text) if topic == "geometry": return self._geometry_pack(question_text) return { "first_step": "Identify the exact relationship the question is testing before doing any arithmetic.", "hint_1": "Separate what is given from what you need to find.", "hint_2": "Build the relationship or formula that links those pieces.", "hint_3": "Only calculate after the structure is correct.", "walkthrough_steps": [ "State what is given and what is unknown.", "Build the relationship between them.", "Solve for the requested quantity.", ], "method_steps": [ "General quant questions become clearer when you translate the wording into a structure first.", ], "answer_path": [ "Identify the structure.", "Set up the relationship.", "Solve the target quantity.", ], "common_trap": "Starting arithmetic before the structure of the problem is clear.", } def _merge_support_pack(self, generated: Dict[str, Any], stored: Optional[Dict[str, Any]], topic: str) -> Dict[str, Any]: if not stored: out = dict(generated) out["support_source"] = "generated_question_specific" return out merged = dict(stored) looks_generic = self._pack_looks_generic(stored, topic) if looks_generic: for key, value in generated.items(): if value: merged[key] = value merged["support_source"] = "question_bank_refined" else: for key, value in generated.items(): if key not in merged or not merged.get(key): merged[key] = value merged["support_source"] = "question_bank" return merged def get_support_pack(self, *, question_id: Optional[str], question_text: str, options_text: Optional[List[str]], topic: Optional[str], category: Optional[str]) -> Dict[str, Any]: resolved_topic = self._normalize_topic(topic, question_text) generated = self._topic_defaults(resolved_topic, question_text, options_text) stored = question_support_bank.get(question_id=question_id, question_text=question_text, options_text=options_text) pack = self._merge_support_pack(generated, stored, resolved_topic) pack.setdefault("question_id", question_id) pack.setdefault("question_text", question_text) pack.setdefault("stem", question_text) pack.setdefault("options_text", list(options_text or [])) pack.setdefault("choices", list(options_text or [])) pack.setdefault("topic", resolved_topic) pack.setdefault("category", category or "General") return pack def _hint_ladder_from_pack(self, pack: Dict[str, Any]) -> List[str]: lines: List[str] = [] if self._clean(pack.get("first_step")): lines.append(self._clean(pack.get("first_step"))) for key in ("hint_1", "hint_2", "hint_3"): value = self._clean(pack.get(key)) if value: lines.append(value) lines.extend(self._listify(pack.get("hint_ladder"))) lines.extend(self._listify(pack.get("hints"))) return self._dedupe(lines) def _walkthrough_from_pack(self, pack: Dict[str, Any]) -> List[str]: lines = self._listify(pack.get("walkthrough_steps")) if not lines and self._clean(pack.get("first_step")): lines.append(self._clean(pack.get("first_step"))) return self._dedupe(lines) def _method_from_pack(self, pack: Dict[str, Any]) -> List[str]: lines: List[str] = [] concept = self._clean(pack.get("concept")) if concept: lines.append(concept) lines.extend(self._listify(pack.get("method_steps"))) lines.extend(self._listify(pack.get("method_explanation"))) if not lines: lines.extend(self._walkthrough_from_pack(pack)[:2]) return self._dedupe(lines) def _answer_path_from_pack(self, pack: Dict[str, Any]) -> List[str]: lines = self._listify(pack.get("answer_path")) if not lines: lines = self._walkthrough_from_pack(pack) return self._dedupe(lines) def _verbosity_limit(self, verbosity: float, *, low: int, mid: int, high: int) -> int: if verbosity < 0.28: return low if verbosity < 0.68: return mid return high def build_response(self, *, question_id: Optional[str], question_text: str, options_text: Optional[List[str]], topic: Optional[str], category: Optional[str], help_mode: str, hint_stage: int, verbosity: float) -> Dict[str, Any]: pack = self.get_support_pack(question_id=question_id, question_text=question_text, options_text=options_text, topic=topic, category=category) mode = (help_mode or "answer").lower() stage = max(1, int(hint_stage or 1)) hint_ladder = self._hint_ladder_from_pack(pack) walkthrough_steps = self._walkthrough_from_pack(pack) method_steps = self._method_from_pack(pack) answer_path = self._answer_path_from_pack(pack) common_trap = self._clean(pack.get("common_trap")) if mode == "hint": idx = min(stage - 1, max(len(hint_ladder) - 1, 0)) if hint_ladder: lines = [hint_ladder[idx]] if verbosity >= 0.62 and idx + 1 < len(hint_ladder): lines.append(hint_ladder[idx + 1]) if verbosity >= 0.82 and stage >= 3 and common_trap: lines.append(f"Watch out for this trap: {common_trap}") else: lines = [self._clean(pack.get("first_step")) or "Start with the structure of the problem."] elif mode in {"walkthrough", "instruction", "step_by_step"}: source = walkthrough_steps or answer_path or hint_ladder lines = source[: self._verbosity_limit(verbosity, low=2, mid=4, high=6)] if verbosity >= 0.8 and common_trap: lines = list(lines) + [f"Watch out for this trap: {common_trap}"] elif mode in {"method", "explain", "concept", "definition"}: source = method_steps or walkthrough_steps or answer_path lines = source[: self._verbosity_limit(verbosity, low=1, mid=2, high=4)] if verbosity >= 0.72 and common_trap: lines = list(lines) + [f"Common trap: {common_trap}"] else: source = answer_path or walkthrough_steps or hint_ladder lines = source[: self._verbosity_limit(verbosity, low=2, mid=3, high=5)] return {"lines": self._dedupe(lines), "pack": pack} question_fallback_router = QuestionFallbackRouter()