j-js commited on
Commit
47fe089
·
verified ·
1 Parent(s): e9dedef

Update formatting.py

Browse files
Files changed (1) hide show
  1. formatting.py +260 -491
formatting.py CHANGED
@@ -1,500 +1,269 @@
1
  from __future__ import annotations
2
 
3
  import re
4
- from typing import Any, List, Optional
5
 
 
6
 
7
- def style_prefix(tone: float) -> str:
8
- if tone < 0.2:
9
- return ""
10
- if tone < 0.45:
11
- return "ok.."
12
- if tone < 0.75:
13
- return "Let’s work through it."
14
- return "You’ve got this — let’s solve it cleanly."
15
 
 
 
 
16
 
17
- def _normalize_key(text: str) -> str:
18
- text = (text or "").strip().lower()
19
- text = text.replace("’", "'")
20
- text = re.sub(r"\s+", " ", text)
21
- return text
22
-
23
-
24
- def _clean_lines(core: str) -> list[str]:
25
- lines = []
26
- for line in (core or "").splitlines():
27
- cleaned = line.strip()
28
- if cleaned:
29
- lines.append(cleaned)
30
- return lines
31
-
32
-
33
- def _dedupe_lines(lines: list[str]) -> list[str]:
34
- seen = set()
35
- output = []
36
- for line in lines:
37
- key = _normalize_key(line)
38
- if key and key not in seen:
39
- seen.add(key)
40
- output.append(line.strip())
41
- return output
42
-
43
-
44
- def _coerce_string(value: Any) -> str:
45
- return (value or "").strip() if isinstance(value, str) else ""
46
-
47
-
48
- def _coerce_list(value: Any) -> List[str]:
49
- if not value:
50
- return []
51
- if isinstance(value, list):
52
- return [str(v).strip() for v in value if str(v).strip()]
53
- if isinstance(value, tuple):
54
- return [str(v).strip() for v in value if str(v).strip()]
55
- if isinstance(value, str):
56
- text = value.strip()
57
- return [text] if text else []
58
- return []
59
-
60
-
61
- def _limit_steps(steps: List[str], verbosity: float, minimum: int = 1) -> List[str]:
62
- if not steps:
63
  return []
64
- if verbosity < 0.25:
65
- limit = minimum
66
- elif verbosity < 0.5:
67
- limit = max(minimum, 2)
68
- elif verbosity < 0.75:
69
- limit = max(minimum, 3)
70
- else:
71
- limit = max(minimum, 5)
72
- return steps[:limit]
73
-
74
-
75
- def _extract_topic_from_text(text: str, fallback: Optional[str] = None) -> str:
76
- low = (text or "").lower()
77
- if fallback:
78
- return fallback
79
- if any(word in low for word in ["equation", "variable", "isolate", "algebra"]):
80
- return "algebra"
81
- if any(word in low for word in ["percent", "percentage", "%"]):
82
- return "percent"
83
- if any(word in low for word in ["ratio", "proportion"]):
84
- return "ratio"
85
- if any(word in low for word in ["probability", "outcome", "chance", "odds"]):
86
- return "probability"
87
- if any(word in low for word in ["mean", "median", "average"]):
88
- return "statistics"
89
- if any(word in low for word in ["triangle", "circle", "angle", "area", "perimeter"]):
90
- return "geometry"
91
- if any(word in low for word in ["integer", "factor", "multiple", "prime", "remainder"]):
92
- return "number_theory"
93
- return "general"
94
-
95
-
96
- def _strip_bullet_prefix(text: str) -> str:
97
- return re.sub(r"^\s*-\s*", "", (text or "").strip())
98
-
99
-
100
- def _normalize_display_lines(lines: List[str]) -> List[str]:
101
- cleaned: List[str] = []
102
- for line in lines:
103
- item = _strip_bullet_prefix(line)
104
- if item:
105
- cleaned.append(item)
106
- return cleaned
107
-
108
-
109
- def _format_answer_mode(
110
- lines: List[str],
111
- topic: str,
112
- tone: float,
113
- verbosity: float,
114
- transparency: float,
115
- ) -> str:
116
- output: List[str] = []
117
- prefix = style_prefix(tone)
118
- if prefix:
119
- output.append(prefix)
120
- output.append("")
121
-
122
- normalized = _normalize_display_lines(lines)
123
- limited = _limit_steps(normalized, verbosity, minimum=2)
124
-
125
- if limited:
126
- output.append("Answer path:")
127
- for step in limited:
128
- output.append(f"- {step}")
129
-
130
- if transparency >= 0.8:
131
- output.append("")
132
- output.append(_why_line(topic))
133
-
134
- return "\n".join(output).strip()
135
-
136
-
137
- def format_reply(
138
- core: str,
139
- tone: float,
140
- verbosity: float,
141
- transparency: float,
142
- help_mode: str,
143
- hint_stage: int = 0,
144
- topic: Optional[str] = None,
145
- ) -> str:
146
- prefix = style_prefix(tone)
147
- core = (core or "").strip()
148
-
149
- if not core:
150
- return prefix or "Start with the structure of the problem."
151
-
152
- lines = _dedupe_lines(_clean_lines(core))
153
- if not lines:
154
- return prefix or "Start with the structure of the problem."
155
-
156
- resolved_topic = _extract_topic_from_text(core, topic)
157
- normalized_lines = _normalize_display_lines(lines)
158
-
159
- if help_mode == "answer":
160
- return _format_answer_mode(normalized_lines, resolved_topic, tone, verbosity, transparency)
161
-
162
- shown = _limit_steps(normalized_lines, verbosity, minimum=1)
163
- output: List[str] = []
164
-
165
- if prefix:
166
- output.append(prefix)
167
- output.append("")
168
-
169
- if help_mode == "hint":
170
- index = max(0, min(int(hint_stage or 0) - 1, len(shown) - 1))
171
- output.append("Hint:")
172
- output.append(f"- {shown[index]}")
173
- if transparency >= 0.8:
174
- output.append("")
175
- output.append(_why_line(resolved_topic))
176
- return "\n".join(output).strip()
177
-
178
- if help_mode == "walkthrough":
179
- output.append("Walkthrough:")
180
- for line in shown:
181
- output.append(f"- {line}")
182
- if transparency >= 0.8:
183
- output.append("")
184
- output.append(_why_line(resolved_topic))
185
- return "\n".join(output).strip()
186
-
187
- if help_mode in {"instruction", "step_by_step"}:
188
- output.append("First step:")
189
- for line in shown:
190
- output.append(f"- {line}")
191
- if transparency >= 0.8:
192
- output.append("")
193
- output.append(_why_line(resolved_topic))
194
- return "\n".join(output).strip()
195
-
196
- if help_mode in {"method", "explain", "concept", "definition"}:
197
- label = {
198
- "method": "Method:",
199
- "explain": "Explanation:",
200
- "concept": "Key idea:",
201
- "definition": "Key idea:",
202
- }[help_mode]
203
- output.append(label)
204
- for line in shown:
205
- output.append(f"- {line}")
206
- if transparency >= 0.75:
207
- output.append("")
208
- output.append(_why_line(resolved_topic))
209
- return "\n".join(output).strip()
210
-
211
- for line in shown:
212
- output.append(f"- {line}")
213
-
214
- if transparency >= 0.85:
215
- output.append("")
216
- output.append(_why_line(resolved_topic))
217
-
218
- return "\n".join(output).strip()
219
-
220
-
221
- def _get_scaffold(result: Any):
222
- return getattr(result, "scaffold", None)
223
-
224
-
225
- def _staged_scaffold_lines(
226
- result: Any,
227
- help_mode: str,
228
- hint_stage: int,
229
- verbosity: float,
230
- transparency: float,
231
- ) -> List[str]:
232
- output: List[str] = []
233
- scaffold = _get_scaffold(result)
234
- if scaffold is None:
235
- return output
236
-
237
- stage = max(0, min(int(hint_stage), 3))
238
-
239
- concept = _coerce_string(getattr(scaffold, "concept", ""))
240
- ask = _coerce_string(getattr(scaffold, "ask", ""))
241
- first_move = _coerce_string(getattr(scaffold, "first_move", ""))
242
- next_hint = _coerce_string(getattr(scaffold, "next_hint", ""))
243
- setup_actions = _coerce_list(getattr(scaffold, "setup_actions", []))
244
- intermediate_steps = _coerce_list(getattr(scaffold, "intermediate_steps", []))
245
- variables_to_define = _coerce_list(getattr(scaffold, "variables_to_define", []))
246
- equations_to_form = _coerce_list(getattr(scaffold, "equations_to_form", []))
247
- common_traps = _coerce_list(getattr(scaffold, "common_traps", []))
248
- hint_ladder = _coerce_list(getattr(scaffold, "hint_ladder", []))
249
- key_operations = _coerce_list(getattr(scaffold, "key_operations", []))
250
-
251
- if help_mode == "hint":
252
- if hint_ladder:
253
- if stage <= 0:
254
- chosen = hint_ladder[0]
255
- elif stage == 1:
256
- chosen = hint_ladder[min(1, len(hint_ladder) - 1)]
257
- elif stage == 2:
258
- chosen = hint_ladder[min(2, len(hint_ladder) - 1)]
259
- else:
260
- chosen = next_hint or hint_ladder[-1]
261
-
262
- output.append("Hint:")
263
- output.append(f"- {chosen}")
264
-
265
- if stage >= 2 and first_move:
266
- output.append("")
267
- output.append("Focus on this move:")
268
- output.append(f"- {first_move}")
269
-
270
- if stage >= 3 and equations_to_form:
271
- output.append("")
272
- output.append("Set it up like this:")
273
- for item in equations_to_form[:1]:
274
- output.append(f"- {item}")
275
-
276
- if stage >= 3 and key_operations:
277
- output.append("")
278
- output.append("Operation to use:")
279
- for item in key_operations[-2:]:
280
- output.append(f"- {item}")
281
-
282
- if transparency >= 0.8 and concept:
283
- output.append("")
284
- output.append("Core idea:")
285
- output.append(f"- {concept}")
286
-
287
- return output
288
-
289
- if first_move:
290
- output.append("Hint:")
291
- output.append(f"- {first_move}")
292
- return output
293
-
294
- if stage == 0:
295
- if concept and transparency >= 0.75:
296
- output.append("Core idea:")
297
- output.append(f"- {concept}")
298
- output.append("")
299
-
300
- if ask:
301
- output.append("What to identify first:")
302
- output.append(f"- {ask}")
303
-
304
- if setup_actions:
305
- output.append("")
306
- output.append("Set-up path:")
307
- for item in _limit_steps(setup_actions, verbosity, minimum=2):
308
- output.append(f"- {item}")
309
-
310
- if first_move or hint_ladder:
311
- output.append("")
312
- output.append("First move:")
313
- output.append(f"- {first_move or hint_ladder[0]}")
314
-
315
- if intermediate_steps:
316
- output.append("")
317
- output.append("How to build it:")
318
- for item in _limit_steps(intermediate_steps, verbosity, minimum=2):
319
- output.append(f"- {item}")
320
-
321
- if variables_to_define:
322
- output.append("")
323
- output.append("Variables to define:")
324
- for item in variables_to_define[:3]:
325
- output.append(f"- {item}")
326
-
327
- if equations_to_form:
328
- output.append("")
329
- output.append("Equations to form:")
330
- for item in equations_to_form[:3]:
331
- output.append(f"- {item}")
332
-
333
- if key_operations:
334
- output.append("")
335
- output.append("Key operations:")
336
- for item in key_operations[:3]:
337
- output.append(f"- {item}")
338
-
339
- if next_hint or len(hint_ladder) >= 2:
340
- output.append("")
341
- output.append("Next hint:")
342
- output.append(f"- {next_hint or hint_ladder[1]}")
343
-
344
- if common_traps and verbosity >= 0.7:
345
- output.append("")
346
- output.append("Watch out for:")
347
- for item in common_traps[:3]:
348
- output.append(f"- {item}")
349
-
350
- return output
351
-
352
- if stage == 1:
353
- if ask:
354
- output.append("What to identify first:")
355
- output.append(f"- {ask}")
356
-
357
- if first_move or hint_ladder:
358
- output.append("")
359
- output.append("First move:")
360
- output.append(f"- {first_move or hint_ladder[0]}")
361
-
362
- if next_hint or len(hint_ladder) >= 2:
363
- output.append("")
364
- output.append("Next hint:")
365
- output.append(f"- {next_hint or hint_ladder[1]}")
366
-
367
- return output
368
-
369
- if stage == 2:
370
- if setup_actions:
371
- output.append("Set-up path:")
372
- for item in _limit_steps(setup_actions, verbosity, minimum=2):
373
- output.append(f"- {item}")
374
-
375
- if intermediate_steps:
376
- output.append("")
377
- output.append("How to build it:")
378
- for item in _limit_steps(intermediate_steps, verbosity, minimum=2):
379
- output.append(f"- {item}")
380
-
381
- if variables_to_define:
382
- output.append("")
383
- output.append("Variables to define:")
384
- for item in variables_to_define[:2]:
385
- output.append(f"- {item}")
386
-
387
- if equations_to_form:
388
- output.append("")
389
- output.append("Equations to form:")
390
- for item in equations_to_form[:2]:
391
- output.append(f"- {item}")
392
-
393
- return output
394
-
395
- if setup_actions:
396
- output.append("Set-up path:")
397
- for item in _limit_steps(setup_actions, verbosity, minimum=2):
398
- output.append(f"- {item}")
399
-
400
- if intermediate_steps:
401
- output.append("")
402
- output.append("How to build it:")
403
- for item in _limit_steps(intermediate_steps, verbosity, minimum=2):
404
- output.append(f"- {item}")
405
-
406
- if variables_to_define:
407
- output.append("")
408
- output.append("Variables to define:")
409
- for item in variables_to_define[:3]:
410
- output.append(f"- {item}")
411
-
412
- if equations_to_form:
413
- output.append("")
414
- output.append("Equations to form:")
415
- for item in equations_to_form[:3]:
416
- output.append(f"- {item}")
417
-
418
- if key_operations:
419
- output.append("")
420
- output.append("Key operations:")
421
- for item in key_operations[:3]:
422
- output.append(f"- {item}")
423
-
424
- if next_hint or len(hint_ladder) >= 2:
425
- output.append("")
426
- output.append("Next hint:")
427
- output.append(f"- {next_hint or hint_ladder[1]}")
428
-
429
- if common_traps and verbosity >= 0.65:
430
- output.append("")
431
- output.append("Watch out for:")
432
- for item in common_traps[:3]:
433
- output.append(f"- {item}")
434
-
435
- return output
436
-
437
-
438
- def format_explainer_response(
439
- result: Any,
440
- tone: float,
441
- verbosity: float,
442
- transparency: float,
443
- help_mode: str = "explain",
444
- hint_stage: int = 0,
445
- ) -> str:
446
- if not result or not getattr(result, "understood", False):
447
- return "I can help explain what the question is asking, but I need the full wording of the question."
448
-
449
- output: List[str] = []
450
- prefix = style_prefix(tone)
451
- if prefix:
452
- output.append(prefix)
453
-
454
- stage = max(0, int(getattr(result, "hint_stage", hint_stage) or 0))
455
-
456
- summary = _coerce_string(getattr(result, "summary", ""))
457
- teaching_points = _coerce_list(getattr(result, "teaching_points", []))
458
-
459
- if help_mode != "hint" and stage == 0 and summary:
460
- if output:
461
- output.append("")
462
- output.append(summary)
463
-
464
- scaffold_lines = _staged_scaffold_lines(
465
- result=result,
466
- help_mode=help_mode,
467
- hint_stage=stage,
468
- verbosity=verbosity,
469
- transparency=transparency,
470
- )
471
- if scaffold_lines:
472
- if output:
473
- output.append("")
474
- output.extend(scaffold_lines)
475
-
476
- if help_mode != "hint" and teaching_points and (stage == 0 or stage >= 3) and verbosity >= 0.55:
477
- output.append("")
478
- output.append("Key teaching points:")
479
- for item in _limit_steps(teaching_points, verbosity, minimum=2):
480
- output.append(f"- {item}")
481
-
482
- topic = _extract_topic_from_text(
483
- f"{summary} {' '.join(teaching_points)}",
484
- getattr(result, "topic", None),
485
- )
486
-
487
- if help_mode != "hint" and transparency >= 0.8 and stage not in {1, 2}:
488
- output.append("")
489
- output.append(_why_line(topic))
490
-
491
- cleaned: List[str] = []
492
- previous_blank = False
493
- for line in output:
494
- is_blank = not line.strip()
495
- if is_blank and previous_blank:
496
- continue
497
- cleaned.append(line)
498
- previous_blank = is_blank
499
 
500
- return "\n".join(cleaned).strip()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from __future__ import annotations
2
 
3
  import re
4
+ from typing import Any, Dict, List, Optional
5
 
6
+ from question_support_loader import question_support_bank
7
 
 
 
 
 
 
 
 
 
8
 
9
+ class QuestionFallbackRouter:
10
+ def _clean(self, text: Optional[str]) -> str:
11
+ return (text or "").strip()
12
 
13
+ def _listify(self, value: Any) -> List[str]:
14
+ if value is None:
15
+ return []
16
+ if isinstance(value, list):
17
+ return [str(v).strip() for v in value if str(v).strip()]
18
+ if isinstance(value, tuple):
19
+ return [str(v).strip() for v in value if str(v).strip()]
20
+ if isinstance(value, str):
21
+ text = value.strip()
22
+ return [text] if text else []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  return []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
 
25
+ def _normalize_topic(self, topic: Optional[str], question_text: str) -> str:
26
+ q = (question_text or "").lower()
27
+ t = (topic or "").strip().lower()
28
+ if t and t not in {"general", "unknown", "general_quant"}:
29
+ return t
30
+ if "%" in q or "percent" in q:
31
+ return "percent"
32
+ if "ratio" in q or re.search(r"\b\d+\s*:\s*\d+\b", q):
33
+ return "ratio"
34
+ if any(k in q for k in ["probability", "odds", "chance", "random"]):
35
+ return "probability"
36
+ if any(k in q for k in ["remainder", "factor", "multiple", "prime", "divisible"]):
37
+ return "number_theory"
38
+ if any(k in q for k in ["triangle", "circle", "angle", "area", "perimeter"]):
39
+ return "geometry"
40
+ if any(k in q for k in ["mean", "median", "average", "standard deviation"]):
41
+ return "statistics"
42
+ if "=" in q or re.search(r"\b[xyz]\b", q):
43
+ return "algebra"
44
+ return "general"
45
+
46
+ def _preview_question(self, question_text: str) -> str:
47
+ cleaned = " ".join(question_text.split())
48
+ if len(cleaned) <= 120:
49
+ return cleaned
50
+ return cleaned[:117].rstrip() + "..."
51
+
52
+ def _topic_defaults(self, topic: str, question_text: str, options_text: Optional[List[str]]) -> Dict[str, Any]:
53
+ preview = self._preview_question(question_text)
54
+ has_options = bool(options_text)
55
+
56
+ generic = {
57
+ "first_step": f"Focus on what the question is asking in: {preview}",
58
+ "hint_ladder": [
59
+ "Identify the exact quantity the question wants.",
60
+ "Translate the relationships in the stem into a setup you can work with.",
61
+ "Check each step against the wording before choosing an option.",
62
+ ],
63
+ "walkthrough_steps": [
64
+ "Underline what is given and what must be found.",
65
+ "Set up the relationship before you calculate.",
66
+ "Work step by step and keep the units or labels consistent.",
67
+ ],
68
+ "method_steps": [
69
+ "Start from the structure of the problem rather than jumping into arithmetic.",
70
+ "Use the wording to decide which relationship matters most.",
71
+ ],
72
+ "answer_path": [
73
+ "Set up the correct structure first.",
74
+ "Only then simplify or evaluate the result.",
75
+ ],
76
+ "common_trap": "Rushing into calculation before setting up the relationship.",
77
+ }
78
+
79
+ if topic == "algebra":
80
+ generic.update(
81
+ {
82
+ "first_step": "Write the equation exactly as given and identify the operation attached to the variable.",
83
+ "hint_ladder": [
84
+ "Look at which operation is attached to the variable first.",
85
+ "Undo that operation on both sides, not just one side.",
86
+ "Keep simplifying until the variable is isolated.",
87
+ ],
88
+ "walkthrough_steps": [
89
+ "Copy the equation exactly as written.",
90
+ "Use inverse operations to undo what is attached to the variable.",
91
+ "Do the same thing to both sides each time.",
92
+ "Once the variable is isolated, compare with the answer choices if there are any.",
93
+ ],
94
+ "method_steps": [
95
+ "Algebra questions are about preserving balance while isolating the unknown.",
96
+ "Treat each operation in reverse order to peel the equation back.",
97
+ ],
98
+ "common_trap": "Changing only one side of the equation or combining terms too early.",
99
+ }
100
+ )
101
+ elif topic == "percent":
102
+ generic.update(
103
+ {
104
+ "first_step": "Identify the base quantity before doing any percent calculation.",
105
+ "hint_ladder": [
106
+ "Decide what quantity the percent is 'of'.",
107
+ "Rewrite the percent as a decimal or fraction if that helps.",
108
+ "Set up part = percent × base, or reverse it if the base is unknown.",
109
+ ],
110
+ "walkthrough_steps": [
111
+ "Find the base quantity the percent refers to.",
112
+ "Translate the wording into a percent relationship.",
113
+ "Solve for the missing quantity.",
114
+ "Check whether the question asks for the part, the whole, or a percent change.",
115
+ ],
116
+ "method_steps": [
117
+ "Percent questions become easier once you identify the correct base.",
118
+ "Do not apply the percent to the wrong quantity.",
119
+ ],
120
+ "common_trap": "Using the wrong base quantity.",
121
+ }
122
+ )
123
+ elif topic == "ratio":
124
+ generic.update(
125
+ {
126
+ "first_step": "Keep the ratio order consistent and assign one shared multiplier.",
127
+ "hint_ladder": [
128
+ "Write each part of the ratio with the same multiplier.",
129
+ "Use the total or known part to solve for that multiplier.",
130
+ "Substitute back into the specific part you need.",
131
+ ],
132
+ "walkthrough_steps": [
133
+ "Translate the ratio into algebraic parts with one common scale factor.",
134
+ "Use the total or given piece to solve for the factor.",
135
+ "Find the required part and check the order of the ratio.",
136
+ ],
137
+ "method_steps": [
138
+ "Ratio questions are controlled by one shared multiplier.",
139
+ "Preserve the ratio order all the way through.",
140
+ ],
141
+ "common_trap": "Reversing the ratio order or using different multipliers for different parts.",
142
+ }
143
+ )
144
+ elif topic == "probability":
145
+ generic.update(
146
+ {
147
+ "first_step": "Count the successful outcomes and the total possible outcomes separately.",
148
+ "hint_ladder": [
149
+ "Decide exactly what counts as a success.",
150
+ "Count all possible outcomes under the same rules.",
151
+ "Write probability as successful over total, then simplify if needed.",
152
+ ],
153
+ "walkthrough_steps": [
154
+ "Identify the event the question cares about.",
155
+ "Count the successful outcomes.",
156
+ "Count the full sample space.",
157
+ "Form the probability and simplify carefully.",
158
+ ],
159
+ "method_steps": [
160
+ "Probability is a comparison of favorable outcomes to all valid outcomes.",
161
+ "Make sure the numerator and denominator come from the same setup.",
162
+ ],
163
+ "common_trap": "Counting the right numerator with the wrong denominator.",
164
+ }
165
+ )
166
+ elif topic == "number_theory":
167
+ generic.update(
168
+ {
169
+ "first_step": "Identify which number property matters most: factors, multiples, divisibility, or remainders.",
170
+ "common_trap": "Using arithmetic intuition instead of the actual number-property rule being tested.",
171
+ }
172
+ )
173
+
174
+ if has_options:
175
+ generic["answer_path"].append("Use the choices to check which one matches your setup instead of guessing.")
176
+
177
+ return generic
178
+
179
+ def get_support_pack(
180
+ self,
181
+ *,
182
+ question_id: Optional[str],
183
+ question_text: str,
184
+ options_text: Optional[List[str]],
185
+ topic: Optional[str],
186
+ category: Optional[str],
187
+ ) -> Dict[str, Any]:
188
+ stored = question_support_bank.get(question_id=question_id, question_text=question_text)
189
+ resolved_topic = self._normalize_topic(topic, question_text)
190
+ if stored:
191
+ pack = dict(stored)
192
+ pack.setdefault("topic", resolved_topic)
193
+ pack.setdefault("category", category or "General")
194
+ pack.setdefault("support_source", "question_bank")
195
+ return pack
196
+
197
+ generated = self._topic_defaults(resolved_topic, question_text, options_text)
198
+ generated.update(
199
+ {
200
+ "question_id": question_id,
201
+ "question_text": question_text,
202
+ "options_text": list(options_text or []),
203
+ "topic": resolved_topic,
204
+ "category": category or "General",
205
+ "support_source": "generated_question_specific",
206
+ }
207
+ )
208
+ return generated
209
+
210
+ def build_response(
211
+ self,
212
+ *,
213
+ question_id: Optional[str],
214
+ question_text: str,
215
+ options_text: Optional[List[str]],
216
+ topic: Optional[str],
217
+ category: Optional[str],
218
+ help_mode: str,
219
+ hint_stage: int,
220
+ verbosity: float,
221
+ ) -> Dict[str, Any]:
222
+ pack = self.get_support_pack(
223
+ question_id=question_id,
224
+ question_text=question_text,
225
+ options_text=options_text,
226
+ topic=topic,
227
+ category=category,
228
+ )
229
+
230
+ mode = (help_mode or "answer").lower()
231
+ stage = max(1, min(int(hint_stage or 1), 3))
232
+
233
+ hint_ladder = self._listify(pack.get("hint_ladder") or pack.get("hints"))
234
+ walkthrough_steps = self._listify(pack.get("walkthrough_steps"))
235
+ method_steps = self._listify(pack.get("method_steps") or pack.get("method_explanation"))
236
+ answer_path = self._listify(pack.get("answer_path"))
237
+ common_trap = self._clean(pack.get("common_trap"))
238
+ first_step = self._clean(pack.get("first_step"))
239
+
240
+ if mode == "hint":
241
+ selected = []
242
+ if first_step and stage == 1:
243
+ selected.append(first_step)
244
+ if hint_ladder:
245
+ idx = min(stage - 1, len(hint_ladder) - 1)
246
+ selected.append(hint_ladder[idx])
247
+ if common_trap and verbosity >= 0.55:
248
+ selected.append(f"Watch out for this trap: {common_trap}")
249
+ lines = selected or [first_step or "Start by identifying the structure of the question."]
250
+ elif mode in {"walkthrough", "step_by_step", "instruction"}:
251
+ lines = walkthrough_steps or answer_path or hint_ladder or [first_step or "Start by setting up the problem."]
252
+ if first_step and (not lines or lines[0] != first_step):
253
+ lines = [first_step] + lines
254
+ elif mode in {"method", "explain", "concept", "definition"}:
255
+ lines = method_steps or walkthrough_steps or answer_path or [first_step or "Start from the problem structure."]
256
+ if common_trap and verbosity >= 0.65:
257
+ lines = list(lines) + [f"Common trap: {common_trap}"]
258
+ else:
259
+ lines = answer_path or walkthrough_steps or hint_ladder or [first_step or "Start by identifying the relationship in the question."]
260
+ if first_step and (not lines or lines[0] != first_step):
261
+ lines = [first_step] + lines
262
+
263
+ return {
264
+ "lines": lines,
265
+ "pack": pack,
266
+ }
267
+
268
+
269
+ question_fallback_router = QuestionFallbackRouter()