j-js commited on
Commit
2036f25
·
verified ·
1 Parent(s): 4e8eb94

Update conversation_logic.py

Browse files
Files changed (1) hide show
  1. conversation_logic.py +150 -953
conversation_logic.py CHANGED
@@ -1,177 +1,16 @@
1
- from __future__ import annotations
2
-
3
- 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_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",
19
- r"\bwhat is\b",
20
- r"\bfind\b",
21
- r"\bgive (?:me )?the answer\b",
22
- r"\bjust the answer\b",
23
- r"\banswer only\b",
24
- r"\bcalculate\b",
25
- ]
26
-
27
- CONTROL_PREFIX_PATTERNS = [
28
- r"^\s*solve\s*:\s*",
29
- r"^\s*solve\s+",
30
- r"^\s*question\s*:\s*",
31
- r"^\s*q\s*:\s*",
32
- r"^\s*hint\s*:\s*",
33
- r"^\s*hint\s*$",
34
- r"^\s*next hint\s*:\s*",
35
- r"^\s*next hint\s*$",
36
- r"^\s*another hint\s*:\s*",
37
- r"^\s*another hint\s*$",
38
- r"^\s*walkthrough\s*:\s*",
39
- r"^\s*walkthrough\s*$",
40
- r"^\s*step by step\s*:\s*",
41
- r"^\s*step by step\s*$",
42
- r"^\s*explain\s*:\s*",
43
- r"^\s*explain\s*$",
44
- r"^\s*method\s*:\s*",
45
- r"^\s*method\s*$",
46
- r"^\s*continue\s*$",
47
- r"^\s*go on\s*$",
48
- r"^\s*next step\s*$",
49
- ]
50
-
51
- FOLLOWUP_ONLY_INPUTS = {
52
- "hint",
53
- "a hint",
54
- "give me a hint",
55
- "can i have a hint",
56
- "next hint",
57
- "another hint",
58
- "next step",
59
- "continue",
60
- "go on",
61
- "walk me through it",
62
- "step by step",
63
- "walkthrough",
64
- "i'm confused",
65
- "im confused",
66
- "confused",
67
- "explain more",
68
- "more explanation",
69
- "can you explain that",
70
- "help me understand",
71
- "help",
72
- }
73
-
74
-
75
- def _clean_text(text: Optional[str]) -> str:
76
- return (text or "").strip()
77
-
78
-
79
- def _safe_get_state(session_state: Optional[Dict[str, Any]]) -> Dict[str, Any]:
80
- return dict(session_state) if isinstance(session_state, dict) else {}
81
-
82
-
83
- def _extract_question_candidates_from_history_item(item: Dict[str, Any]) -> List[str]:
84
- if not isinstance(item, dict):
85
- return []
86
- candidates: List[str] = []
87
- for key in ("question_text", "raw_user_text", "content", "text", "message"):
88
- value = item.get(key)
89
- if isinstance(value, str) and value.strip():
90
- candidates.append(value.strip())
91
- meta = item.get("meta")
92
- if isinstance(meta, dict):
93
- for key in ("question_text", "recovered_question_text"):
94
- value = meta.get(key)
95
- if isinstance(value, str) and value.strip():
96
- candidates.append(value.strip())
97
- nested_state = meta.get("session_state")
98
- if isinstance(nested_state, dict):
99
- value = nested_state.get("question_text")
100
- if isinstance(value, str) and value.strip():
101
- candidates.append(value.strip())
102
- return candidates
103
-
104
-
105
- def _is_followup_hint_only(text: str) -> bool:
106
- low = (text or "").strip().lower()
107
- return low in FOLLOWUP_ONLY_INPUTS
108
-
109
-
110
- def _strip_control_prefix(text: str) -> str:
111
- cleaned = (text or "").strip()
112
- if not cleaned:
113
- return ""
114
- previous = None
115
- while previous != cleaned:
116
- previous = cleaned
117
- for pattern in CONTROL_PREFIX_PATTERNS:
118
- cleaned = re.sub(pattern, "", cleaned, flags=re.I).strip()
119
- return cleaned
120
-
121
-
122
- def _sanitize_question_text(text: str) -> str:
123
- raw = (text or "").strip()
124
- if not raw:
125
- return ""
126
- lines = [line.strip() for line in raw.splitlines() if line.strip()]
127
- for line in lines:
128
- candidate = _strip_control_prefix(line)
129
- if candidate and not _is_followup_hint_only(candidate):
130
- return candidate
131
- return _strip_control_prefix(raw)
132
-
133
-
134
- def _looks_like_question_text(text: str) -> bool:
135
- t = (text or "").strip()
136
- if not t:
137
- return False
138
- low = t.lower()
139
- return any(
140
- [
141
- "=" in t,
142
- "%" in t,
143
- bool(re.search(r"\b\d+\s*:\s*\d+\b", t)),
144
- bool(re.search(r"[a-zA-Z]\s*[\+\-\*/=]", t)),
145
- any(
146
- k in low
147
- for k in [
148
- "what is",
149
- "find",
150
- "if ",
151
- "how many",
152
- "probability",
153
- "ratio",
154
- "percent",
155
- "equation",
156
- "integer",
157
- "triangle",
158
- "circle",
159
- "mean",
160
- "median",
161
- "average",
162
- "remainder",
163
- "prime",
164
- "factor",
165
- "divisible",
166
- "area",
167
- "perimeter",
168
- "circumference",
169
- ]
170
- ),
171
- ]
172
- )
173
 
174
 
 
 
 
 
175
  def _is_topic_query(text: str) -> bool:
176
  low = (text or "").strip().lower()
177
 
@@ -191,7 +30,6 @@ def _is_topic_query(text: str) -> bool:
191
  if any(p in low for p in exact_patterns):
192
  return True
193
 
194
- # shorter / looser phrasing
195
  if "topic" in low and "this" in low:
196
  return True
197
 
@@ -207,814 +45,173 @@ def _is_topic_query(text: str) -> bool:
207
  return False
208
 
209
 
210
- def _specific_topic_from_question(question_text: str, fallback_topic: str, classified_topic: str) -> str:
211
- q = _clean_text(question_text).lower()
212
- topic = (fallback_topic or classified_topic or "general").lower()
213
-
214
- if any(k in q for k in ["variability", "spread", "standard deviation"]):
215
- return "variability"
216
- if any(k in q for k in ["mean", "average"]):
217
- return "mean"
218
- if "median" in q:
219
- return "median"
220
- if "range" in q:
221
- return "range"
222
- if topic == "data" and any(k in q for k in ["dataset", "table", "chart", "graph"]):
223
- return "statistics"
224
- return topic
225
-
226
-
227
- def _build_topic_query_reply(question_text: str, fallback_topic: str, classified_topic: str, category: str) -> str:
228
- specific = _specific_topic_from_question(question_text, fallback_topic, classified_topic)
229
- cat = (category or "").strip()
230
-
231
- if specific == "variability":
232
- return (
233
- "- This is a statistics / data insight question about variability (spread).\n"
234
- "- The key idea is to compare how spread out each dataset is, not which one has the biggest average.\n"
235
- "- A good first move is to compare how far the outer values sit from the middle value in each set."
236
- )
237
- if specific == "statistics":
238
- return (
239
- "- This is a statistics / data insight question.\n"
240
- "- The key skill is spotting which statistical idea matters most, then comparing the answer choices using that idea."
241
- )
242
- if specific == "algebra":
243
- return (
244
- "- This is an algebra question.\n"
245
- "- The key skill is rewriting the relationship cleanly, then simplifying the expression the question actually asks for."
246
- )
247
- if specific == "ratio":
248
- return (
249
- "- This is a ratio question.\n"
250
- "- The key skill is turning the ratio into consistent parts and then building the requested expression from those parts."
251
- )
252
- if specific == "percent":
253
- return (
254
- "- This is a percent question.\n"
255
- "- The key skill is identifying the correct base quantity before applying the percent relationship."
256
- )
257
 
258
- label = specific if specific != "general" else (cat.lower() if cat else "quantitative reasoning")
259
- return f"- This looks like a {label} question."
 
260
 
261
-
262
- def _classify_input_type(raw_user_text: str) -> str:
263
- text = _clean_text(raw_user_text).lower()
264
- if not text:
265
- return "empty"
266
- if text in {"hint", "a hint", "give me a hint", "can i have a hint"} or text.startswith("hint:"):
267
  return "hint"
268
- if text in {"next hint", "another hint", "more hint", "more hints", "next step", "continue", "go on"} or text.startswith("next hint:"):
269
- return "next_hint"
270
- if any(
271
- x in text
272
- for x in [
273
- "walkthrough",
274
- "step by step",
275
- "i'm confused",
276
- "im confused",
277
- "confused",
278
- "explain more",
279
- "help me understand",
280
- "method",
281
- "explain",
282
- ]
283
- ):
284
- return "confusion"
285
- if text.startswith("solve:") or text.startswith("solve "):
286
- return "solve"
287
- if _looks_like_question_text(_strip_control_prefix(raw_user_text)):
288
- return "question"
289
  return "other"
290
 
291
 
292
- def _is_followup_input(input_type: str) -> bool:
293
- return input_type in {"hint", "next_hint", "confusion"}
294
-
295
-
296
- def _history_hint_stage(chat_history: Optional[List[Dict[str, Any]]]) -> int:
297
- best = 0
298
- for item in chat_history or []:
299
- if not isinstance(item, dict):
300
- continue
301
- try:
302
- best = max(best, int(item.get("hint_stage", 0) or 0))
303
- except Exception:
304
- pass
305
- meta = item.get("meta")
306
- if isinstance(meta, dict):
307
- try:
308
- best = max(best, int(meta.get("hint_stage", 0) or 0))
309
- except Exception:
310
- pass
311
- nested_state = meta.get("session_state")
312
- if isinstance(nested_state, dict):
313
- try:
314
- best = max(best, int(nested_state.get("hint_stage", 0) or 0))
315
- except Exception:
316
- pass
317
- return min(best, 3)
318
-
319
-
320
- def _recover_question_text(
321
- raw_user_text: str,
322
- question_text: Optional[str],
323
- chat_history: Optional[List[Dict[str, Any]]],
324
- input_type: str,
325
- ) -> str:
326
- explicit = _sanitize_question_text(question_text or "")
327
- if explicit:
328
- return explicit
329
- direct_candidate = _sanitize_question_text(raw_user_text)
330
- if direct_candidate and _looks_like_question_text(direct_candidate):
331
- return direct_candidate
332
- if not _is_followup_input(input_type):
333
- return direct_candidate
334
- for item in reversed(chat_history or []):
335
- for candidate in _extract_question_candidates_from_history_item(item):
336
- recovered = _sanitize_question_text(candidate)
337
- if recovered and not _is_followup_hint_only(recovered) and _looks_like_question_text(recovered):
338
- return recovered
339
- return ""
340
-
341
-
342
- def _choose_effective_question_text(
343
  raw_user_text: str,
344
- question_text: Optional[str],
345
- input_type: str,
346
- state: Dict[str, Any],
347
- chat_history: Optional[List[Dict[str, Any]]],
348
- ) -> Tuple[str, bool]:
349
- explicit_question = _sanitize_question_text(question_text or "")
350
- stored_question = _sanitize_question_text(state.get("question_text", ""))
351
- if _is_followup_input(input_type):
352
- if explicit_question and _looks_like_question_text(explicit_question):
353
- return explicit_question, False
354
- direct_candidate = _sanitize_question_text(raw_user_text)
355
- if direct_candidate and _looks_like_question_text(direct_candidate):
356
- return direct_candidate, False
357
- if stored_question and _looks_like_question_text(stored_question):
358
- return stored_question, True
359
- recovered = _recover_question_text(raw_user_text, question_text, chat_history, input_type)
360
- return recovered, True
361
- if explicit_question:
362
- return explicit_question, False
363
- return _sanitize_question_text(raw_user_text), False
364
-
365
-
366
- def _compute_hint_stage(input_type: str, prior_hint_stage: int, fallback_history_stage: int = 0) -> int:
367
- base = max(int(prior_hint_stage or 0), int(fallback_history_stage or 0))
368
- if input_type in {"solve", "question"}:
369
- return 0
370
- if input_type == "hint":
371
- return min(max(1, base if base > 0 else 1), 3)
372
- if input_type == "next_hint":
373
- return min((base if base > 0 else 1) + 1, 3)
374
- if input_type == "confusion":
375
- return 3
376
- return min(base, 3)
377
-
378
-
379
- def _update_session_state(
380
- state: Dict[str, Any],
381
- *,
382
  question_text: str,
 
383
  question_id: Optional[str],
384
- hint_stage: int,
385
- user_last_input_type: str,
386
- built_on_previous_turn: bool,
387
- help_mode: str,
388
- intent: str,
389
- topic: Optional[str],
390
  category: Optional[str],
391
- ) -> Dict[str, Any]:
392
- if question_text:
393
- state["question_text"] = question_text
394
- if question_id:
395
- state["question_id"] = question_id
396
- state["hint_stage"] = int(hint_stage or 0)
397
- state["user_last_input_type"] = user_last_input_type
398
- state["built_on_previous_turn"] = bool(built_on_previous_turn)
399
- state["help_mode"] = help_mode
400
- state["intent"] = intent
401
- state["topic"] = topic
402
- state["category"] = category
403
- return state
404
-
405
-
406
- def _normalize_classified_topic(topic: Optional[str], category: Optional[str], question_text: str) -> str:
407
- t = (topic or "").strip().lower()
408
- q = (question_text or "").lower()
409
- c = normalize_category(category)
410
- has_ratio_form = bool(re.search(r"\b\d+\s*:\s*\d+\b", q))
411
- has_algebra_form = (
412
- "=" in q
413
- or bool(re.search(r"\b[xyzabn]\b", q))
414
- or bool(re.search(r"\d+[a-z]\b", q))
415
- or bool(re.search(r"\b[a-z]\s*[\+\-\*/=]", q))
416
- )
417
- if t not in {"general_quant", "general", "unknown", ""}:
418
- return t
419
- if "%" in q or "percent" in q:
420
- return "percent"
421
- if "ratio" in q or has_ratio_form:
422
- return "ratio"
423
- if any(k in q for k in ["probability", "chosen at random", "odds", "chance"]):
424
- return "probability"
425
- if any(k in q for k in ["divisible", "remainder", "prime", "factor"]):
426
- return "number_theory"
427
- if any(k in q for k in ["circle", "triangle", "perimeter", "area", "circumference", "rectangle"]):
428
- return "geometry"
429
- if any(k in q for k in ["mean", "median", "average", "variability", "standard deviation"]):
430
- return "statistics" if c == "Quantitative" else "data"
431
- if has_algebra_form:
432
- return "algebra"
433
- if c == "DataInsight":
434
- return "data"
435
- if c == "Verbal":
436
- return "verbal"
437
- if c == "Quantitative":
438
- return "quant"
439
- return "general"
440
-
441
-
442
- def _strip_bullet_prefix(text: str) -> str:
443
- return re.sub(r"^\s*[-•]\s*", "", (text or "").strip())
444
-
445
-
446
- def _safe_steps(steps: List[str]) -> List[str]:
447
- banned_patterns = [
448
- r"\bthe answer is\b",
449
- r"\banswer:\b",
450
- r"\bthat gives\b",
451
- r"\bthis gives\b",
452
- r"\btherefore\b",
453
- r"\bthus\b",
454
- r"\bresult is\b",
455
- r"\bfinal answer\b",
456
- ]
457
- cleaned: List[str] = []
458
- for step in steps:
459
- s = _strip_bullet_prefix(step)
460
- lowered = s.lower()
461
- if any(re.search(pattern, lowered) for pattern in banned_patterns):
462
- continue
463
- if s:
464
- cleaned.append(s)
465
- deduped: List[str] = []
466
- seen = set()
467
- for step in cleaned:
468
- key = step.lower().strip()
469
- if key and key not in seen:
470
- seen.add(key)
471
- deduped.append(step)
472
- return deduped
473
-
474
-
475
- def _safe_meta_list(items: Any) -> List[str]:
476
- if not items:
477
- return []
478
- if isinstance(items, list):
479
- return [str(x).strip() for x in items if str(x).strip()]
480
- if isinstance(items, tuple):
481
- return [str(x).strip() for x in items if str(x).strip()]
482
- if isinstance(items, str):
483
- text = items.strip()
484
- return [text] if text else []
485
- return []
486
-
487
-
488
- def _safe_meta_text(value: Any) -> Optional[str]:
489
- if value is None:
490
- return None
491
- text = str(value).strip()
492
- return text or None
493
-
494
-
495
- def _extract_explainer_scaffold(explainer_result: Any) -> Dict[str, Any]:
496
- scaffold = getattr(explainer_result, "scaffold", None)
497
- if scaffold is None:
498
- return {}
499
- return {
500
- "concept": _safe_meta_text(getattr(scaffold, "concept", None)),
501
- "ask": _safe_meta_text(getattr(scaffold, "ask", None)),
502
- "givens": _safe_meta_list(getattr(scaffold, "givens", [])),
503
- "target": _safe_meta_text(getattr(scaffold, "target", None)),
504
- "setup_actions": _safe_meta_list(getattr(scaffold, "setup_actions", [])),
505
- "intermediate_steps": _safe_meta_list(getattr(scaffold, "intermediate_steps", [])),
506
- "first_move": _safe_meta_text(getattr(scaffold, "first_move", None)),
507
- "next_hint": _safe_meta_text(getattr(scaffold, "next_hint", None)),
508
- "common_traps": _safe_meta_list(getattr(scaffold, "common_traps", [])),
509
- "variables_to_define": _safe_meta_list(getattr(scaffold, "variables_to_define", [])),
510
- "equations_to_form": _safe_meta_list(getattr(scaffold, "equations_to_form", [])),
511
- "answer_hidden": bool(getattr(scaffold, "answer_hidden", True)),
512
- "solution_path_type": _safe_meta_text(getattr(scaffold, "solution_path_type", None)),
513
- "key_operations": _safe_meta_list(getattr(scaffold, "key_operations", [])),
514
- "hint_ladder": _safe_meta_list(getattr(scaffold, "hint_ladder", [])),
515
- }
516
-
517
-
518
- def _get_result_steps(result: Optional[SolverResult]) -> List[str]:
519
- if result is None:
520
- return []
521
- display_steps = getattr(result, "display_steps", None)
522
- if isinstance(display_steps, list) and display_steps:
523
- return _safe_steps(display_steps)
524
- result_steps = getattr(result, "steps", None)
525
- if isinstance(result_steps, list) and result_steps:
526
- return _safe_steps(result_steps)
527
- meta = getattr(result, "meta", {}) or {}
528
- meta_display_steps = meta.get("display_steps")
529
- if isinstance(meta_display_steps, list) and meta_display_steps:
530
- return _safe_steps(meta_display_steps)
531
- meta_steps = meta.get("steps")
532
- if isinstance(meta_steps, list) and meta_steps:
533
- return _safe_steps(meta_steps)
534
- return []
535
-
536
-
537
- def _apply_safe_step_sanitization(result: Optional[SolverResult]) -> None:
538
- if result is None:
539
- return
540
- safe_steps = _get_result_steps(result)
541
- result.steps = list(safe_steps)
542
- setattr(result, "display_steps", list(safe_steps))
543
- result.meta = result.meta or {}
544
- result.meta["steps"] = list(safe_steps)
545
- result.meta["display_steps"] = list(safe_steps)
546
-
547
-
548
- def _solver_has_useful_steps(result: Optional[SolverResult]) -> bool:
549
- return bool(result is not None and _get_result_steps(result))
550
-
551
-
552
- def _answer_path_from_steps(steps: List[str], verbosity: float) -> str:
553
- safe_steps = _safe_steps(steps)
554
- if not safe_steps:
555
- return ""
556
- shown_steps = safe_steps[:2] if verbosity < 0.35 else safe_steps[:3] if verbosity < 0.8 else safe_steps
557
- return "\n".join(f"- {step}" for step in shown_steps)
558
-
559
-
560
- def _build_fallback_reply(
561
- *,
562
- question_id: Optional[str],
563
- question_text: str,
564
- options_text: Optional[List[str]],
565
- topic: Optional[str],
566
- category: Optional[str],
567
- help_mode: str,
568
- hint_stage: int,
569
- verbosity: float,
570
- ) -> Tuple[str, Dict[str, Any]]:
571
- payload = question_fallback_router.build_response(
572
- question_id=question_id,
573
- question_text=question_text,
574
- options_text=options_text,
575
- topic=topic,
576
- category=category,
577
- help_mode=help_mode,
578
- hint_stage=hint_stage,
579
- verbosity=verbosity,
580
- )
581
- lines = payload.get("lines") or ["Start by identifying the main relationship in the problem."]
582
- pack = payload.get("pack") or {}
583
- return "\n".join(f"- {line}" for line in lines if str(line).strip()), pack
584
-
585
-
586
- def _is_direct_solve_request(text: str, intent: str) -> bool:
587
- if intent == "answer":
588
- return True
589
- t = re.sub(r"\s+", " ", (text or "").strip().lower())
590
- if any(re.search(p, t) for p in DIRECT_SOLVE_PATTERNS):
591
- if not any(word in t for word in ["how", "explain", "why", "method", "hint", "define", "definition", "step"]):
592
- return True
593
- return False
594
 
 
595
 
596
- def _is_help_first_mode(help_mode: str) -> bool:
597
- return help_mode in {"hint", "walkthrough", "explain", "instruction", "step_by_step"}
598
 
 
599
 
600
- def _should_try_solver(is_quant: bool, help_mode: str, solver_input: str) -> bool:
601
- if not is_quant or not solver_input:
602
- return False
603
- return help_mode in {"answer", "walkthrough", "instruction", "hint", "step_by_step"}
604
 
 
 
 
 
605
 
606
- def _should_prefer_question_support(help_mode: str, fallback_pack: Dict[str, Any]) -> bool:
607
- if not fallback_pack:
608
- return False
609
- support_source = str(fallback_pack.get("support_source", "")).strip().lower()
610
- has_specific_content = support_source in {"question_bank", "question_id", "question_text"}
611
- if help_mode in {"hint", "walkthrough", "instruction", "step_by_step", "explain"}:
612
- return has_specific_content or bool(fallback_pack)
613
- return False
614
 
 
 
 
615
 
616
- def _minimal_generic_reply(category: Optional[str]) -> str:
617
- c = normalize_category(category)
618
- if c == "Verbal":
619
- return "I can help analyse the wording or logic, but I need the full question text to guide you properly."
620
- if c == "DataInsight":
621
- return "I can help reason through the data, but I need the full question or chart details to guide you properly."
622
- return "Start by identifying the main relationship in the problem."
623
-
624
-
625
- class ConversationEngine:
626
- def __init__(
627
- self,
628
- retriever: Optional[RetrievalEngine] = None,
629
- generator: Optional[GeneratorEngine] = None,
630
- **kwargs,
631
- ) -> None:
632
- self.retriever = retriever
633
- self.generator = generator
634
-
635
- def generate_response(
636
- self,
637
- raw_user_text: Optional[str] = None,
638
- tone: float = 0.5,
639
- verbosity: float = 0.5,
640
- transparency: float = 0.5,
641
- intent: Optional[str] = None,
642
- help_mode: Optional[str] = None,
643
- retrieval_context: Optional[List[RetrievedChunk]] = None,
644
- chat_history: Optional[List[Dict[str, Any]]] = None,
645
- question_text: Optional[str] = None,
646
- options_text: Optional[List[str]] = None,
647
- question_id: Optional[str] = None,
648
- session_state: Optional[Dict[str, Any]] = None,
649
- **kwargs,
650
- ) -> SolverResult:
651
- user_text = _clean_text(raw_user_text)
652
- state = _safe_get_state(session_state)
653
- input_type = _classify_input_type(user_text)
654
-
655
- effective_question_text, built_on_previous_turn = _choose_effective_question_text(
656
- raw_user_text=user_text,
657
- question_text=question_text,
658
- input_type=input_type,
659
- state=state,
660
- chat_history=chat_history,
661
- )
662
- if _is_followup_input(input_type):
663
- built_on_previous_turn = True
664
-
665
- solver_input = _sanitize_question_text(effective_question_text)
666
- question_id = question_id or state.get("question_id")
667
-
668
- category = normalize_category(kwargs.get("category"))
669
- classification = classify_question(question_text=solver_input, category=category)
670
- inferred_category = normalize_category(classification.get("category") or category)
671
- question_topic = _normalize_classified_topic(classification.get("topic"), inferred_category, solver_input)
672
-
673
- resolved_intent = intent or detect_intent(user_text, help_mode)
674
- if input_type in {"hint", "next_hint"}:
675
- resolved_intent = "hint"
676
- elif input_type == "confusion":
677
- resolved_intent = "walkthrough"
678
- elif input_type in {"solve", "question"} and resolved_intent in {"hint", "walkthrough", "step_by_step"}:
679
- resolved_intent = "answer"
680
-
681
- resolved_help_mode = help_mode or intent_to_help_mode(resolved_intent)
682
- if input_type in {"hint", "next_hint"}:
683
- resolved_help_mode = "hint"
684
- elif input_type == "confusion":
685
- resolved_help_mode = "walkthrough"
686
- elif resolved_help_mode == "step_by_step":
687
- resolved_help_mode = "walkthrough"
688
-
689
- prior_hint_stage = int(state.get("hint_stage", 0) or 0)
690
- history_hint_stage = _history_hint_stage(chat_history)
691
- hint_stage = _compute_hint_stage(input_type, prior_hint_stage, history_hint_stage)
692
-
693
- is_quant = bool(solver_input) and (
694
- inferred_category == "Quantitative" or is_quant_question(solver_input)
695
- )
696
 
697
- result = SolverResult(
698
- domain="quant" if is_quant else "general",
699
- solved=False,
700
- help_mode=resolved_help_mode,
701
- topic=question_topic if is_quant else "general",
702
- used_retrieval=False,
703
- used_generator=False,
704
- steps=[],
705
- teaching_chunks=[],
706
- meta={},
707
- )
708
 
709
- solver_result: Optional[SolverResult] = None
710
- if _should_try_solver(is_quant, resolved_help_mode, solver_input):
711
- try:
712
- solver_result = route_solver(solver_input)
713
- except Exception:
714
- solver_result = None
715
- _apply_safe_step_sanitization(solver_result)
716
-
717
- explainer_result = None
718
- explainer_understood = False
719
- explainer_scaffold: Dict[str, Any] = {}
720
- if solver_input:
721
- try:
722
- explainer_result = route_explainer(solver_input)
723
- except Exception:
724
- explainer_result = None
725
- if explainer_result is not None and getattr(explainer_result, "understood", False):
726
- explainer_understood = True
727
- explainer_scaffold = _extract_explainer_scaffold(explainer_result)
728
-
729
- fallback_reply_core = ""
730
- fallback_pack: Dict[str, Any] = {}
731
- if solver_input:
732
- fallback_reply_core, fallback_pack = _build_fallback_reply(
733
- question_id=question_id,
734
- question_text=solver_input,
735
- options_text=options_text,
736
- topic=question_topic,
737
- category=inferred_category,
738
- help_mode=resolved_help_mode,
739
- hint_stage=hint_stage,
740
- verbosity=verbosity,
741
  )
742
 
743
- # Merge solver result into base result only if it agrees with the classified topic.
744
- if solver_result is not None:
745
- result.meta = result.meta or {}
746
- solver_topic = getattr(solver_result, "topic", None) or "unknown"
747
-
748
- compatible_topics = {
749
- question_topic,
750
- "general_quant",
751
- "general",
752
- "unknown",
753
- }
754
-
755
- # allow a few sensible cross-matches
756
- if question_topic == "algebra":
757
- compatible_topics.update({"ratio"})
758
- elif question_topic == "ratio":
759
- compatible_topics.update({"algebra"})
760
- elif question_topic == "percent":
761
- compatible_topics.update({"ratio", "algebra"})
762
-
763
- if solver_topic in compatible_topics:
764
- result = solver_result
765
- result.domain = "quant"
766
- result.meta = result.meta or {}
767
- result.topic = question_topic if question_topic else solver_topic
768
- result.meta["solver_topic_accepted"] = solver_topic
769
- else:
770
- result.meta["solver_topic_rejected"] = solver_topic
771
- result.meta["solver_topic_expected"] = question_topic
772
- result.topic = question_topic if is_quant else result.topic
773
- else:
774
- result.meta = result.meta or {}
775
- result.topic = question_topic if is_quant else result.topic
776
-
777
- _apply_safe_step_sanitization(result)
778
- solver_steps = _get_result_steps(result)
779
- solver_has_steps = bool(solver_steps)
780
- prefer_question_support = _should_prefer_question_support(resolved_help_mode, fallback_pack)
781
- direct_solve_request = _is_direct_solve_request(user_text or solver_input, resolved_intent)
782
- solver_topic_ok = result.meta.get("solver_topic_rejected") is None
783
-
784
- result.help_mode = resolved_help_mode
785
- result.meta = result.meta or {}
786
- result.meta["hint_stage"] = hint_stage
787
- result.meta["max_stage"] = 3
788
- result.meta["recovered_question_text"] = solver_input
789
- result.meta["question_id"] = question_id
790
- result.meta["classified_topic"] = question_topic if question_topic else "general"
791
- result.meta["explainer_understood"] = explainer_understood
792
- result.meta["explainer_scaffold"] = explainer_scaffold
793
-
794
- if input_type == "topic_query":
795
- support_topic = fallback_pack.get("topic") if fallback_pack else ""
796
- topic_reply_core = _build_topic_query_reply(
797
- question_text=solver_input,
798
- fallback_topic=support_topic,
799
- classified_topic=question_topic if question_topic else "general",
800
- category=inferred_category if inferred_category else "General",
801
- )
802
- reply = format_reply(
803
- topic_reply_core,
804
- tone=tone,
805
- verbosity=verbosity,
806
- transparency=transparency,
807
- help_mode="answer",
808
- hint_stage=hint_stage,
809
- topic=support_topic or question_topic or result.topic,
810
- )
811
- result.topic = support_topic or question_topic or result.topic
812
- result.reply = reply
813
- result.help_mode = "answer"
814
- result.meta["response_source"] = "topic_classifier"
815
- result.meta["question_support_used"] = bool(fallback_pack)
816
- result.meta["question_support_source"] = fallback_pack.get("support_source") if fallback_pack else None
817
- result.meta["question_support_topic"] = support_topic or None
818
- result.meta["help_mode"] = "answer"
819
- result.meta["intent"] = "topic_query"
820
- result.meta["question_text"] = solver_input or ""
821
- result.meta["options_count"] = len(options_text or [])
822
- result.meta["category"] = inferred_category if inferred_category else "General"
823
- result.meta["user_last_input_type"] = input_type
824
- result.meta["built_on_previous_turn"] = built_on_previous_turn
825
- state = _update_session_state(
826
- state,
827
- question_text=solver_input,
828
- question_id=question_id,
829
- hint_stage=hint_stage,
830
- user_last_input_type=input_type,
831
- built_on_previous_turn=built_on_previous_turn,
832
- help_mode="answer",
833
- intent="topic_query",
834
- topic=result.topic,
835
- category=inferred_category,
836
  )
837
- result.meta["session_state"] = state
838
- result.meta["used_retrieval"] = False
839
- result.meta["used_generator"] = False
840
- result.meta["can_reveal_answer"] = False
841
- result.answer_letter = None
842
- result.answer_value = None
843
- result.internal_answer = None
844
- result.meta["internal_answer"] = None
845
- return result
846
-
847
- if fallback_pack and fallback_pack.get("topic") == "statistics":
848
- qlow = (solver_input or "").lower()
849
- wants_topic = input_type == "topic_query"
850
- if any(k in qlow for k in ["variability", "spread", "standard deviation"]):
851
- if wants_topic:
852
- fallback_reply_core = (
853
- "- This is a statistics / data insight question about variability (spread).\n"
854
- "- Focus on how spread out each dataset is rather than the average.\n"
855
- "- Compare how far the outer values sit from the middle value in each set."
856
- )
857
- elif resolved_help_mode == "answer":
858
- fallback_reply_core = (
859
- "- Notice this is asking about variability, which means spread, not the mean.\n"
860
- "- Compare how far the smallest and largest values sit from the middle value in each dataset.\n"
861
- "- The set with the widest spread has the greatest variability."
862
- )
863
-
864
- # Source selection priority:
865
- # 1) question-specific fallback for help modes
866
- # 2) explainer for explain when specific support is unavailable
867
- # 3) solver steps for direct answer / walkthrough fallback
868
- # 4) generic fallback
869
- if resolved_help_mode == "explain" and prefer_question_support and fallback_reply_core:
870
- reply_core = fallback_reply_core
871
- result.meta["response_source"] = "question_support"
872
- result.meta["question_support_used"] = True
873
- result.meta["question_support_source"] = fallback_pack.get("support_source")
874
- result.meta["question_support_topic"] = fallback_pack.get("topic")
875
- reply = format_reply(
876
- reply_core,
877
- tone=tone,
878
- verbosity=verbosity,
879
- transparency=transparency,
880
- help_mode=resolved_help_mode,
881
- hint_stage=hint_stage,
882
- topic=result.topic,
883
- )
884
- elif resolved_help_mode == "explain" and explainer_understood:
885
- reply = format_explainer_response(
886
- result=explainer_result,
887
- tone=tone,
888
- verbosity=verbosity,
889
- transparency=transparency,
890
- help_mode=resolved_help_mode,
891
- hint_stage=hint_stage,
892
- )
893
- result.meta["response_source"] = "explainer"
894
- result.meta["explainer_used"] = True
895
- result.meta["question_support_used"] = False
896
- elif _is_help_first_mode(resolved_help_mode) and prefer_question_support and fallback_reply_core:
897
- reply_core = fallback_reply_core
898
- result.meta["response_source"] = "question_support"
899
- result.meta["question_support_used"] = True
900
- result.meta["question_support_source"] = fallback_pack.get("support_source")
901
- result.meta["question_support_topic"] = fallback_pack.get("topic")
902
- reply = format_reply(
903
- reply_core,
904
- tone=tone,
905
- verbosity=verbosity,
906
- transparency=transparency,
907
- help_mode=resolved_help_mode,
908
- hint_stage=hint_stage,
909
- topic=result.topic,
910
- )
911
- elif (
912
- resolved_help_mode == "answer"
913
- and solver_has_steps
914
- and result.meta.get("solver_topic_rejected") is None
915
- and direct_solve_request
916
- ):
917
- reply_core = _answer_path_from_steps(solver_steps, verbosity=verbosity)
918
- result.meta["response_source"] = "solver_steps"
919
- result.meta["question_support_used"] = False
920
- reply = format_reply(
921
- reply_core,
922
- tone=tone,
923
- verbosity=verbosity,
924
- transparency=transparency,
925
- help_mode=resolved_help_mode,
926
- hint_stage=hint_stage,
927
- topic=result.topic,
928
- )
929
- elif (
930
- resolved_help_mode == "walkthrough"
931
- and solver_has_steps
932
- and not prefer_question_support
933
- and result.meta.get("solver_topic_rejected") is None
934
- ):
935
- reply_core = _answer_path_from_steps(solver_steps, verbosity=verbosity)
936
- result.meta["response_source"] = "solver_steps"
937
- result.meta["question_support_used"] = False
938
- reply = format_reply(
939
- reply_core,
940
- tone=tone,
941
- verbosity=verbosity,
942
- transparency=transparency,
943
- help_mode=resolved_help_mode,
944
- hint_stage=hint_stage,
945
- topic=result.topic,
946
  )
947
- elif fallback_reply_core:
948
- reply_core = fallback_reply_core
949
- result.meta["response_source"] = "fallback"
950
- result.meta["question_support_used"] = bool(fallback_pack)
951
- result.meta["question_support_source"] = fallback_pack.get("support_source")
952
- result.meta["question_support_topic"] = fallback_pack.get("topic")
953
- reply = format_reply(
954
- reply_core,
955
- tone=tone,
956
- verbosity=verbosity,
957
- transparency=transparency,
958
- help_mode=resolved_help_mode,
959
- hint_stage=hint_stage,
960
- topic=result.topic,
961
  )
962
- else:
963
- reply_core = _minimal_generic_reply(inferred_category)
964
- if not reply_core.startswith("- "):
965
- reply_core = f"- {reply_core}"
966
- result.meta["response_source"] = "generic"
967
- result.meta["question_support_used"] = False
968
- reply = format_reply(
969
- reply_core,
970
- tone=tone,
971
- verbosity=verbosity,
972
- transparency=transparency,
973
- help_mode=resolved_help_mode,
974
- hint_stage=hint_stage,
975
- topic=result.topic,
976
  )
977
 
978
- # Never reveal final answers during tutoring/help modes.
979
- if resolved_help_mode in {"hint", "walkthrough", "explain", "instruction", "step_by_step"}:
980
- result.solved = False
981
- result.answer_letter = None
982
- result.answer_value = None
983
- result.internal_answer = None
984
- result.meta["internal_answer"] = None
985
-
986
- # Only allow answer metadata to survive for true direct solve requests.
987
- can_reveal_answer = bool(result.solved and direct_solve_request and not _is_help_first_mode(resolved_help_mode))
988
- result.meta["can_reveal_answer"] = can_reveal_answer
989
- if not can_reveal_answer:
990
- result.answer_letter = None
991
- result.answer_value = None
992
- result.internal_answer = None
993
- result.meta["internal_answer"] = None
994
-
995
- state = _update_session_state(
996
- state,
997
- question_text=solver_input,
998
- question_id=question_id,
999
- hint_stage=hint_stage,
1000
- user_last_input_type=input_type,
1001
- built_on_previous_turn=built_on_previous_turn,
1002
- help_mode=resolved_help_mode,
1003
- intent=resolved_intent,
1004
- topic=result.topic,
1005
- category=inferred_category,
1006
  )
1007
 
1008
  result.reply = reply
1009
- result.help_mode = resolved_help_mode
1010
- result.meta["help_mode"] = resolved_help_mode
1011
- result.meta["intent"] = resolved_intent
1012
- result.meta["question_text"] = solver_input or ""
1013
- result.meta["options_count"] = len(options_text or [])
1014
- result.meta["category"] = inferred_category if inferred_category else "General"
1015
- result.meta["user_last_input_type"] = input_type
1016
- result.meta["built_on_previous_turn"] = built_on_previous_turn
1017
- result.meta["session_state"] = state
1018
- result.meta["used_retrieval"] = False
1019
- result.meta["used_generator"] = False
 
 
1020
  return result
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # conversation_logic.py
2
+
3
+ from typing import List, Optional
4
+ from models import ChatResponse
5
+ from formatting import format_reply
6
+ from quant_solver import solve_quant, is_quant_question
7
+ from question_fallback_router import get_fallback_pack
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
 
9
 
10
+ # =========================
11
+ # INPUT TYPE DETECTION
12
+ # =========================
13
+
14
  def _is_topic_query(text: str) -> bool:
15
  low = (text or "").strip().lower()
16
 
 
30
  if any(p in low for p in exact_patterns):
31
  return True
32
 
 
33
  if "topic" in low and "this" in low:
34
  return True
35
 
 
45
  return False
46
 
47
 
48
+ def _classify_input_type(text: str) -> str:
49
+ low = (text or "").lower()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
 
51
+ # NEW FIRST PRIORITY
52
+ if _is_topic_query(text):
53
+ return "topic_query"
54
 
55
+ if any(x in low for x in ["hint", "help"]):
 
 
 
 
 
56
  return "hint"
57
+
58
+ if any(x in low for x in ["how", "walkthrough", "steps"]):
59
+ return "walkthrough"
60
+
61
+ if any(x in low for x in ["answer", "solve"]):
62
+ return "answer"
63
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
  return "other"
65
 
66
 
67
+ # =========================
68
+ # MAIN ENTRY
69
+ # =========================
70
+
71
+ def generate_response(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
  raw_user_text: str,
73
+ tone: float,
74
+ verbosity: float,
75
+ transparency: float,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
  question_text: str,
77
+ options_text: List[str],
78
  question_id: Optional[str],
 
 
 
 
 
 
79
  category: Optional[str],
80
+ ) -> ChatResponse:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81
 
82
+ result = ChatResponse()
83
 
84
+ input_type = _classify_input_type(raw_user_text)
 
85
 
86
+ solver_input = question_text if question_text else raw_user_text
87
 
88
+ is_quant = is_quant_question(solver_input)
 
 
 
89
 
90
+ fallback_pack = get_fallback_pack(
91
+ question_text=solver_input,
92
+ category=category,
93
+ )
94
 
95
+ question_topic = fallback_pack.get("topic") if fallback_pack else "general"
 
 
 
 
 
 
 
96
 
97
+ # =========================
98
+ # ✅ TOPIC QUERY HANDLER
99
+ # =========================
100
 
101
+ if input_type == "topic_query":
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
102
 
103
+ final_topic = question_topic or "general"
104
+ qlow = (solver_input or "").lower()
 
 
 
 
 
 
 
 
 
105
 
106
+ if "variability" in qlow or "spread" in qlow:
107
+ core = (
108
+ "This is a statistics / data insight question about variability (spread).\n"
109
+ "The key skill is comparing how spread out the values are."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
  )
111
 
112
+ elif final_topic == "algebra":
113
+ core = (
114
+ "This is an algebra question.\n"
115
+ "It tests how to use a relationship to rewrite or simplify an expression."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
  )
117
+
118
+ elif final_topic == "ratio":
119
+ core = (
120
+ "This is a ratio question.\n"
121
+ "It tests how to turn ratios into consistent parts and use them in expressions."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
  )
123
+
124
+ elif final_topic == "percent":
125
+ core = (
126
+ "This is a percent question.\n"
127
+ "It tests identifying the correct base and applying the percent relationship."
 
 
 
 
 
 
 
 
 
128
  )
129
+
130
+ elif final_topic == "statistics":
131
+ core = (
132
+ "This is a statistics / data insight question.\n"
133
+ "It tests identifying the correct statistical concept and comparing datasets."
 
 
 
 
 
 
 
 
 
134
  )
135
 
136
+ else:
137
+ core = f"This looks like a {final_topic} question."
138
+
139
+ reply = format_reply(
140
+ core,
141
+ tone=tone,
142
+ verbosity=verbosity,
143
+ transparency=transparency,
144
+ help_mode="answer",
145
+ hint_stage=0,
146
+ topic=final_topic,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
147
  )
148
 
149
  result.reply = reply
150
+ result.topic = final_topic
151
+
152
+ result.meta = {
153
+ "intent": "topic_query",
154
+ "response_source": "topic_classifier",
155
+ "topic": final_topic,
156
+ "question_text": solver_input,
157
+ "options_count": len(options_text or []),
158
+ "category": category,
159
+ "used_retrieval": False,
160
+ "used_generator": False,
161
+ }
162
+
163
  return result
164
+
165
+ # =========================
166
+ # NORMAL FLOW
167
+ # =========================
168
+
169
+ solver_result = None
170
+
171
+ if is_quant:
172
+ solver_result = solve_quant(solver_input, options_text)
173
+
174
+ # =========================
175
+ # FALLBACK / SOLVER
176
+ # =========================
177
+
178
+ if solver_result and solver_result.steps:
179
+ reply_core = "\n".join(f"- {s}" for s in solver_result.steps)
180
+ source = "solver_steps"
181
+ topic = solver_result.topic or question_topic
182
+
183
+ elif fallback_pack:
184
+ reply_core = "\n".join(f"- {s}" for s in fallback_pack.get("answer_path", []))
185
+ source = "fallback"
186
+ topic = question_topic
187
+
188
+ else:
189
+ reply_core = "Try breaking the problem into smaller steps."
190
+ source = "generic"
191
+ topic = "general"
192
+
193
+ reply = format_reply(
194
+ reply_core,
195
+ tone=tone,
196
+ verbosity=verbosity,
197
+ transparency=transparency,
198
+ help_mode="answer",
199
+ hint_stage=0,
200
+ topic=topic,
201
+ )
202
+
203
+ result.reply = reply
204
+ result.topic = topic
205
+
206
+ result.meta = {
207
+ "intent": "answer",
208
+ "response_source": source,
209
+ "topic": topic,
210
+ "question_text": solver_input,
211
+ "options_count": len(options_text or []),
212
+ "category": category,
213
+ "used_retrieval": False,
214
+ "used_generator": False,
215
+ }
216
+
217
+ return result