GameAI / question_fallback_router.py
j-js's picture
Update question_fallback_router.py
a5e2e9b verified
raw
history blame
24.3 kB
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()