| 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() |
|
|