j-js commited on
Commit
e5300bf
·
verified ·
1 Parent(s): 82d72b0

Update conversation_logic.py

Browse files
Files changed (1) hide show
  1. conversation_logic.py +148 -509
conversation_logic.py CHANGED
@@ -4,15 +4,15 @@ import re
4
  from typing import Any, Dict, List, Optional, Tuple
5
 
6
  from context_parser import detect_intent, intent_to_help_mode
7
- from formatting import format_reply, format_explainer_response
8
  from generator_engine import GeneratorEngine
9
  from models import RetrievedChunk, SolverResult
10
  from quant_solver import is_quant_question
11
- from solver_router import route_solver
12
- from explainers.explainer_router import route_explainer
13
  from question_classifier import classify_question, normalize_category
 
14
  from retrieval_engine import RetrievalEngine
15
-
 
16
 
17
  DIRECT_SOLVE_PATTERNS = [
18
  r"\bsolve\b",
@@ -54,83 +54,29 @@ def _clean_text(text: Optional[str]) -> str:
54
 
55
 
56
  def _safe_get_state(session_state: Optional[Dict[str, Any]]) -> Dict[str, Any]:
57
- if isinstance(session_state, dict):
58
- return dict(session_state)
59
- return {}
60
-
61
-
62
- def _extract_text_from_history_item(item: Dict[str, Any]) -> str:
63
- if not isinstance(item, dict):
64
- return ""
65
-
66
- direct_keys = ("content", "text", "message", "question_text", "raw_user_text")
67
- for key in direct_keys:
68
- value = item.get(key)
69
- if isinstance(value, str) and value.strip():
70
- return value.strip()
71
-
72
- meta = item.get("meta")
73
- if isinstance(meta, dict):
74
- for key in ("question_text", "recovered_question_text"):
75
- value = meta.get(key)
76
- if isinstance(value, str) and value.strip():
77
- return value.strip()
78
-
79
- nested_state = meta.get("session_state")
80
- if isinstance(nested_state, dict):
81
- value = nested_state.get("question_text")
82
- if isinstance(value, str) and value.strip():
83
- return value.strip()
84
-
85
- nested_state = item.get("session_state")
86
- if isinstance(nested_state, dict):
87
- value = nested_state.get("question_text")
88
- if isinstance(value, str) and value.strip():
89
- return value.strip()
90
-
91
- return ""
92
 
93
 
94
  def _extract_question_candidates_from_history_item(item: Dict[str, Any]) -> List[str]:
95
  if not isinstance(item, dict):
96
  return []
97
-
98
  candidates: List[str] = []
99
-
100
- direct_keys = ("question_text", "raw_user_text", "content", "text", "message")
101
- for key in direct_keys:
102
  value = item.get(key)
103
  if isinstance(value, str) and value.strip():
104
  candidates.append(value.strip())
105
-
106
  meta = item.get("meta")
107
  if isinstance(meta, dict):
108
  for key in ("question_text", "recovered_question_text"):
109
  value = meta.get(key)
110
  if isinstance(value, str) and value.strip():
111
  candidates.append(value.strip())
112
-
113
  nested_state = meta.get("session_state")
114
  if isinstance(nested_state, dict):
115
  value = nested_state.get("question_text")
116
  if isinstance(value, str) and value.strip():
117
  candidates.append(value.strip())
118
-
119
- nested_state = item.get("session_state")
120
- if isinstance(nested_state, dict):
121
- value = nested_state.get("question_text")
122
- if isinstance(value, str) and value.strip():
123
- candidates.append(value.strip())
124
-
125
- deduped: List[str] = []
126
- seen = set()
127
- for candidate in candidates:
128
- key = candidate.strip().lower()
129
- if key and key not in seen:
130
- seen.add(key)
131
- deduped.append(candidate.strip())
132
-
133
- return deduped
134
 
135
 
136
  def _is_followup_hint_only(text: str) -> bool:
@@ -160,13 +106,11 @@ def _strip_control_prefix(text: str) -> str:
160
  cleaned = (text or "").strip()
161
  if not cleaned:
162
  return ""
163
-
164
  previous = None
165
  while previous != cleaned:
166
  previous = cleaned
167
  for pattern in CONTROL_PREFIX_PATTERNS:
168
  cleaned = re.sub(pattern, "", cleaned, flags=re.I).strip()
169
-
170
  return cleaned
171
 
172
 
@@ -174,131 +118,48 @@ def _sanitize_question_text(text: str) -> str:
174
  raw = (text or "").strip()
175
  if not raw:
176
  return ""
177
-
178
  lines = [line.strip() for line in raw.splitlines() if line.strip()]
179
-
180
- if len(lines) >= 9 and re.fullmatch(r"-?\d+", lines[2]) and lines[4].lower() in {"true", "false"}:
181
- return _strip_control_prefix(lines[1])
182
-
183
- if len(lines) >= 2 and lines[0].lower() in {
184
- "hint", "next hint", "another hint", "continue", "go on",
185
- "walkthrough", "step by step", "solve", "explain", "method"
186
- }:
187
- return _strip_control_prefix(lines[1])
188
-
189
  for line in lines:
190
  candidate = _strip_control_prefix(line)
191
- if not candidate:
192
- continue
193
- if (
194
- "=" in candidate
195
- or "%" in candidate
196
- or re.search(r"\b(probability|ratio|percent|integer|triangle|circle|mean|median)\b", candidate.lower())
197
- or re.search(r"[a-zA-Z]\s*[\+\-\*/=]", candidate)
198
- or re.search(r"[\+\-\*/=]\s*[a-zA-Z0-9]", candidate)
199
- ):
200
  return candidate
201
-
202
- return _strip_control_prefix(lines[0] if lines else raw)
203
-
204
-
205
- def _extract_question_text(raw_user_text: str) -> str:
206
- text = _clean_text(raw_user_text)
207
- return _strip_control_prefix(text)
208
 
209
 
210
  def _looks_like_question_text(text: str) -> bool:
211
  t = (text or "").strip()
212
  if not t:
213
  return False
214
-
215
  low = t.lower()
216
-
217
- if "=" in t:
218
- return True
219
- if "%" in t:
220
- return True
221
- if re.search(r"\b\d+\s*:\s*\d+\b", t):
222
- return True
223
- if re.search(r"[a-zA-Z]\s*[\+\-\*/=]", t):
224
- return True
225
- if re.search(r"[\+\-\*/=]\s*[a-zA-Z0-9]", t):
226
- return True
227
- if any(k in low for k in [
228
- "what is", "find", "if ", "how many", "probability", "ratio",
229
- "percent", "equation", "integer", "triangle", "circle",
230
- "mean", "median", "average", "remainder", "prime", "factor",
231
- ]):
232
- return True
233
-
234
- return False
235
 
236
 
237
  def _classify_input_type(raw_user_text: str) -> str:
238
- text = _clean_text(raw_user_text)
239
  if not text:
240
  return "empty"
241
-
242
- lines = [line.strip() for line in text.splitlines() if line.strip()]
243
- first = lines[0].lower() if lines else text.lower().strip()
244
- t = text.lower().strip()
245
-
246
- structured_map = {
247
- "solve": "solve",
248
- "hint": "hint",
249
- "next hint": "next_hint",
250
- "another hint": "next_hint",
251
- "walkthrough": "confusion",
252
- "step by step": "confusion",
253
- "continue": "next_hint",
254
- "go on": "next_hint",
255
- }
256
- if first in structured_map:
257
- return structured_map[first]
258
-
259
- if t.startswith("hint:"):
260
  return "hint"
261
- if t.startswith("next hint:") or t.startswith("another hint:"):
262
  return "next_hint"
263
- if t.startswith("walkthrough:") or t.startswith("step by step:"):
264
  return "confusion"
265
- if t.startswith("solve:") or t.startswith("solve "):
266
  return "solve"
267
-
268
- if t in {"hint", "a hint", "give me a hint", "can i have a hint"}:
269
- return "hint"
270
-
271
- if t in {"next hint", "another hint", "more hint", "more hints", "next step", "continue", "go on"}:
272
- return "next_hint"
273
-
274
- if any(x in t for x in [
275
- "i don't get it",
276
- "i dont get it",
277
- "i do not get it",
278
- "i'm confused",
279
- "im confused",
280
- "confused",
281
- "explain more",
282
- "more explanation",
283
- "can you explain that",
284
- "help me understand",
285
- "walk me through it",
286
- "walkthrough",
287
- "step by step",
288
- ]):
289
- return "confusion"
290
-
291
- stripped = _strip_control_prefix(text)
292
-
293
- if "=" in stripped and re.search(r"[A-Za-z]", stripped):
294
  return "question"
295
-
296
- if any(k in stripped.lower() for k in [
297
- "what is", "find", "if ", "how many", "probability", "ratio",
298
- "percent", "equation", "integer"
299
- ]):
300
- return "question"
301
-
302
  return "other"
303
 
304
 
@@ -307,135 +168,74 @@ def _is_followup_input(input_type: str) -> bool:
307
 
308
 
309
  def _history_hint_stage(chat_history: Optional[List[Dict[str, Any]]]) -> int:
310
- if not chat_history:
311
- return 0
312
-
313
  best = 0
314
- for item in chat_history:
315
  if not isinstance(item, dict):
316
  continue
317
-
318
- if "hint_stage" in item:
319
- try:
320
- best = max(best, int(item["hint_stage"]))
321
- except Exception:
322
- pass
323
-
324
  meta = item.get("meta")
325
- if isinstance(meta, dict) and "hint_stage" in meta:
326
  try:
327
- best = max(best, int(meta["hint_stage"]))
328
  except Exception:
329
  pass
330
-
331
- if isinstance(meta, dict):
332
  nested_state = meta.get("session_state")
333
- if isinstance(nested_state, dict) and "hint_stage" in nested_state:
334
  try:
335
- best = max(best, int(nested_state["hint_stage"]))
336
  except Exception:
337
  pass
338
-
339
  return min(best, 3)
340
 
341
 
342
- def _recover_question_text(
343
- raw_user_text: str,
344
- question_text: Optional[str],
345
- chat_history: Optional[List[Dict[str, Any]]],
346
- input_type: str,
347
- ) -> str:
348
  explicit = _sanitize_question_text(question_text or "")
349
  if explicit:
350
  return explicit
351
-
352
- direct_candidate = _sanitize_question_text(_extract_question_text(raw_user_text))
353
  if direct_candidate and _looks_like_question_text(direct_candidate):
354
  return direct_candidate
355
-
356
  if not _is_followup_input(input_type):
357
  return direct_candidate
358
-
359
- if not chat_history:
360
- return ""
361
-
362
- for item in reversed(chat_history):
363
- candidates = _extract_question_candidates_from_history_item(item)
364
- for candidate in candidates:
365
  recovered = _sanitize_question_text(candidate)
366
- if not recovered:
367
- continue
368
- if _is_followup_hint_only(recovered):
369
- continue
370
- if _looks_like_question_text(recovered):
371
  return recovered
372
-
373
- extracted = _sanitize_question_text(_extract_question_text(recovered))
374
- if extracted and not _is_followup_hint_only(extracted) and _looks_like_question_text(extracted):
375
- return extracted
376
-
377
  return ""
378
 
379
 
380
- def _choose_effective_question_text(
381
- raw_user_text: str,
382
- question_text: Optional[str],
383
- input_type: str,
384
- state: Dict[str, Any],
385
- chat_history: Optional[List[Dict[str, Any]]],
386
- ) -> Tuple[str, bool]:
387
  explicit_question = _sanitize_question_text(question_text or "")
388
  stored_question = _sanitize_question_text(state.get("question_text", ""))
389
-
390
  if _is_followup_input(input_type):
391
  if explicit_question and _looks_like_question_text(explicit_question):
392
  return explicit_question, False
393
-
394
- direct_candidate = _sanitize_question_text(_extract_question_text(raw_user_text))
395
  if direct_candidate and _looks_like_question_text(direct_candidate):
396
  return direct_candidate, False
397
-
398
  if stored_question and _looks_like_question_text(stored_question):
399
  return stored_question, True
400
-
401
- recovered = _recover_question_text(
402
- raw_user_text=raw_user_text,
403
- question_text=question_text,
404
- chat_history=chat_history,
405
- input_type=input_type,
406
- )
407
- if recovered:
408
- return recovered, True
409
-
410
- return "", True
411
-
412
  if explicit_question:
413
  return explicit_question, False
 
414
 
415
- return _sanitize_question_text(_extract_question_text(raw_user_text)), False
416
-
417
-
418
- def _compute_hint_stage(
419
- input_type: str,
420
- prior_hint_stage: int,
421
- fallback_history_stage: int = 0,
422
- ) -> int:
423
- prior = int(prior_hint_stage or 0)
424
- history = int(fallback_history_stage or 0)
425
- base = max(prior, history)
426
 
 
 
427
  if input_type in {"solve", "question"}:
428
  return 0
429
-
430
  if input_type == "hint":
431
  return min(max(1, base if base > 0 else 1), 3)
432
-
433
  if input_type == "next_hint":
434
  return min((base if base > 0 else 1) + 1, 3)
435
-
436
  if input_type == "confusion":
437
  return 3
438
-
439
  return min(base, 3)
440
 
441
 
@@ -443,6 +243,7 @@ def _update_session_state(
443
  state: Dict[str, Any],
444
  *,
445
  question_text: str,
 
446
  hint_stage: int,
447
  user_last_input_type: str,
448
  built_on_previous_turn: bool,
@@ -453,6 +254,8 @@ def _update_session_state(
453
  ) -> Dict[str, Any]:
454
  if question_text:
455
  state["question_text"] = question_text
 
 
456
  state["hint_stage"] = int(hint_stage or 0)
457
  state["user_last_input_type"] = user_last_input_type
458
  state["built_on_previous_turn"] = bool(built_on_previous_turn)
@@ -467,7 +270,6 @@ def _normalize_classified_topic(topic: Optional[str], category: Optional[str], q
467
  t = (topic or "").strip().lower()
468
  q = (question_text or "").lower()
469
  c = normalize_category(category)
470
-
471
  has_ratio_form = bool(re.search(r"\b\d+\s*:\s*\d+\b", q))
472
  has_algebra_form = (
473
  "=" in q
@@ -475,38 +277,28 @@ def _normalize_classified_topic(topic: Optional[str], category: Optional[str], q
475
  or bool(re.search(r"\d+[a-z]\b", q))
476
  or bool(re.search(r"\b[a-z]\s*[\+\-\*/=]", q))
477
  )
478
-
479
  if has_algebra_form:
480
  return "algebra"
481
-
482
- if t == "ratio" and not has_ratio_form and has_algebra_form:
483
- t = "algebra"
484
-
485
  if t not in {"general_quant", "general", "unknown", ""}:
486
  return t
487
-
488
  if "%" in q or "percent" in q:
489
  return "percent"
490
  if "ratio" in q or has_ratio_form:
491
  return "ratio"
492
- if "probability" in q or "chosen at random" in q or "odds" in q or "chance" in q:
493
  return "probability"
494
- if "divisible" in q or "remainder" in q or "prime" in q or "factor" in q:
495
  return "number_theory"
496
  if any(k in q for k in ["circle", "triangle", "perimeter", "area", "circumference"]):
497
  return "geometry"
498
- if any(k in q for k in ["mean", "median", "average", "sales", "revenue"]):
499
  return "statistics" if c == "Quantitative" else "data"
500
- if has_algebra_form or "what is x" in q or "what is y" in q or "integer" in q:
501
- return "algebra"
502
-
503
  if c == "DataInsight":
504
  return "data"
505
  if c == "Verbal":
506
  return "verbal"
507
  if c == "Quantitative":
508
  return "quant"
509
-
510
  return "general"
511
 
512
 
@@ -515,7 +307,6 @@ def _strip_bullet_prefix(text: str) -> str:
515
 
516
 
517
  def _safe_steps(steps: List[str]) -> List[str]:
518
- cleaned: List[str] = []
519
  banned_patterns = [
520
  r"\bthe answer is\b",
521
  r"\banswer:\b",
@@ -530,7 +321,7 @@ def _safe_steps(steps: List[str]) -> List[str]:
530
  r"\bresult is\b",
531
  r"\bfinal answer\b",
532
  ]
533
-
534
  for step in steps:
535
  s = _strip_bullet_prefix(step)
536
  lowered = s.lower()
@@ -538,15 +329,13 @@ def _safe_steps(steps: List[str]) -> List[str]:
538
  continue
539
  if s:
540
  cleaned.append(s)
541
-
542
  deduped: List[str] = []
543
  seen = set()
544
  for step in cleaned:
545
- key = step.strip().lower()
546
  if key and key not in seen:
547
  seen.add(key)
548
- deduped.append(step.strip())
549
-
550
  return deduped
551
 
552
 
@@ -572,10 +361,8 @@ def _safe_meta_text(value: Any) -> Optional[str]:
572
 
573
  def _extract_explainer_scaffold(explainer_result: Any) -> Dict[str, Any]:
574
  scaffold = getattr(explainer_result, "scaffold", None)
575
-
576
  if scaffold is None:
577
  return {}
578
-
579
  return {
580
  "concept": _safe_meta_text(getattr(scaffold, "concept", None)),
581
  "ask": _safe_meta_text(getattr(scaffold, "ask", None)),
@@ -599,199 +386,69 @@ def _get_result_steps(result: SolverResult) -> List[str]:
599
  display_steps = getattr(result, "display_steps", None)
600
  if isinstance(display_steps, list) and display_steps:
601
  return _safe_steps(display_steps)
602
-
603
  result_steps = getattr(result, "steps", None)
604
  if isinstance(result_steps, list) and result_steps:
605
  return _safe_steps(result_steps)
606
-
607
  meta = getattr(result, "meta", {}) or {}
608
  meta_display_steps = meta.get("display_steps")
609
  if isinstance(meta_display_steps, list) and meta_display_steps:
610
  return _safe_steps(meta_display_steps)
611
-
612
  meta_steps = meta.get("steps")
613
  if isinstance(meta_steps, list) and meta_steps:
614
  return _safe_steps(meta_steps)
615
-
616
  return []
617
 
618
 
619
  def _apply_safe_step_sanitization(result: SolverResult) -> None:
620
  safe_steps = _get_result_steps(result)
621
-
622
  result.steps = list(safe_steps)
623
  setattr(result, "display_steps", list(safe_steps))
624
-
625
  result.meta = result.meta or {}
626
  result.meta["steps"] = list(safe_steps)
627
  result.meta["display_steps"] = list(safe_steps)
628
 
629
 
630
  def _solver_has_useful_steps(result: Optional[SolverResult]) -> bool:
631
- if result is None:
632
- return False
633
- return len(_get_result_steps(result)) > 0
634
-
635
-
636
- def _build_hint_from_steps(
637
- steps: List[str],
638
- hint_stage: int,
639
- fallback: Optional[List[str]] = None,
640
- ) -> str:
641
- safe_steps = _safe_steps(steps)
642
- stage = max(1, min(int(hint_stage or 1), 3))
643
-
644
- if safe_steps:
645
- index = min(stage - 1, len(safe_steps) - 1)
646
- return f"- {safe_steps[index]}"
647
-
648
- fallback = _safe_steps(fallback or [])
649
- if fallback:
650
- index = min(stage - 1, len(fallback) - 1)
651
- return f"- {fallback[index]}"
652
-
653
- return "- Start by identifying the main relationship in the problem."
654
-
655
-
656
- def _walkthrough_from_steps(steps: List[str], verbosity: float) -> str:
657
- safe_steps = _safe_steps(steps)
658
- if not safe_steps:
659
- return ""
660
-
661
- shown_steps = (
662
- safe_steps[:2] if verbosity < 0.25 else
663
- safe_steps[:3] if verbosity < 0.6 else
664
- safe_steps[:4] if verbosity < 0.85 else
665
- safe_steps
666
- )
667
- return "\n".join(f"- {step}" for step in shown_steps)
668
 
669
 
670
  def _answer_path_from_steps(steps: List[str], verbosity: float) -> str:
671
  safe_steps = _safe_steps(steps)
672
  if not safe_steps:
673
  return ""
674
-
675
- shown_steps = (
676
- safe_steps[:2] if verbosity < 0.35 else
677
- safe_steps[:3] if verbosity < 0.8 else
678
- safe_steps
679
- )
680
  return "\n".join(f"- {step}" for step in shown_steps)
681
 
682
 
683
- def _compose_reply(
684
- result: SolverResult,
685
- intent: str,
 
 
 
 
 
 
686
  verbosity: float,
687
- category: Optional[str] = None,
688
- hint_stage: int = 0,
689
- explainer_scaffold: Optional[Dict[str, Any]] = None,
690
- ) -> str:
691
- steps = _get_result_steps(result)
692
- topic = (result.topic or "").lower()
693
- scaffold = explainer_scaffold or {}
694
- scaffold_hint_ladder = _safe_meta_list(scaffold.get("hint_ladder", []))
695
-
696
- if intent == "hint":
697
- return _build_hint_from_steps(steps, hint_stage=hint_stage, fallback=scaffold_hint_ladder)
698
-
699
- if intent == "instruction":
700
- if steps:
701
- return f"- {steps[0]}"
702
- return "- First, identify the key relationship or comparison in the question."
703
-
704
- if intent == "definition":
705
- if steps:
706
- return f"- {steps[0]}"
707
- return "- This is asking for the meaning of the term or idea in the question."
708
-
709
- if intent in {"walkthrough", "step_by_step"}:
710
- step_reply = _walkthrough_from_steps(steps, verbosity=verbosity)
711
- if step_reply:
712
- return step_reply
713
-
714
- if topic == "algebra":
715
- return "\n".join([
716
- "- Start with the equation exactly as written.",
717
- "- Identify the operation attached to the variable.",
718
- "- Apply the inverse operation to both sides.",
719
- "- Continue until the variable is isolated.",
720
- ])
721
-
722
- if topic == "percent":
723
- return "\n".join([
724
- "- Identify the base quantity and the percent relationship.",
725
- "- Rewrite the percent as a fraction or decimal.",
726
- "- Set up the equation before simplifying.",
727
- "- Isolate the requested quantity carefully.",
728
- ])
729
-
730
- if topic == "ratio":
731
- return "\n".join([
732
- "- Keep the ratio order consistent.",
733
- "- Write each part using the same multiplier.",
734
- "- Use the total or known part to solve for that multiplier.",
735
- "- Substitute back to get the requested quantity.",
736
- ])
737
-
738
- if topic == "probability":
739
- return "\n".join([
740
- "- Count the successful outcomes.",
741
- "- Count all possible outcomes.",
742
- "- Write probability as successful over total.",
743
- "- Simplify only at the end if needed.",
744
- ])
745
-
746
- return "\n".join([
747
- "- Start with the structure of the problem.",
748
- "- Identify the relationship being tested.",
749
- "- Set up the needed equation or comparison.",
750
- "- Work forward one step at a time.",
751
- ])
752
-
753
- if intent in {"explain", "method", "concept", "answer"}:
754
- step_reply = _answer_path_from_steps(steps, verbosity=verbosity)
755
- if step_reply:
756
- return step_reply
757
-
758
- if topic == "algebra":
759
- return "\n".join([
760
- "- Identify the operation attached to the variable.",
761
- "- Use the inverse operation on both sides.",
762
- ])
763
-
764
- if topic == "percent":
765
- return "\n".join([
766
- "- Identify the base quantity.",
767
- "- Set up the percent relationship before calculating.",
768
- ])
769
-
770
- if topic == "ratio":
771
- return "\n".join([
772
- "- Keep the ratio order consistent.",
773
- "- Express each part using the same multiplier.",
774
- ])
775
-
776
- if topic == "probability":
777
- return "\n".join([
778
- "- Count the successful outcomes.",
779
- "- Count all possible outcomes.",
780
- ])
781
-
782
- if normalize_category(category) == "Verbal":
783
- return "I can help analyse the wording or logic, but I need the full question text to guide you properly."
784
-
785
- if normalize_category(category) == "DataInsight":
786
- return "I can help reason through the data, but I need the full question or chart details to guide you properly."
787
-
788
- return "Start by identifying the main relationship in the problem."
789
 
790
 
791
  def _is_direct_solve_request(text: str, intent: str) -> bool:
792
  if intent == "answer":
793
  return True
794
-
795
  t = re.sub(r"\s+", " ", (text or "").strip().lower())
796
  if any(re.search(p, t) for p in DIRECT_SOLVE_PATTERNS):
797
  if not any(word in t for word in ["how", "explain", "why", "method", "hint", "define", "definition", "step"]):
@@ -800,12 +457,7 @@ def _is_direct_solve_request(text: str, intent: str) -> bool:
800
 
801
 
802
  class ConversationEngine:
803
- def __init__(
804
- self,
805
- retriever: Optional[RetrievalEngine] = None,
806
- generator: Optional[GeneratorEngine] = None,
807
- **kwargs,
808
- ) -> None:
809
  self.retriever = retriever
810
  self.generator = generator
811
 
@@ -821,13 +473,12 @@ class ConversationEngine:
821
  chat_history: Optional[List[Dict[str, Any]]] = None,
822
  question_text: Optional[str] = None,
823
  options_text: Optional[List[str]] = None,
 
824
  session_state: Optional[Dict[str, Any]] = None,
825
  **kwargs,
826
  ) -> SolverResult:
827
-
828
  user_text = _clean_text(raw_user_text)
829
  state = _safe_get_state(session_state)
830
-
831
  input_type = _classify_input_type(user_text)
832
 
833
  effective_question_text, built_on_previous_turn = _choose_effective_question_text(
@@ -837,24 +488,18 @@ class ConversationEngine:
837
  state=state,
838
  chat_history=chat_history,
839
  )
840
-
841
  if _is_followup_input(input_type):
842
  built_on_previous_turn = True
843
-
844
  solver_input = _sanitize_question_text(effective_question_text)
 
845
 
846
  category = normalize_category(kwargs.get("category"))
847
  classification = classify_question(question_text=solver_input, category=category)
848
  inferred_category = normalize_category(classification.get("category") or category)
849
-
850
- question_topic = _normalize_classified_topic(
851
- classification.get("topic"),
852
- inferred_category,
853
- solver_input,
854
- )
855
 
856
  resolved_intent = intent or detect_intent(user_text, help_mode)
857
-
858
  if input_type in {"hint", "next_hint"}:
859
  resolved_intent = "hint"
860
  elif input_type == "confusion":
@@ -862,11 +507,7 @@ class ConversationEngine:
862
  elif input_type in {"solve", "question"} and resolved_intent in {"hint", "walkthrough", "step_by_step"}:
863
  resolved_intent = "answer"
864
 
865
- if help_mode:
866
- resolved_help_mode = help_mode
867
- else:
868
- resolved_help_mode = intent_to_help_mode(resolved_intent)
869
-
870
  if input_type in {"hint", "next_hint"}:
871
  resolved_help_mode = "hint"
872
  elif input_type == "confusion":
@@ -874,27 +515,17 @@ class ConversationEngine:
874
 
875
  prior_hint_stage = int(state.get("hint_stage", 0) or 0)
876
  history_hint_stage = _history_hint_stage(chat_history)
877
-
878
- hint_stage = _compute_hint_stage(
879
- input_type=input_type,
880
- prior_hint_stage=prior_hint_stage,
881
- fallback_history_stage=history_hint_stage,
882
- )
883
 
884
- is_quant = bool(solver_input) and (
885
- inferred_category == "Quantitative" or is_quant_question(solver_input)
886
- )
887
 
888
  result = SolverResult(
889
  domain="quant" if is_quant else "general",
890
  solved=False,
891
  help_mode=resolved_help_mode,
892
- answer_letter=None,
893
- answer_value=None,
894
  topic=question_topic if is_quant else "general",
895
  used_retrieval=False,
896
  used_generator=False,
897
- internal_answer=None,
898
  steps=[],
899
  teaching_chunks=[],
900
  meta={},
@@ -902,48 +533,53 @@ class ConversationEngine:
902
 
903
  solver_result: Optional[SolverResult] = None
904
  if is_quant and resolved_help_mode in {"answer", "walkthrough", "instruction", "hint"}:
905
- solver_result = route_solver(solver_input)
 
 
 
906
  if solver_result is not None:
907
- original_meta = result.meta or {}
908
  result = solver_result
909
  result.domain = "quant"
910
  result.meta = result.meta or {}
911
- result.meta.update(original_meta)
912
-
913
  if not result.topic or result.topic in {"general_quant", "general", "unknown"}:
914
  result.topic = question_topic
915
 
916
  _apply_safe_step_sanitization(result)
 
917
 
918
- explainer_result = route_explainer(solver_input) if solver_input else None
919
- explainer_understood = bool(
920
- explainer_result is not None and getattr(explainer_result, "understood", False)
921
- )
922
- explainer_meta = (getattr(explainer_result, "meta", {}) or {}) if explainer_understood else {}
923
- explainer_scaffold = _extract_explainer_scaffold(explainer_result) if explainer_understood else {}
924
- explainer_summary = getattr(explainer_result, "summary", None) if explainer_understood else None
925
- explainer_teaching_points = (
926
- _safe_meta_list(getattr(explainer_result, "teaching_points", []))
927
- if explainer_understood else []
928
- )
929
 
930
  result.help_mode = resolved_help_mode
931
-
932
  result.meta = result.meta or {}
933
  result.meta["hint_stage"] = hint_stage
934
  result.meta["max_stage"] = 3
935
  result.meta["recovered_question_text"] = solver_input
936
-
937
- if explainer_understood:
938
- result.meta["explainer_used"] = False
939
- result.meta["bridge_ready"] = bool(explainer_meta.get("bridge_ready", False))
940
- result.meta["hint_style"] = explainer_meta.get("hint_style")
941
- result.meta["explainer_summary"] = explainer_summary
942
- result.meta["explainer_teaching_points"] = explainer_teaching_points
943
- result.meta["scaffold"] = explainer_scaffold
944
-
945
- use_solver_steps = _solver_has_useful_steps(result)
946
- use_solver_steps_for_hint = resolved_help_mode == "hint" and use_solver_steps
 
 
 
 
 
947
 
948
  if resolved_help_mode == "explain" and explainer_understood:
949
  reply = format_explainer_response(
@@ -955,8 +591,7 @@ class ConversationEngine:
955
  hint_stage=hint_stage,
956
  )
957
  result.meta["explainer_used"] = True
958
-
959
- elif resolved_help_mode == "hint" and explainer_understood and not use_solver_steps_for_hint:
960
  reply = format_explainer_response(
961
  result=explainer_result,
962
  tone=tone,
@@ -966,17 +601,25 @@ class ConversationEngine:
966
  hint_stage=hint_stage,
967
  )
968
  result.meta["explainer_used"] = True
969
-
970
  else:
971
- reply_core = _compose_reply(
972
- result=result,
973
- intent=resolved_intent,
974
- verbosity=verbosity,
975
- category=inferred_category,
976
- hint_stage=hint_stage,
977
- explainer_scaffold=explainer_scaffold,
978
- )
979
-
 
 
 
 
 
 
 
 
 
980
  reply = format_reply(
981
  reply_core,
982
  tone=tone,
@@ -986,8 +629,7 @@ class ConversationEngine:
986
  hint_stage=hint_stage,
987
  topic=result.topic,
988
  )
989
-
990
- # Force non-answer modes to stay unsolved
991
  if resolved_help_mode in {"hint", "walkthrough", "explain", "instruction"}:
992
  result.solved = False
993
  result.answer_letter = None
@@ -996,20 +638,18 @@ class ConversationEngine:
996
  result.meta["internal_answer"] = None
997
 
998
  result.meta["can_reveal_answer"] = bool(
999
- result.solved
1000
- and _is_direct_solve_request(user_text or solver_input, resolved_intent)
1001
- and hint_stage >= 3
1002
  )
1003
-
1004
  if not result.meta.get("can_reveal_answer", False):
1005
  result.answer_letter = None
1006
  result.answer_value = None
1007
  result.internal_answer = None
1008
  result.meta["internal_answer"] = None
1009
-
1010
  state = _update_session_state(
1011
  state,
1012
  question_text=solver_input,
 
1013
  hint_stage=hint_stage,
1014
  user_last_input_type=input_type,
1015
  built_on_previous_turn=built_on_previous_turn,
@@ -1018,7 +658,7 @@ class ConversationEngine:
1018
  topic=result.topic,
1019
  category=inferred_category,
1020
  )
1021
-
1022
  result.reply = reply
1023
  result.help_mode = resolved_help_mode
1024
  result.meta["help_mode"] = resolved_help_mode
@@ -1030,5 +670,4 @@ class ConversationEngine:
1030
  result.meta["user_last_input_type"] = input_type
1031
  result.meta["built_on_previous_turn"] = built_on_previous_turn
1032
  result.meta["session_state"] = state
1033
-
1034
- return result
 
4
  from typing import Any, Dict, List, Optional, Tuple
5
 
6
  from context_parser import detect_intent, intent_to_help_mode
7
+ from formatting import format_explainer_response, format_reply
8
  from generator_engine import GeneratorEngine
9
  from models import RetrievedChunk, SolverResult
10
  from quant_solver import is_quant_question
 
 
11
  from question_classifier import classify_question, normalize_category
12
+ from question_fallback_router import question_fallback_router
13
  from retrieval_engine import RetrievalEngine
14
+ from solver_router import route_solver
15
+ from explainers.explainer_router import route_explainer
16
 
17
  DIRECT_SOLVE_PATTERNS = [
18
  r"\bsolve\b",
 
54
 
55
 
56
  def _safe_get_state(session_state: Optional[Dict[str, Any]]) -> Dict[str, Any]:
57
+ return dict(session_state) if isinstance(session_state, dict) else {}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
 
59
 
60
  def _extract_question_candidates_from_history_item(item: Dict[str, Any]) -> List[str]:
61
  if not isinstance(item, dict):
62
  return []
 
63
  candidates: List[str] = []
64
+ for key in ("question_text", "raw_user_text", "content", "text", "message"):
 
 
65
  value = item.get(key)
66
  if isinstance(value, str) and value.strip():
67
  candidates.append(value.strip())
 
68
  meta = item.get("meta")
69
  if isinstance(meta, dict):
70
  for key in ("question_text", "recovered_question_text"):
71
  value = meta.get(key)
72
  if isinstance(value, str) and value.strip():
73
  candidates.append(value.strip())
 
74
  nested_state = meta.get("session_state")
75
  if isinstance(nested_state, dict):
76
  value = nested_state.get("question_text")
77
  if isinstance(value, str) and value.strip():
78
  candidates.append(value.strip())
79
+ return candidates
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
 
81
 
82
  def _is_followup_hint_only(text: str) -> bool:
 
106
  cleaned = (text or "").strip()
107
  if not cleaned:
108
  return ""
 
109
  previous = None
110
  while previous != cleaned:
111
  previous = cleaned
112
  for pattern in CONTROL_PREFIX_PATTERNS:
113
  cleaned = re.sub(pattern, "", cleaned, flags=re.I).strip()
 
114
  return cleaned
115
 
116
 
 
118
  raw = (text or "").strip()
119
  if not raw:
120
  return ""
 
121
  lines = [line.strip() for line in raw.splitlines() if line.strip()]
 
 
 
 
 
 
 
 
 
 
122
  for line in lines:
123
  candidate = _strip_control_prefix(line)
124
+ if candidate and not _is_followup_hint_only(candidate):
 
 
 
 
 
 
 
 
125
  return candidate
126
+ return _strip_control_prefix(raw)
 
 
 
 
 
 
127
 
128
 
129
  def _looks_like_question_text(text: str) -> bool:
130
  t = (text or "").strip()
131
  if not t:
132
  return False
 
133
  low = t.lower()
134
+ return any(
135
+ [
136
+ "=" in t,
137
+ "%" in t,
138
+ bool(re.search(r"\b\d+\s*:\s*\d+\b", t)),
139
+ bool(re.search(r"[a-zA-Z]\s*[\+\-\*/=]", t)),
140
+ any(k in low for k in [
141
+ "what is", "find", "if ", "how many", "probability", "ratio",
142
+ "percent", "equation", "integer", "triangle", "circle",
143
+ "mean", "median", "average", "remainder", "prime", "factor",
144
+ ]),
145
+ ]
146
+ )
 
 
 
 
 
 
147
 
148
 
149
  def _classify_input_type(raw_user_text: str) -> str:
150
+ text = _clean_text(raw_user_text).lower()
151
  if not text:
152
  return "empty"
153
+ if text in {"hint", "a hint", "give me a hint", "can i have a hint"} or text.startswith("hint:"):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
154
  return "hint"
155
+ if text in {"next hint", "another hint", "more hint", "more hints", "next step", "continue", "go on"} or text.startswith("next hint:"):
156
  return "next_hint"
157
+ if any(x in text for x in ["walkthrough", "step by step", "i'm confused", "im confused", "confused", "explain more", "help me understand"]):
158
  return "confusion"
159
+ if text.startswith("solve:") or text.startswith("solve "):
160
  return "solve"
161
+ if _looks_like_question_text(_strip_control_prefix(raw_user_text)):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
162
  return "question"
 
 
 
 
 
 
 
163
  return "other"
164
 
165
 
 
168
 
169
 
170
  def _history_hint_stage(chat_history: Optional[List[Dict[str, Any]]]) -> int:
 
 
 
171
  best = 0
172
+ for item in chat_history or []:
173
  if not isinstance(item, dict):
174
  continue
175
+ try:
176
+ best = max(best, int(item.get("hint_stage", 0) or 0))
177
+ except Exception:
178
+ pass
 
 
 
179
  meta = item.get("meta")
180
+ if isinstance(meta, dict):
181
  try:
182
+ best = max(best, int(meta.get("hint_stage", 0) or 0))
183
  except Exception:
184
  pass
 
 
185
  nested_state = meta.get("session_state")
186
+ if isinstance(nested_state, dict):
187
  try:
188
+ best = max(best, int(nested_state.get("hint_stage", 0) or 0))
189
  except Exception:
190
  pass
 
191
  return min(best, 3)
192
 
193
 
194
+ def _recover_question_text(raw_user_text: str, question_text: Optional[str], chat_history: Optional[List[Dict[str, Any]]], input_type: str) -> str:
 
 
 
 
 
195
  explicit = _sanitize_question_text(question_text or "")
196
  if explicit:
197
  return explicit
198
+ direct_candidate = _sanitize_question_text(raw_user_text)
 
199
  if direct_candidate and _looks_like_question_text(direct_candidate):
200
  return direct_candidate
 
201
  if not _is_followup_input(input_type):
202
  return direct_candidate
203
+ for item in reversed(chat_history or []):
204
+ for candidate in _extract_question_candidates_from_history_item(item):
 
 
 
 
 
205
  recovered = _sanitize_question_text(candidate)
206
+ if recovered and not _is_followup_hint_only(recovered) and _looks_like_question_text(recovered):
 
 
 
 
207
  return recovered
 
 
 
 
 
208
  return ""
209
 
210
 
211
+ def _choose_effective_question_text(raw_user_text: str, question_text: Optional[str], input_type: str, state: Dict[str, Any], chat_history: Optional[List[Dict[str, Any]]]) -> Tuple[str, bool]:
 
 
 
 
 
 
212
  explicit_question = _sanitize_question_text(question_text or "")
213
  stored_question = _sanitize_question_text(state.get("question_text", ""))
 
214
  if _is_followup_input(input_type):
215
  if explicit_question and _looks_like_question_text(explicit_question):
216
  return explicit_question, False
217
+ direct_candidate = _sanitize_question_text(raw_user_text)
 
218
  if direct_candidate and _looks_like_question_text(direct_candidate):
219
  return direct_candidate, False
 
220
  if stored_question and _looks_like_question_text(stored_question):
221
  return stored_question, True
222
+ recovered = _recover_question_text(raw_user_text, question_text, chat_history, input_type)
223
+ return recovered, True
 
 
 
 
 
 
 
 
 
 
224
  if explicit_question:
225
  return explicit_question, False
226
+ return _sanitize_question_text(raw_user_text), False
227
 
 
 
 
 
 
 
 
 
 
 
 
228
 
229
+ def _compute_hint_stage(input_type: str, prior_hint_stage: int, fallback_history_stage: int = 0) -> int:
230
+ base = max(int(prior_hint_stage or 0), int(fallback_history_stage or 0))
231
  if input_type in {"solve", "question"}:
232
  return 0
 
233
  if input_type == "hint":
234
  return min(max(1, base if base > 0 else 1), 3)
 
235
  if input_type == "next_hint":
236
  return min((base if base > 0 else 1) + 1, 3)
 
237
  if input_type == "confusion":
238
  return 3
 
239
  return min(base, 3)
240
 
241
 
 
243
  state: Dict[str, Any],
244
  *,
245
  question_text: str,
246
+ question_id: Optional[str],
247
  hint_stage: int,
248
  user_last_input_type: str,
249
  built_on_previous_turn: bool,
 
254
  ) -> Dict[str, Any]:
255
  if question_text:
256
  state["question_text"] = question_text
257
+ if question_id:
258
+ state["question_id"] = question_id
259
  state["hint_stage"] = int(hint_stage or 0)
260
  state["user_last_input_type"] = user_last_input_type
261
  state["built_on_previous_turn"] = bool(built_on_previous_turn)
 
270
  t = (topic or "").strip().lower()
271
  q = (question_text or "").lower()
272
  c = normalize_category(category)
 
273
  has_ratio_form = bool(re.search(r"\b\d+\s*:\s*\d+\b", q))
274
  has_algebra_form = (
275
  "=" in q
 
277
  or bool(re.search(r"\d+[a-z]\b", q))
278
  or bool(re.search(r"\b[a-z]\s*[\+\-\*/=]", q))
279
  )
 
280
  if has_algebra_form:
281
  return "algebra"
 
 
 
 
282
  if t not in {"general_quant", "general", "unknown", ""}:
283
  return t
 
284
  if "%" in q or "percent" in q:
285
  return "percent"
286
  if "ratio" in q or has_ratio_form:
287
  return "ratio"
288
+ if any(k in q for k in ["probability", "chosen at random", "odds", "chance"]):
289
  return "probability"
290
+ if any(k in q for k in ["divisible", "remainder", "prime", "factor"]):
291
  return "number_theory"
292
  if any(k in q for k in ["circle", "triangle", "perimeter", "area", "circumference"]):
293
  return "geometry"
294
+ if any(k in q for k in ["mean", "median", "average"]):
295
  return "statistics" if c == "Quantitative" else "data"
 
 
 
296
  if c == "DataInsight":
297
  return "data"
298
  if c == "Verbal":
299
  return "verbal"
300
  if c == "Quantitative":
301
  return "quant"
 
302
  return "general"
303
 
304
 
 
307
 
308
 
309
  def _safe_steps(steps: List[str]) -> List[str]:
 
310
  banned_patterns = [
311
  r"\bthe answer is\b",
312
  r"\banswer:\b",
 
321
  r"\bresult is\b",
322
  r"\bfinal answer\b",
323
  ]
324
+ cleaned: List[str] = []
325
  for step in steps:
326
  s = _strip_bullet_prefix(step)
327
  lowered = s.lower()
 
329
  continue
330
  if s:
331
  cleaned.append(s)
 
332
  deduped: List[str] = []
333
  seen = set()
334
  for step in cleaned:
335
+ key = step.lower().strip()
336
  if key and key not in seen:
337
  seen.add(key)
338
+ deduped.append(step)
 
339
  return deduped
340
 
341
 
 
361
 
362
  def _extract_explainer_scaffold(explainer_result: Any) -> Dict[str, Any]:
363
  scaffold = getattr(explainer_result, "scaffold", None)
 
364
  if scaffold is None:
365
  return {}
 
366
  return {
367
  "concept": _safe_meta_text(getattr(scaffold, "concept", None)),
368
  "ask": _safe_meta_text(getattr(scaffold, "ask", None)),
 
386
  display_steps = getattr(result, "display_steps", None)
387
  if isinstance(display_steps, list) and display_steps:
388
  return _safe_steps(display_steps)
 
389
  result_steps = getattr(result, "steps", None)
390
  if isinstance(result_steps, list) and result_steps:
391
  return _safe_steps(result_steps)
 
392
  meta = getattr(result, "meta", {}) or {}
393
  meta_display_steps = meta.get("display_steps")
394
  if isinstance(meta_display_steps, list) and meta_display_steps:
395
  return _safe_steps(meta_display_steps)
 
396
  meta_steps = meta.get("steps")
397
  if isinstance(meta_steps, list) and meta_steps:
398
  return _safe_steps(meta_steps)
 
399
  return []
400
 
401
 
402
  def _apply_safe_step_sanitization(result: SolverResult) -> None:
403
  safe_steps = _get_result_steps(result)
 
404
  result.steps = list(safe_steps)
405
  setattr(result, "display_steps", list(safe_steps))
 
406
  result.meta = result.meta or {}
407
  result.meta["steps"] = list(safe_steps)
408
  result.meta["display_steps"] = list(safe_steps)
409
 
410
 
411
  def _solver_has_useful_steps(result: Optional[SolverResult]) -> bool:
412
+ return bool(result is not None and _get_result_steps(result))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
413
 
414
 
415
  def _answer_path_from_steps(steps: List[str], verbosity: float) -> str:
416
  safe_steps = _safe_steps(steps)
417
  if not safe_steps:
418
  return ""
419
+ shown_steps = safe_steps[:2] if verbosity < 0.35 else safe_steps[:3] if verbosity < 0.8 else safe_steps
 
 
 
 
 
420
  return "\n".join(f"- {step}" for step in shown_steps)
421
 
422
 
423
+ def _build_fallback_reply(
424
+ *,
425
+ question_id: Optional[str],
426
+ question_text: str,
427
+ options_text: Optional[List[str]],
428
+ topic: Optional[str],
429
+ category: Optional[str],
430
+ help_mode: str,
431
+ hint_stage: int,
432
  verbosity: float,
433
+ ) -> Tuple[str, Dict[str, Any]]:
434
+ payload = question_fallback_router.build_response(
435
+ question_id=question_id,
436
+ question_text=question_text,
437
+ options_text=options_text,
438
+ topic=topic,
439
+ category=category,
440
+ help_mode=help_mode,
441
+ hint_stage=hint_stage,
442
+ verbosity=verbosity,
443
+ )
444
+ lines = payload.get("lines") or ["Start by identifying the main relationship in the problem."]
445
+ pack = payload.get("pack") or {}
446
+ return "\n".join(f"- {line}" for line in lines if str(line).strip()), pack
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
447
 
448
 
449
  def _is_direct_solve_request(text: str, intent: str) -> bool:
450
  if intent == "answer":
451
  return True
 
452
  t = re.sub(r"\s+", " ", (text or "").strip().lower())
453
  if any(re.search(p, t) for p in DIRECT_SOLVE_PATTERNS):
454
  if not any(word in t for word in ["how", "explain", "why", "method", "hint", "define", "definition", "step"]):
 
457
 
458
 
459
  class ConversationEngine:
460
+ def __init__(self, retriever: Optional[RetrievalEngine] = None, generator: Optional[GeneratorEngine] = None, **kwargs) -> None:
 
 
 
 
 
461
  self.retriever = retriever
462
  self.generator = generator
463
 
 
473
  chat_history: Optional[List[Dict[str, Any]]] = None,
474
  question_text: Optional[str] = None,
475
  options_text: Optional[List[str]] = None,
476
+ question_id: Optional[str] = None,
477
  session_state: Optional[Dict[str, Any]] = None,
478
  **kwargs,
479
  ) -> SolverResult:
 
480
  user_text = _clean_text(raw_user_text)
481
  state = _safe_get_state(session_state)
 
482
  input_type = _classify_input_type(user_text)
483
 
484
  effective_question_text, built_on_previous_turn = _choose_effective_question_text(
 
488
  state=state,
489
  chat_history=chat_history,
490
  )
 
491
  if _is_followup_input(input_type):
492
  built_on_previous_turn = True
493
+
494
  solver_input = _sanitize_question_text(effective_question_text)
495
+ question_id = question_id or state.get("question_id")
496
 
497
  category = normalize_category(kwargs.get("category"))
498
  classification = classify_question(question_text=solver_input, category=category)
499
  inferred_category = normalize_category(classification.get("category") or category)
500
+ question_topic = _normalize_classified_topic(classification.get("topic"), inferred_category, solver_input)
 
 
 
 
 
501
 
502
  resolved_intent = intent or detect_intent(user_text, help_mode)
 
503
  if input_type in {"hint", "next_hint"}:
504
  resolved_intent = "hint"
505
  elif input_type == "confusion":
 
507
  elif input_type in {"solve", "question"} and resolved_intent in {"hint", "walkthrough", "step_by_step"}:
508
  resolved_intent = "answer"
509
 
510
+ resolved_help_mode = help_mode or intent_to_help_mode(resolved_intent)
 
 
 
 
511
  if input_type in {"hint", "next_hint"}:
512
  resolved_help_mode = "hint"
513
  elif input_type == "confusion":
 
515
 
516
  prior_hint_stage = int(state.get("hint_stage", 0) or 0)
517
  history_hint_stage = _history_hint_stage(chat_history)
518
+ hint_stage = _compute_hint_stage(input_type, prior_hint_stage, history_hint_stage)
 
 
 
 
 
519
 
520
+ is_quant = bool(solver_input) and (inferred_category == "Quantitative" or is_quant_question(solver_input))
 
 
521
 
522
  result = SolverResult(
523
  domain="quant" if is_quant else "general",
524
  solved=False,
525
  help_mode=resolved_help_mode,
 
 
526
  topic=question_topic if is_quant else "general",
527
  used_retrieval=False,
528
  used_generator=False,
 
529
  steps=[],
530
  teaching_chunks=[],
531
  meta={},
 
533
 
534
  solver_result: Optional[SolverResult] = None
535
  if is_quant and resolved_help_mode in {"answer", "walkthrough", "instruction", "hint"}:
536
+ try:
537
+ solver_result = route_solver(solver_input)
538
+ except Exception:
539
+ solver_result = None
540
  if solver_result is not None:
 
541
  result = solver_result
542
  result.domain = "quant"
543
  result.meta = result.meta or {}
 
 
544
  if not result.topic or result.topic in {"general_quant", "general", "unknown"}:
545
  result.topic = question_topic
546
 
547
  _apply_safe_step_sanitization(result)
548
+ solver_steps = _get_result_steps(result)
549
 
550
+ explainer_result = None
551
+ explainer_understood = False
552
+ explainer_scaffold: Dict[str, Any] = {}
553
+ if solver_input:
554
+ try:
555
+ explainer_result = route_explainer(solver_input)
556
+ except Exception:
557
+ explainer_result = None
558
+ if explainer_result is not None and getattr(explainer_result, "understood", False):
559
+ explainer_understood = True
560
+ explainer_scaffold = _extract_explainer_scaffold(explainer_result)
561
 
562
  result.help_mode = resolved_help_mode
 
563
  result.meta = result.meta or {}
564
  result.meta["hint_stage"] = hint_stage
565
  result.meta["max_stage"] = 3
566
  result.meta["recovered_question_text"] = solver_input
567
+ result.meta["question_id"] = question_id
568
+
569
+ use_solver_steps_for_reply = bool(solver_steps)
570
+ fallback_reply_core = ""
571
+ fallback_pack: Dict[str, Any] = {}
572
+ if solver_input:
573
+ fallback_reply_core, fallback_pack = _build_fallback_reply(
574
+ question_id=question_id,
575
+ question_text=solver_input,
576
+ options_text=options_text,
577
+ topic=result.topic or question_topic,
578
+ category=inferred_category,
579
+ help_mode=resolved_help_mode,
580
+ hint_stage=hint_stage,
581
+ verbosity=verbosity,
582
+ )
583
 
584
  if resolved_help_mode == "explain" and explainer_understood:
585
  reply = format_explainer_response(
 
591
  hint_stage=hint_stage,
592
  )
593
  result.meta["explainer_used"] = True
594
+ elif resolved_help_mode == "hint" and explainer_understood and not use_solver_steps_for_reply and not fallback_pack.get("support_source") == "question_bank":
 
595
  reply = format_explainer_response(
596
  result=explainer_result,
597
  tone=tone,
 
601
  hint_stage=hint_stage,
602
  )
603
  result.meta["explainer_used"] = True
 
604
  else:
605
+ if use_solver_steps_for_reply:
606
+ reply_core = _answer_path_from_steps(solver_steps, verbosity=verbosity)
607
+ result.meta["question_support_used"] = False
608
+ elif fallback_reply_core:
609
+ reply_core = fallback_reply_core
610
+ result.meta["question_support_used"] = True
611
+ result.meta["question_support_source"] = fallback_pack.get("support_source")
612
+ result.meta["question_support_topic"] = fallback_pack.get("topic")
613
+ elif inferred_category == "Verbal":
614
+ reply_core = "I can help analyse the wording or logic, but I need the full question text to guide you properly."
615
+ result.meta["question_support_used"] = False
616
+ elif inferred_category == "DataInsight":
617
+ reply_core = "I can help reason through the data, but I need the full question or chart details to guide you properly."
618
+ result.meta["question_support_used"] = False
619
+ else:
620
+ reply_core = "- Start by identifying the main relationship in the problem."
621
+ result.meta["question_support_used"] = False
622
+
623
  reply = format_reply(
624
  reply_core,
625
  tone=tone,
 
629
  hint_stage=hint_stage,
630
  topic=result.topic,
631
  )
632
+
 
633
  if resolved_help_mode in {"hint", "walkthrough", "explain", "instruction"}:
634
  result.solved = False
635
  result.answer_letter = None
 
638
  result.meta["internal_answer"] = None
639
 
640
  result.meta["can_reveal_answer"] = bool(
641
+ result.solved and _is_direct_solve_request(user_text or solver_input, resolved_intent) and hint_stage >= 3
 
 
642
  )
 
643
  if not result.meta.get("can_reveal_answer", False):
644
  result.answer_letter = None
645
  result.answer_value = None
646
  result.internal_answer = None
647
  result.meta["internal_answer"] = None
648
+
649
  state = _update_session_state(
650
  state,
651
  question_text=solver_input,
652
+ question_id=question_id,
653
  hint_stage=hint_stage,
654
  user_last_input_type=input_type,
655
  built_on_previous_turn=built_on_previous_turn,
 
658
  topic=result.topic,
659
  category=inferred_category,
660
  )
661
+
662
  result.reply = reply
663
  result.help_mode = resolved_help_mode
664
  result.meta["help_mode"] = resolved_help_mode
 
670
  result.meta["user_last_input_type"] = input_type
671
  result.meta["built_on_previous_turn"] = built_on_previous_turn
672
  result.meta["session_state"] = state
673
+ return result