| from __future__ import annotations |
|
|
| import re |
| from typing import Any, Dict, List, Optional |
|
|
| from question_support_loader import question_support_bank |
|
|
|
|
| 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): |
| text = value.strip() |
| return [text] if text 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"}: |
| return t |
| if "%" in q or "percent" in q: |
| return "percent" |
| if "ratio" in q or re.search(r"\b\d+\s*:\s*\d+\b", q): |
| return "ratio" |
| if any(k in q for k in ["probability", "odds", "chance", "random"]): |
| return "probability" |
| if any(k in q for k in ["remainder", "factor", "multiple", "prime", "divisible"]): |
| return "number_theory" |
| if any(k in q for k in ["triangle", "circle", "angle", "area", "perimeter", "rectangle", "circumference"]): |
| return "geometry" |
| if any(k in q for k in ["mean", "median", "average", "standard deviation", "variability"]): |
| return "statistics" |
| if "=" in q or re.search(r"\b[xyzabn]\b", q): |
| return "algebra" |
| return "general" |
|
|
| def _preview_question(self, question_text: str) -> str: |
| cleaned = " ".join((question_text or "").split()) |
| if len(cleaned) <= 120: |
| return cleaned |
| return cleaned[:117].rstrip() + "..." |
|
|
| def _extract_equation(self, question_text: str) -> Optional[str]: |
| text = self._clean(question_text) |
| m = re.search(r"([^\?]*=[^\?]*)", text) |
| if m: |
| eq = self._clean(m.group(1)) |
| return eq or None |
| return None |
|
|
| def _extract_ratio(self, question_text: str) -> Optional[str]: |
| text = self._clean(question_text) |
| m = re.search(r"\b(\d+\s*:\s*\d+)\b", text) |
| if m: |
| return self._clean(m.group(1)) |
| return None |
|
|
| def _extract_percent_values(self, question_text: str) -> List[str]: |
| return re.findall(r"\d+\.?\d*\s*%", question_text or "") |
|
|
| def _topic_defaults(self, topic: str, question_text: str, options_text: Optional[List[str]]) -> Dict[str, Any]: |
| preview = self._preview_question(question_text) |
| equation = self._extract_equation(question_text) |
| ratio_text = self._extract_ratio(question_text) |
| percent_values = self._extract_percent_values(question_text) |
| has_options = bool(options_text) |
|
|
| generic = { |
| "first_step": f"Focus on what the question is really asking in: {preview}", |
| "hint_1": "Identify the exact quantity you need to find.", |
| "hint_2": "Translate the key relationship in the question into a usable setup.", |
| "hint_3": "Check each step against the wording before choosing an option.", |
| "hint_ladder": [ |
| "Identify the exact quantity you need to find.", |
| "Translate the key relationship in the question into a usable setup.", |
| "Check each step against the wording before choosing an option.", |
| ], |
| "walkthrough_steps": [ |
| "Underline what is given and what must be found.", |
| "Set up the relationship before you calculate.", |
| "Work step by step and keep labels or units consistent.", |
| ], |
| "method_steps": [ |
| "Start from the structure of the problem rather than jumping into arithmetic.", |
| "Use the wording to decide which relationship matters most.", |
| ], |
| "answer_path": [ |
| "Set up the correct structure first.", |
| "Only then simplify or evaluate the result.", |
| ], |
| "common_trap": "Rushing into calculation before setting up the relationship.", |
| } |
|
|
| if topic == "algebra": |
| first_step = ( |
| f"Start with the equation: {equation}" |
| if equation |
| else "Write the equation exactly as given and identify the operation attached to the variable." |
| ) |
| hint_1 = "Focus on isolating the variable." |
| hint_2 = "Undo addition or subtraction before undoing multiplication or division." |
| hint_3 = "Do the same operation to both sides each time so the equation stays balanced." |
|
|
| generic.update( |
| { |
| "first_step": first_step, |
| "hint_1": hint_1, |
| "hint_2": hint_2, |
| "hint_3": hint_3, |
| "hint_ladder": [hint_1, hint_2, hint_3], |
| "walkthrough_steps": [ |
| first_step, |
| "Use inverse operations to undo what is attached to the variable.", |
| "Do the same thing to both sides each time.", |
| "Once the variable is isolated, compare with the answer choices if there are any.", |
| ], |
| "method_steps": [ |
| "Algebra questions are about preserving balance while isolating the unknown.", |
| "Treat each operation in reverse order to peel the equation back.", |
| ], |
| "answer_path": [ |
| first_step, |
| "Reverse the operations in a sensible order.", |
| "Simplify only after keeping both sides balanced.", |
| ], |
| "common_trap": "Changing only one side of the equation or reversing the order of operations badly.", |
| } |
| ) |
|
|
| elif topic == "percent": |
| first_step = "Identify the base quantity before doing any percent calculation." |
| if percent_values: |
| first_step = f"Track the percentage relationship carefully here: {' then '.join(percent_values[:2]) if len(percent_values) > 1 else percent_values[0]}" |
|
|
| hint_1 = "Decide what quantity the percent is of." |
| hint_2 = "Rewrite the percent as a decimal or fraction if that makes the relationship clearer." |
| hint_3 = "Set up part = percent × base, or reverse that relationship if the base is unknown." |
|
|
| generic.update( |
| { |
| "first_step": first_step, |
| "hint_1": hint_1, |
| "hint_2": hint_2, |
| "hint_3": hint_3, |
| "hint_ladder": [hint_1, hint_2, hint_3], |
| "walkthrough_steps": [ |
| first_step, |
| "Find the base quantity the percent refers to.", |
| "Translate the wording into a percent relationship.", |
| "Solve for the missing quantity.", |
| "Check whether the question asks for the part, the whole, or a percent change.", |
| ], |
| "method_steps": [ |
| "Percent questions become easier once you identify the correct base.", |
| "Do not apply the percent to the wrong quantity.", |
| ], |
| "answer_path": [ |
| first_step, |
| "Turn the wording into a percent equation.", |
| "Then solve for the missing part of the relationship.", |
| ], |
| "common_trap": "Using the wrong base quantity.", |
| } |
| ) |
|
|
| elif topic == "ratio": |
| first_step = "Keep the ratio order consistent and assign one shared multiplier." |
| if ratio_text: |
| first_step = f"Use the ratio {ratio_text} as parts of one whole." |
|
|
| hint_1 = "Write each part of the ratio using the same multiplier." |
| hint_2 = "Use the total or known part to solve for that shared multiplier." |
| hint_3 = "Substitute back into the specific part you actually need." |
|
|
| generic.update( |
| { |
| "first_step": first_step, |
| "hint_1": hint_1, |
| "hint_2": hint_2, |
| "hint_3": hint_3, |
| "hint_ladder": [hint_1, hint_2, hint_3], |
| "walkthrough_steps": [ |
| first_step, |
| "Translate the ratio into algebraic parts with one common scale factor.", |
| "Use the total or given piece to solve for the factor.", |
| "Find the required part and check the order of the ratio.", |
| ], |
| "method_steps": [ |
| "Ratio questions are controlled by one shared multiplier.", |
| "Preserve the ratio order all the way through.", |
| ], |
| "answer_path": [ |
| first_step, |
| "Convert the ratio into parts of a total.", |
| "Solve for the shared scale factor before finding the requested part.", |
| ], |
| "common_trap": "Reversing the ratio order or using different multipliers for different parts.", |
| } |
| ) |
|
|
| elif topic == "probability": |
| hint_1 = "Decide exactly what counts as a successful outcome." |
| hint_2 = "Count all possible outcomes under the same rules." |
| hint_3 = "Write probability as successful over total, then simplify if needed." |
|
|
| generic.update( |
| { |
| "first_step": "Count successful outcomes and total outcomes separately.", |
| "hint_1": hint_1, |
| "hint_2": hint_2, |
| "hint_3": hint_3, |
| "hint_ladder": [hint_1, hint_2, hint_3], |
| "walkthrough_steps": [ |
| "Identify the event the question cares about.", |
| "Count the successful outcomes.", |
| "Count the full sample space.", |
| "Form the probability and simplify carefully.", |
| ], |
| "method_steps": [ |
| "Probability is a comparison of favorable outcomes to all valid outcomes.", |
| "Make sure the numerator and denominator come from the same setup.", |
| ], |
| "answer_path": [ |
| "Define the event clearly.", |
| "Count favorable outcomes and total outcomes from the same sample space.", |
| "Then simplify the fraction if possible.", |
| ], |
| "common_trap": "Counting the right numerator with the wrong denominator.", |
| } |
| ) |
|
|
| elif topic == "number_theory": |
| generic.update( |
| { |
| "first_step": "Identify which number property matters most: factors, multiples, divisibility, or remainders.", |
| "hint_1": "Look for the number property the question is testing.", |
| "hint_2": "Use that rule directly instead of trying random arithmetic.", |
| "hint_3": "Check the candidate values against the exact condition in the question.", |
| "hint_ladder": [ |
| "Look for the number property the question is testing.", |
| "Use that rule directly instead of trying random arithmetic.", |
| "Check the candidate values against the exact condition in the question.", |
| ], |
| "common_trap": "Using arithmetic intuition instead of the actual number-property rule being tested.", |
| } |
| ) |
|
|
| elif topic == "geometry": |
| generic.update( |
| { |
| "first_step": "Identify the shape and the formula or relationship that belongs to it.", |
| "hint_1": "Work out which measurement is given and which one you need.", |
| "hint_2": "Choose the correct geometry formula before substituting numbers.", |
| "hint_3": "Substitute carefully and keep track of what the question asks for.", |
| "hint_ladder": [ |
| "Work out which measurement is given and which one you need.", |
| "Choose the correct geometry formula before substituting numbers.", |
| "Substitute carefully and keep track of what the question asks for.", |
| ], |
| "common_trap": "Using the wrong formula or solving for the wrong geometric quantity.", |
| } |
| ) |
|
|
| elif topic == "statistics": |
| qlow = (question_text or "").lower() |
| |
| if any(k in qlow for k in ["variability", "spread", "standard deviation"]): |
| generic.update( |
| { |
| "first_step": "This is about spread, so compare how far the values sit from the center.", |
| "hint_1": "Check which dataset has values furthest from its middle value.", |
| "hint_2": "Since each set has three numbers, compare how spread out the smallest and largest values are.", |
| "hint_3": "The set with the biggest overall spread has the greatest variability here.", |
| "hint_ladder": [ |
| "Check which dataset has values furthest from its middle value.", |
| "Compare the distance from the middle to the outer numbers in each set.", |
| "The set with the largest spread has the greatest variability.", |
| ], |
| "walkthrough_steps": [ |
| "Notice that the question is about variability, not the average.", |
| "For each dataset, identify the middle value.", |
| "Compare how far the outer values sit from that middle value.", |
| "The dataset with the widest spread is the most variable.", |
| ], |
| "method_steps": [ |
| "For short answer choices like these, you can often compare spread visually instead of computing a full standard deviation.", |
| "Look at how tightly clustered or widely spaced the numbers are.", |
| ], |
| "answer_path": [ |
| "Identify that the question is testing spread rather than center.", |
| "Compare how far the values extend away from the middle in each dataset.", |
| "Choose the dataset with the widest spread.", |
| ], |
| "common_trap": "Comparing means instead of comparing spread.", |
| } |
| ) |
| else: |
| generic.update( |
| { |
| "first_step": "Identify which measure the question wants before calculating anything.", |
| "hint_1": "Check whether this is asking for mean, median, range, or another measure.", |
| "hint_2": "Set up the data in a clean order if needed.", |
| "hint_3": "Use the correct formula or definition for that exact measure.", |
| "hint_ladder": [ |
| "Check whether this is asking for mean, median, range, or another measure.", |
| "Set up the data in a clean order if needed.", |
| "Use the correct formula or definition for that exact measure.", |
| ], |
| "common_trap": "Using the wrong statistical measure because the wording was skimmed too quickly.", |
| } |
| ) |
|
|
| if has_options: |
| generic["answer_path"] = list(generic.get("answer_path", [])) + [ |
| "Use the choices to check which one matches your setup instead of guessing." |
| ] |
|
|
| return generic |
|
|
| 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]: |
| stored = question_support_bank.get(question_id=question_id, question_text=question_text) |
| resolved_topic = self._normalize_topic(topic, question_text) |
|
|
| if stored: |
| pack = dict(stored) |
| pack.setdefault("question_id", question_id) |
| pack.setdefault("question_text", question_text) |
| pack.setdefault("options_text", list(options_text or [])) |
| pack.setdefault("topic", resolved_topic) |
| pack.setdefault("category", category or "General") |
| pack.setdefault("support_source", "question_bank") |
| return pack |
|
|
| generated = self._topic_defaults(resolved_topic, question_text, options_text) |
| generated.update( |
| { |
| "question_id": question_id, |
| "question_text": question_text, |
| "options_text": list(options_text or []), |
| "topic": resolved_topic, |
| "category": category or "General", |
| "support_source": "generated_question_specific", |
| } |
| ) |
| return generated |
|
|
| def _hint_ladder_from_pack(self, pack: Dict[str, Any]) -> List[str]: |
| hints: List[str] = [] |
|
|
| for key in ("hint_1", "hint_2", "hint_3"): |
| value = self._clean(pack.get(key)) |
| if value: |
| hints.append(value) |
|
|
| hints.extend(self._listify(pack.get("hint_ladder"))) |
| hints.extend(self._listify(pack.get("hints"))) |
|
|
| return self._dedupe(hints) |
|
|
| def _walkthrough_from_pack(self, pack: Dict[str, Any]) -> List[str]: |
| lines: List[str] = [] |
| first_step = self._clean(pack.get("first_step")) |
| if first_step: |
| lines.append(first_step) |
| lines.extend(self._listify(pack.get("walkthrough_steps"))) |
| return self._dedupe(lines) |
|
|
| def _method_from_pack(self, pack: Dict[str, Any]) -> List[str]: |
| lines: List[str] = [] |
| lines.extend(self._listify(pack.get("method_steps"))) |
| lines.extend(self._listify(pack.get("method_explanation"))) |
| concept = self._clean(pack.get("concept")) |
| if concept: |
| lines.insert(0, concept) |
| return self._dedupe(lines) |
|
|
| def _answer_path_from_pack(self, pack: Dict[str, Any]) -> List[str]: |
| lines: List[str] = [] |
| first_step = self._clean(pack.get("first_step")) |
| if first_step: |
| lines.append(first_step) |
| lines.extend(self._listify(pack.get("answer_path"))) |
| return self._dedupe(lines) |
|
|
| def _verbosity_limit(self, verbosity: float, low: int, mid: int, high: int) -> int: |
| if verbosity < 0.25: |
| return low |
| if verbosity < 0.65: |
| 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, min(int(hint_stage or 1), 3)) |
|
|
| first_step = self._clean(pack.get("first_step")) |
| 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")) |
|
|
| lines: List[str] = [] |
|
|
| if mode == "hint": |
| selected: List[str] = [] |
|
|
| if stage == 1: |
| if first_step: |
| selected.append(first_step) |
| if hint_ladder: |
| selected.append(hint_ladder[0]) |
|
|
| if verbosity >= 0.65 and len(hint_ladder) >= 2: |
| selected.append(hint_ladder[1]) |
|
|
| elif stage == 2: |
| if len(hint_ladder) >= 2: |
| selected.append(hint_ladder[1]) |
| elif hint_ladder: |
| selected.append(hint_ladder[0]) |
|
|
| if verbosity >= 0.45 and first_step: |
| selected.insert(0, first_step) |
|
|
| if verbosity >= 0.65 and len(hint_ladder) >= 3: |
| selected.append(hint_ladder[2]) |
|
|
| else: |
| if len(hint_ladder) >= 3: |
| selected.append(hint_ladder[2]) |
| elif hint_ladder: |
| selected.append(hint_ladder[-1]) |
|
|
| if verbosity >= 0.45 and first_step: |
| selected.insert(0, first_step) |
|
|
| if verbosity >= 0.7 and common_trap: |
| selected.append(f"Watch out for this trap: {common_trap}") |
|
|
| lines = self._dedupe(selected) or [first_step or "Start by identifying the structure of the question."] |
|
|
| elif mode in {"walkthrough", "step_by_step", "instruction"}: |
| if walkthrough_steps: |
| limit = self._verbosity_limit(verbosity, low=2, mid=4, high=6) |
| lines = walkthrough_steps[:limit] |
| elif answer_path: |
| limit = self._verbosity_limit(verbosity, low=2, mid=3, high=5) |
| lines = answer_path[:limit] |
| elif hint_ladder: |
| limit = self._verbosity_limit(verbosity, low=1, mid=2, high=3) |
| lines = hint_ladder[:limit] |
| else: |
| lines = [first_step or "Start by setting up the problem."] |
|
|
| if verbosity >= 0.7 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 or hint_ladder |
| if source: |
| limit = self._verbosity_limit(verbosity, low=1, mid=2, high=4) |
| lines = source[:limit] |
| else: |
| lines = [first_step or "Start from the problem structure."] |
|
|
| if verbosity >= 0.65 and common_trap: |
| lines = list(lines) + [f"Common trap: {common_trap}"] |
|
|
| else: |
| source = answer_path or walkthrough_steps or hint_ladder |
| if source: |
| limit = self._verbosity_limit(verbosity, low=2, mid=3, high=5) |
| lines = source[:limit] |
| else: |
| lines = [first_step or "Start by identifying the relationship in the question."] |
|
|
| lines = self._dedupe(lines) |
|
|
| return { |
| "lines": lines, |
| "pack": pack, |
| } |
|
|
|
|
| question_fallback_router = QuestionFallbackRouter() |