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

Update conversation_logic.py

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