j-js commited on
Commit
b3ac5d7
·
verified ·
1 Parent(s): e978573

Update conversation_logic.py

Browse files
Files changed (1) hide show
  1. conversation_logic.py +546 -357
conversation_logic.py CHANGED
@@ -1,393 +1,582 @@
1
  from __future__ import annotations
2
 
3
  import re
4
- from typing import Any, List, Optional
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
 
 
 
6
 
7
- def style_prefix(tone: float) -> str:
8
- if tone < 0.2:
9
- return ""
10
- if tone < 0.45:
11
- return "Let’s solve it efficiently."
12
- if tone < 0.75:
13
- return "Let’s work through it."
14
- return "You’ve got this — let’s solve it cleanly."
15
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
 
17
- def _normalize_key(text: str) -> str:
18
- text = (text or "").strip().lower()
19
- text = text.replace("", "'")
20
- text = re.sub(r"\s+", " ", text)
21
- return text
 
22
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
 
24
- def _clean_lines(core: str) -> list[str]:
25
- lines = []
26
- for line in (core or "").splitlines():
27
- cleaned = line.strip()
28
- if cleaned:
29
- lines.append(cleaned)
30
- return lines
31
 
 
32
 
33
- def _dedupe_lines(lines: list[str]) -> list[str]:
34
- seen = set()
35
- output = []
36
- for line in lines:
37
- key = _normalize_key(line)
38
- if key and key not in seen:
39
- seen.add(key)
40
- output.append(line.strip())
41
- return output
42
 
 
 
43
 
44
- def _coerce_string(value: Any) -> str:
45
- return (value or "").strip() if isinstance(value, str) else ""
46
 
 
 
 
 
 
 
 
47
 
48
- def _coerce_list(value: Any) -> List[str]:
49
- if not value:
 
50
  return []
51
- if isinstance(value, list):
52
- return [str(v).strip() for v in value if str(v).strip()]
53
- if isinstance(value, tuple):
54
- return [str(v).strip() for v in value if str(v).strip()]
55
- if isinstance(value, str):
56
- text = value.strip()
57
  return [text] if text else []
58
  return []
59
 
60
 
61
- def _safe_append(lines: List[str], value: str) -> None:
62
- text = (value or "").strip()
63
- if text:
64
- lines.append(text)
65
-
66
 
67
- def _limit_steps(steps: List[str], verbosity: float, minimum: int = 1) -> List[str]:
68
- if not steps:
69
- return []
70
- if verbosity < 0.25:
71
- limit = minimum
72
- elif verbosity < 0.5:
73
- limit = max(minimum, 2)
74
- elif verbosity < 0.75:
75
- limit = max(minimum, 3)
76
- else:
77
- limit = max(minimum, 5)
78
- return steps[:limit]
79
-
80
-
81
- def _why_line(topic: str) -> str:
82
- topic = (topic or "").lower()
83
-
84
- if topic == "algebra":
85
- return "Why: algebra works by keeping the relationship balanced while undoing the operations attached to the variable."
86
- if topic == "percent":
87
- return "Why: percent questions depend on choosing the correct base before doing any calculation."
88
- if topic == "ratio":
89
- return "Why: ratio questions depend on preserving the comparison and using one shared scale factor."
90
- if topic == "probability":
91
- return "Why: probability compares successful outcomes to all possible outcomes."
92
- if topic == "statistics":
93
- return "Why: the right method depends on which summary measure the question actually asks for."
94
- if topic == "geometry":
95
- return "Why: geometry depends on the relationships between the parts of the figure."
96
- if topic == "number_theory":
97
- return "Why: number properties follow fixed rules about divisibility, factors, and remainders."
98
- return "Why: start with the structure of the problem before calculating."
99
-
100
-
101
- def _extract_topic_from_text(text: str, fallback: Optional[str] = None) -> str:
102
- low = (text or "").lower()
103
- if fallback:
104
- return fallback
105
- if any(word in low for word in ["equation", "variable", "isolate", "algebra"]):
106
- return "algebra"
107
- if any(word in low for word in ["percent", "percentage", "%"]):
108
- return "percent"
109
- if any(word in low for word in ["ratio", "proportion"]):
110
- return "ratio"
111
- if any(word in low for word in ["probability", "outcome", "chance", "odds"]):
112
- return "probability"
113
- if any(word in low for word in ["mean", "median", "average"]):
114
- return "statistics"
115
- if any(word in low for word in ["triangle", "circle", "angle", "area", "perimeter"]):
116
- return "geometry"
117
- if any(word in low for word in ["integer", "factor", "multiple", "prime", "remainder"]):
118
- return "number_theory"
119
- return "general"
120
 
 
 
121
 
122
- def _format_answer_mode(
123
- lines: List[str],
124
- topic: str,
125
- tone: float,
126
- verbosity: float,
127
- transparency: float,
128
- ) -> str:
129
- output: List[str] = []
130
- prefix = style_prefix(tone)
131
- if prefix:
132
- output.append(prefix)
133
- output.append("")
134
-
135
- limited = _limit_steps(lines, verbosity, minimum=2)
136
- if limited:
137
- output.append("Answer path:")
138
- if len(limited) >= 1:
139
- output.append(f"- What to identify: {limited[0]}")
140
- if len(limited) >= 2:
141
- output.append(f"- First move: {limited[1]}")
142
- if len(limited) >= 3:
143
- output.append(f"- Next step: {limited[2]}")
144
- for extra in limited[3:]:
145
- output.append(f"- Keep in mind: {extra}")
146
-
147
- if transparency >= 0.8:
148
- output.append("")
149
- output.append(_why_line(topic))
150
-
151
- return "\n".join(output).strip()
152
-
153
-
154
- def format_reply(
155
- core: str,
156
- tone: float,
157
- verbosity: float,
158
- transparency: float,
159
  help_mode: str,
160
- hint_stage: int = 0,
161
- topic: Optional[str] = None,
162
- ) -> str:
163
- prefix = style_prefix(tone)
164
- core = (core or "").strip()
165
-
166
- if not core:
167
- return prefix or "Start with the structure of the problem."
168
-
169
- lines = _dedupe_lines(_clean_lines(core))
170
- if not lines:
171
- return prefix or "Start with the structure of the problem."
172
-
173
- resolved_topic = _extract_topic_from_text(core, topic)
174
-
175
- if help_mode == "answer":
176
- return _format_answer_mode(lines, resolved_topic, tone, verbosity, transparency)
177
-
178
- shown = _limit_steps(lines, verbosity, minimum=1)
179
- output: List[str] = []
180
-
181
- if prefix:
182
- output.append(prefix)
183
- output.append("")
184
-
185
- if help_mode == "hint":
186
- output.append("Hint:")
187
- output.append(f"- {shown[0]}")
188
- if transparency >= 0.8:
189
- output.append("")
190
- output.append(_why_line(resolved_topic))
191
- return "\n".join(output).strip()
192
-
193
- if help_mode in {"instruction", "step_by_step", "walkthrough"}:
194
- label = "First step:" if help_mode == "instruction" else "Walkthrough:"
195
- output.append(label)
196
- for line in shown:
197
- output.append(f"- {line}")
198
- if transparency >= 0.8:
199
- output.append("")
200
- output.append(_why_line(resolved_topic))
201
- return "\n".join(output).strip()
202
-
203
- if help_mode in {"method", "explain", "concept", "definition"}:
204
- label = {
205
- "method": "Method:",
206
- "explain": "Explanation:",
207
- "concept": "Key idea:",
208
- "definition": "Key idea:",
209
- }[help_mode]
210
- output.append(label)
211
- for line in shown:
212
- output.append(f"- {line}")
213
- if transparency >= 0.75:
214
- output.append("")
215
- output.append(_why_line(resolved_topic))
216
- return "\n".join(output).strip()
217
-
218
- for line in shown:
219
- output.append(f"- {line}")
220
-
221
- if transparency >= 0.85:
222
- output.append("")
223
- output.append(_why_line(resolved_topic))
224
-
225
- return "\n".join(output).strip()
226
-
227
-
228
- def _get_scaffold(result: Any):
229
- return getattr(result, "scaffold", None)
230
-
231
-
232
- def _staged_scaffold_lines(
233
- result: Any,
234
- hint_stage: int,
235
  verbosity: float,
236
  transparency: float,
237
- ) -> List[str]:
238
- output: List[str] = []
239
- scaffold = _get_scaffold(result)
240
- if scaffold is None:
241
- return output
242
-
243
- stage = max(0, min(int(hint_stage), 3))
244
-
245
- concept = _coerce_string(getattr(scaffold, "concept", ""))
246
- ask = _coerce_string(getattr(scaffold, "ask", ""))
247
- first_move = _coerce_string(getattr(scaffold, "first_move", ""))
248
- next_hint = _coerce_string(getattr(scaffold, "next_hint", ""))
249
- setup_actions = _coerce_list(getattr(scaffold, "setup_actions", []))
250
- intermediate_steps = _coerce_list(getattr(scaffold, "intermediate_steps", []))
251
- variables_to_define = _coerce_list(getattr(scaffold, "variables_to_define", []))
252
- equations_to_form = _coerce_list(getattr(scaffold, "equations_to_form", []))
253
- common_traps = _coerce_list(getattr(scaffold, "common_traps", []))
254
- hint_ladder = _coerce_list(getattr(scaffold, "hint_ladder", []))
255
-
256
- if concept and stage == 0 and transparency >= 0.75:
257
- output.append("Core idea:")
258
- output.append(f"- {concept}")
259
- output.append("")
260
-
261
- if ask:
262
- output.append("What to identify first:")
263
- output.append(f"- {ask}")
264
-
265
- if stage == 0:
266
  if first_move:
267
- output.append("")
268
- output.append("First move:")
269
- output.append(f"- {first_move}")
270
- elif hint_ladder:
271
- output.append("")
272
- output.append("First move:")
273
- output.append(f"- {hint_ladder[0]}")
274
- return output
275
-
276
- if setup_actions:
277
- output.append("")
278
- output.append("Set-up path:")
279
- for item in _limit_steps(setup_actions, verbosity, minimum=2 if stage >= 1 else 1):
280
- output.append(f"- {item}")
281
-
282
- if first_move:
283
- output.append("")
284
- output.append("First move:")
285
- output.append(f"- {first_move}")
286
-
287
- if stage == 1:
288
- if next_hint:
289
- output.append("")
290
- output.append("Next hint:")
291
- output.append(f"- {next_hint}")
292
- elif len(hint_ladder) >= 2:
293
- output.append("")
294
- output.append("Next hint:")
295
- output.append(f"- {hint_ladder[1]}")
296
- return output
297
-
298
- if intermediate_steps:
299
- output.append("")
300
- output.append("How to build it:")
301
- for item in _limit_steps(intermediate_steps, verbosity, minimum=2):
302
- output.append(f"- {item}")
303
-
304
- if next_hint:
305
- output.append("")
306
- output.append("Next hint:")
307
- output.append(f"- {next_hint}")
308
-
309
- if stage == 2:
310
- if variables_to_define:
311
- output.append("")
312
- output.append("Variables to define:")
313
- for item in variables_to_define[:2]:
314
- output.append(f"- {item}")
315
- if equations_to_form:
316
- output.append("")
317
- output.append("Equations to form:")
318
- for item in equations_to_form[:2]:
319
- output.append(f"- {item}")
320
- return output
321
-
322
- if variables_to_define:
323
- output.append("")
324
- output.append("Variables to define:")
325
- for item in variables_to_define[:3]:
326
- output.append(f"- {item}")
327
-
328
- if equations_to_form:
329
- output.append("")
330
- output.append("Equations to form:")
331
- for item in equations_to_form[:3]:
332
- output.append(f"- {item}")
333
-
334
- if common_traps:
335
- output.append("")
336
- output.append("Watch out for:")
337
- for item in common_traps[:4]:
338
- output.append(f"- {item}")
339
-
340
- return output
341
-
342
-
343
- def format_explainer_response(
344
- result: Any,
345
- tone: float,
346
- verbosity: float,
347
- transparency: float,
348
- hint_stage: int = 0,
349
- ) -> str:
350
- if not result or not getattr(result, "understood", False):
351
- return "I can help explain what the question is asking, but I need the full wording of the question."
352
 
353
- output: List[str] = []
354
- prefix = style_prefix(tone)
355
- if prefix:
356
- output.append(prefix)
357
- output.append("")
358
 
359
- output.append("Question breakdown:")
360
- output.append("")
361
 
362
- summary = _coerce_string(getattr(result, "summary", ""))
363
- if summary:
364
- output.append(summary)
365
 
366
- scaffold_lines = _staged_scaffold_lines(
367
- result=result,
368
- hint_stage=hint_stage,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
369
  verbosity=verbosity,
370
- transparency=transparency,
371
  )
372
- if scaffold_lines:
373
- if summary:
374
- output.append("")
375
- output.extend(scaffold_lines)
376
-
377
- teaching_points = _coerce_list(getattr(result, "teaching_points", []))
378
- if teaching_points and (verbosity >= 0.55 or hint_stage >= 2):
379
- output.append("")
380
- output.append("Key teaching points:")
381
- for item in _limit_steps(teaching_points, verbosity, minimum=2):
382
- output.append(f"- {item}")
383
-
384
- topic = _extract_topic_from_text(
385
- f"{summary} {' '.join(teaching_points)}",
386
- getattr(result, "topic", None),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
387
  )
388
 
389
- if transparency >= 0.8:
390
- output.append("")
391
- output.append(_why_line(topic))
392
 
393
- return "\n".join(_dedupe_lines(output)).strip()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from __future__ import annotations
2
 
3
  import re
4
+ from typing import Any, Dict, List, Optional, Set
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
+ from utils import normalize_spaces
16
+
17
+
18
+ DIRECT_SOLVE_PATTERNS = [
19
+ r"\bsolve\b",
20
+ r"\bwhat is\b",
21
+ r"\bfind\b",
22
+ r"\bgive (?:me )?the answer\b",
23
+ r"\bjust the answer\b",
24
+ r"\banswer only\b",
25
+ r"\bcalculate\b",
26
+ ]
27
+
28
+ STRUCTURE_KEYWORDS = {
29
+ "algebra": ["equation", "solve", "isolate", "variable", "linear", "expression", "unknown", "algebra"],
30
+ "percent": ["percent", "%", "percentage", "increase", "decrease"],
31
+ "ratio": ["ratio", "proportion", "part", "share"],
32
+ "statistics": ["mean", "median", "mode", "range", "average"],
33
+ "probability": ["probability", "chance", "odds"],
34
+ "geometry": ["triangle", "circle", "angle", "area", "perimeter", "radius", "diameter"],
35
+ "number_theory": ["integer", "odd", "even", "prime", "divisible", "factor", "multiple", "remainder"],
36
+ "sequence": ["sequence", "geometric", "arithmetic", "term", "series"],
37
+ "quant": ["equation", "solve", "value", "integer", "ratio", "percent"],
38
+ "data": ["data", "mean", "median", "trend", "chart", "table", "correlation"],
39
+ "verbal": ["grammar", "meaning", "author", "argument", "sentence", "word"],
40
+ }
41
+
42
+ INTENT_KEYWORDS = {
43
+ "walkthrough": ["walkthrough", "work through", "step by step", "full working"],
44
+ "step_by_step": ["step", "first step", "next step", "step by step"],
45
+ "explain": ["explain", "why", "understand"],
46
+ "method": ["method", "approach", "how do i solve", "how to solve", "equation", "formula"],
47
+ "hint": ["hint", "nudge", "clue", "what do i do"],
48
+ "definition": ["define", "definition", "what does", "what is meant by", "meaning"],
49
+ "concept": ["concept", "idea", "principle", "rule"],
50
+ "instruction": ["how do i", "how to", "what should i do first", "what step", "first step"],
51
+ }
52
+
53
+ MISMATCH_TERMS = {
54
+ "algebra": ["absolute value", "modulus", "square root", "quadratic", "inequality", "roots", "parabola"],
55
+ "percent": ["triangle", "circle", "prime", "absolute value"],
56
+ "ratio": ["absolute value", "quadratic", "circle"],
57
+ "statistics": ["absolute value", "prime", "triangle"],
58
+ "probability": ["absolute value", "circle area", "quadratic"],
59
+ "geometry": ["absolute value", "prime", "median salary"],
60
+ "number_theory": ["circle", "triangle", "median salary"],
61
+ }
62
+
63
+
64
+ def detect_help_mode(text: str) -> str:
65
+ low = (text or "").lower().strip()
66
+
67
+ if any(p in low for p in ["what does", "what is", "define", "meaning of"]):
68
+ return "definition"
69
+
70
+ if any(p in low for p in ["explain", "break down", "what is the question asking", "help me understand"]):
71
+ return "explain"
72
+
73
+ if any(p in low for p in ["step by step", "steps", "walk me through"]):
74
+ return "step_by_step"
75
+
76
+ if any(p in low for p in ["how do i", "how to", "approach this", "method"]):
77
+ return "answer"
78
+
79
+ if any(p in low for p in ["hint", "nudge"]):
80
+ return "hint"
81
+
82
+ if any(p in low for p in ["walkthrough", "work through"]):
83
+ return "walkthrough"
84
+
85
+ return "explain"
86
+
87
+
88
+ def _normalize_classified_topic(topic: Optional[str], category: Optional[str], question_text: str) -> str:
89
+ t = (topic or "").strip().lower()
90
+ q = (question_text or "").lower()
91
+ c = normalize_category(category)
92
+
93
+ has_ratio_form = bool(re.search(r"\b\d+\s*:\s*\d+\b", q))
94
+ has_algebra_form = (
95
+ "=" in q
96
+ or bool(re.search(r"\b[xyz]\b", q))
97
+ or bool(re.search(r"\d+[a-z]\b", q))
98
+ or bool(re.search(r"\b[a-z]\s*[\+\-\*/=]", q))
99
+ )
100
 
101
+ if t == "ratio" and not has_ratio_form and has_algebra_form:
102
+ t = "algebra"
103
 
104
+ if t not in {"general_quant", "general", "unknown", ""}:
105
+ return t
 
 
 
 
 
 
106
 
107
+ if "%" in q or "percent" in q:
108
+ return "percent"
109
+ if "ratio" in q or has_ratio_form:
110
+ return "ratio"
111
+ if "probability" in q or "chosen at random" in q or "odds" in q or "chance" in q:
112
+ return "probability"
113
+ if "divisible" in q or "remainder" in q or "prime" in q or "factor" in q:
114
+ return "number_theory"
115
+ if any(k in q for k in ["circle", "triangle", "perimeter", "area", "circumference"]):
116
+ return "geometry"
117
+ if any(k in q for k in ["mean", "median", "average", "sales", "revenue"]):
118
+ return "statistics" if c == "Quantitative" else "data"
119
+ if has_algebra_form or "what is x" in q or "what is y" in q or "integer" in q:
120
+ return "algebra"
121
 
122
+ if c == "DataInsight":
123
+ return "data"
124
+ if c == "Verbal":
125
+ return "verbal"
126
+ if c == "Quantitative":
127
+ return "quant"
128
 
129
+ return "general"
130
+
131
+
132
+ def _safe_steps(steps: List[str]) -> List[str]:
133
+ cleaned: List[str] = []
134
+ banned_patterns = [
135
+ r"\bthe answer is\b",
136
+ r"\banswer:\b",
137
+ r"\bthat gives\b",
138
+ r"\btherefore\b",
139
+ r"\bso x\s*=",
140
+ r"\bso y\s*=",
141
+ r"\bx\s*=",
142
+ r"\by\s*=",
143
+ r"\bresult is\b",
144
+ r"\bfinal answer\b",
145
+ ]
146
 
147
+ for step in steps:
148
+ s = (step or "").strip()
149
+ lowered = s.lower()
150
+ if any(re.search(p, lowered) for p in banned_patterns):
151
+ continue
152
+ cleaned.append(s)
 
153
 
154
+ return cleaned
155
 
 
 
 
 
 
 
 
 
 
156
 
157
+ def _normalize_text(text: str) -> str:
158
+ return re.sub(r"\s+", " ", (text or "").strip().lower())
159
 
 
 
160
 
161
+ def _extract_keywords(text: str) -> Set[str]:
162
+ raw = re.findall(r"[a-zA-Z][a-zA-Z0-9_+-]*", (text or "").lower())
163
+ stop = {
164
+ "the", "a", "an", "is", "are", "to", "of", "for", "and", "or", "in", "on", "at", "by", "this", "that",
165
+ "it", "be", "do", "i", "me", "my", "you", "how", "what", "why", "give", "show", "please", "can",
166
+ }
167
+ return {w for w in raw if len(w) > 2 and w not in stop}
168
 
169
+
170
+ def _safe_meta_list(items: Any) -> List[str]:
171
+ if not items:
172
  return []
173
+ if isinstance(items, list):
174
+ return [str(x).strip() for x in items if str(x).strip()]
175
+ if isinstance(items, tuple):
176
+ return [str(x).strip() for x in items if str(x).strip()]
177
+ if isinstance(items, str):
178
+ text = items.strip()
179
  return [text] if text else []
180
  return []
181
 
182
 
183
+ def _safe_meta_text(value: Any) -> Optional[str]:
184
+ if value is None:
185
+ return None
186
+ text = str(value).strip()
187
+ return text or None
188
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
189
 
190
+ def _extract_explainer_scaffold(explainer_result: Any) -> Dict[str, Any]:
191
+ scaffold = getattr(explainer_result, "scaffold", None)
192
 
193
+ if scaffold is None:
194
+ return {}
195
+
196
+ return {
197
+ "concept": _safe_meta_text(getattr(scaffold, "concept", None)),
198
+ "ask": _safe_meta_text(getattr(scaffold, "ask", None)),
199
+ "givens": _safe_meta_list(getattr(scaffold, "givens", [])),
200
+ "target": _safe_meta_text(getattr(scaffold, "target", None)),
201
+ "setup_actions": _safe_meta_list(getattr(scaffold, "setup_actions", [])),
202
+ "intermediate_steps": _safe_meta_list(getattr(scaffold, "intermediate_steps", [])),
203
+ "first_move": _safe_meta_text(getattr(scaffold, "first_move", None)),
204
+ "next_hint": _safe_meta_text(getattr(scaffold, "next_hint", None)),
205
+ "common_traps": _safe_meta_list(getattr(scaffold, "common_traps", [])),
206
+ "variables_to_define": _safe_meta_list(getattr(scaffold, "variables_to_define", [])),
207
+ "equations_to_form": _safe_meta_list(getattr(scaffold, "equations_to_form", [])),
208
+ "answer_hidden": bool(getattr(scaffold, "answer_hidden", True)),
209
+ "solution_path_type": _safe_meta_text(getattr(scaffold, "solution_path_type", None)),
210
+ "key_operations": _safe_meta_list(getattr(scaffold, "key_operations", [])),
211
+ "hint_ladder": _safe_meta_list(getattr(scaffold, "hint_ladder", [])),
212
+ }
213
+
214
+
215
+ def _build_scaffold_reply(
216
+ intent: str,
 
 
 
 
 
 
 
 
 
 
 
 
 
217
  help_mode: str,
218
+ scaffold: Dict[str, Any],
219
+ summary: Optional[str],
220
+ teaching_points: List[str],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
221
  verbosity: float,
222
  transparency: float,
223
+ ) -> Optional[str]:
224
+ if not scaffold and not summary and not teaching_points:
225
+ return None
226
+
227
+ ask = _safe_meta_text(scaffold.get("ask")) if scaffold else None
228
+ first_move = _safe_meta_text(scaffold.get("first_move")) if scaffold else None
229
+ next_hint = _safe_meta_text(scaffold.get("next_hint")) if scaffold else None
230
+ setup_actions = _safe_meta_list(scaffold.get("setup_actions", [])) if scaffold else []
231
+ intermediate_steps = _safe_meta_list(scaffold.get("intermediate_steps", [])) if scaffold else []
232
+
233
+ target_mode = help_mode or intent
234
+
235
+ if target_mode == "hint" or intent == "hint":
236
+ return first_move or next_hint or (setup_actions[0] if setup_actions else None) or ask or (teaching_points[0] if teaching_points else None)
237
+
238
+ if target_mode == "instruction" or intent == "instruction":
 
 
 
 
 
 
 
 
 
 
 
 
 
239
  if first_move:
240
+ return f"First step: {first_move}"
241
+ if setup_actions:
242
+ return f"First step: {setup_actions[0]}"
243
+ if ask:
244
+ return f"First, identify this: {ask}"
245
+ return None
246
+
247
+ if target_mode == "definition" or intent == "definition":
248
+ if summary:
249
+ return summary
250
+ if teaching_points:
251
+ return f"Here is the idea in context:\n- {teaching_points[0]}"
252
+ if ask:
253
+ return ask
254
+ return None
255
+
256
+ if target_mode in {"walkthrough", "step_by_step"} or intent in {"walkthrough", "step_by_step"}:
257
+ sequence: List[str] = []
258
+ if ask:
259
+ sequence.append(f"Identify this first: {ask}")
260
+ if first_move and first_move not in sequence:
261
+ sequence.append(first_move)
262
+ sequence.extend(setup_actions)
263
+ sequence.extend(intermediate_steps)
264
+ if next_hint and next_hint not in sequence:
265
+ sequence.append(next_hint)
266
+
267
+ if not sequence and summary:
268
+ sequence.append(summary)
269
+
270
+ if not sequence and teaching_points:
271
+ sequence.extend(teaching_points[:3])
272
+
273
+ if not sequence:
274
+ return None
275
+
276
+ shown = sequence[:1] if verbosity < 0.25 else sequence[:2] if verbosity < 0.6 else sequence[:4] if verbosity < 0.85 else sequence[:6]
277
+ return "\n".join(f"- {s}" for s in shown)
278
+
279
+ if target_mode in {"method", "concept", "explain"} or intent in {"method", "concept", "explain"}:
280
+ lines: List[str] = []
281
+ if summary:
282
+ lines.append(summary)
283
+ if ask:
284
+ lines.append(f"Start by identifying: {ask}")
285
+ if first_move:
286
+ lines.append(first_move)
287
+ lines.extend(setup_actions[:2])
288
+ if transparency >= 0.45:
289
+ lines.extend(intermediate_steps[:2])
290
+ if next_hint and transparency >= 0.65:
291
+ lines.append(f"Next idea: {next_hint}")
292
+
293
+ if not lines and teaching_points:
294
+ lines.extend(teaching_points[:2])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
295
 
296
+ if not lines:
297
+ return None
 
 
 
298
 
299
+ return "\n".join(f"- {s}" for s in lines)
 
300
 
301
+ return first_move or (setup_actions[0] if setup_actions else None) or summary or (teaching_points[0] if teaching_points else None)
 
 
302
 
303
+
304
+ def _compose_reply(
305
+ result: SolverResult,
306
+ intent: str,
307
+ verbosity: float,
308
+ category: Optional[str] = None,
309
+ ) -> str:
310
+ steps = _safe_steps(result.steps or [])
311
+ topic = (result.topic or "").lower().strip()
312
+ meta = result.meta or {}
313
+
314
+ scaffold_reply = _build_scaffold_reply(
315
+ intent=intent,
316
+ help_mode=result.help_mode,
317
+ scaffold=meta.get("scaffold", {}) if isinstance(meta, dict) else {},
318
+ summary=_safe_meta_text(meta.get("explainer_summary")) if isinstance(meta, dict) else None,
319
+ teaching_points=_safe_meta_list(meta.get("explainer_teaching_points", [])) if isinstance(meta, dict) else [],
320
  verbosity=verbosity,
321
+ transparency=0.5,
322
  )
323
+
324
+ if intent == "hint":
325
+ return scaffold_reply or (steps[0] if steps else "Start by identifying the main relationship in the problem.")
326
+
327
+ if intent == "instruction":
328
+ return scaffold_reply or (f"First step: {steps[0]}" if steps else "First, identify the key relationship or comparison in the question.")
329
+
330
+ if intent == "definition":
331
+ return scaffold_reply or (f"Here is the idea in context:\n- {steps[0]}" if steps else "This is asking for the meaning of the term or idea in the question.")
332
+
333
+ if intent in {"walkthrough", "step_by_step", "explain", "method", "concept"}:
334
+ if scaffold_reply:
335
+ return scaffold_reply
336
+ if steps:
337
+ shown_steps = steps[:1] if verbosity < 0.25 else steps[:2] if verbosity < 0.6 else steps[:3] if verbosity < 0.85 else steps
338
+ return "\n".join(f"- {s}" for s in shown_steps)
339
+
340
+ if steps:
341
+ shown_steps = steps[:1] if verbosity < 0.35 else steps[:2]
342
+ return shown_steps[0] if len(shown_steps) == 1 else "\n".join(f"- {s}" for s in shown_steps)
343
+
344
+ if scaffold_reply:
345
+ return scaffold_reply
346
+
347
+ if normalize_category(category) == "Verbal":
348
+ return "I can help analyse the wording or logic, but I need the full question text to guide you properly."
349
+
350
+ if normalize_category(category) == "DataInsight":
351
+ return "I can help reason through the data, but I need the full question or chart details to guide you properly."
352
+
353
+ return "I need the full question wording to guide this properly."
354
+
355
+
356
+ def _is_direct_solve_request(text: str, intent: str) -> bool:
357
+ if intent == "answer":
358
+ return True
359
+
360
+ t = _normalize_text(text)
361
+ if any(re.search(p, t) for p in DIRECT_SOLVE_PATTERNS):
362
+ if not any(word in t for word in ["how", "explain", "why", "method", "hint", "define", "definition", "step"]):
363
+ return True
364
+ return False
365
+
366
+
367
+ def _is_progression_request(text: str) -> bool:
368
+ t = (text or "").strip().lower()
369
+ return any(
370
+ phrase in t
371
+ for phrase in [
372
+ "hint",
373
+ "next hint",
374
+ "another hint",
375
+ "next step",
376
+ "first step",
377
+ "walk me through",
378
+ "step by step",
379
+ "show the steps",
380
+ "go on",
381
+ "continue",
382
+ ]
383
  )
384
 
 
 
 
385
 
386
+ def _history_hint_stage(chat_history: Optional[List[Dict[str, Any]]]) -> int:
387
+ if not chat_history:
388
+ return 0
389
+
390
+ best = 0
391
+ for item in chat_history:
392
+ if not isinstance(item, dict):
393
+ continue
394
+
395
+ if "hint_stage" in item:
396
+ try:
397
+ best = max(best, int(item["hint_stage"]))
398
+ except Exception:
399
+ pass
400
+
401
+ meta = item.get("meta")
402
+ if isinstance(meta, dict) and "hint_stage" in meta:
403
+ try:
404
+ best = max(best, int(meta["hint_stage"]))
405
+ except Exception:
406
+ pass
407
+
408
+ return min(best, 3)
409
+
410
+
411
+ def _resolve_hint_stage(
412
+ user_text: str,
413
+ intent: str,
414
+ help_mode: str,
415
+ chat_history: Optional[List[Dict[str, Any]]],
416
+ ) -> int:
417
+ history_stage = _history_hint_stage(chat_history)
418
+ low = (user_text or "").strip().lower()
419
+
420
+ if help_mode == "definition":
421
+ return 0
422
+
423
+ if help_mode in {"walkthrough", "step_by_step"}:
424
+ return 3
425
+
426
+ if _is_direct_solve_request(user_text, intent):
427
+ return 3
428
+
429
+ if any(p in low for p in ["next hint", "another hint", "next step", "continue", "go on"]):
430
+ return min(history_stage + 1, 3)
431
+
432
+ if "first step" in low:
433
+ return max(history_stage, 1)
434
+
435
+ if "hint" in low:
436
+ return max(history_stage, 1)
437
+
438
+ if intent in {"method", "instruction", "explain", "concept"}:
439
+ return max(history_stage, 1)
440
+
441
+ if _is_progression_request(low):
442
+ return min(max(history_stage, 1), 3)
443
+
444
+ return history_stage
445
+
446
+
447
+ class ConversationEngine:
448
+ def __init__(
449
+ self,
450
+ retriever: Optional[RetrievalEngine] = None,
451
+ generator: Optional[GeneratorEngine] = None,
452
+ **kwargs,
453
+ ) -> None:
454
+ self.retriever = retriever
455
+ self.generator = generator
456
+
457
+ def generate_response(
458
+ self,
459
+ raw_user_text: Optional[str] = None,
460
+ tone: float = 0.5,
461
+ verbosity: float = 0.5,
462
+ transparency: float = 0.5,
463
+ intent: Optional[str] = None,
464
+ help_mode: Optional[str] = None,
465
+ retrieval_context: Optional[List[RetrievedChunk]] = None,
466
+ chat_history: Optional[List[Dict[str, Any]]] = None,
467
+ question_text: Optional[str] = None,
468
+ options_text: Optional[List[str]] = None,
469
+ **kwargs,
470
+ ) -> SolverResult:
471
+ solver_input = (question_text or raw_user_text or "").strip()
472
+ user_text = (raw_user_text or "").strip()
473
+
474
+ category = normalize_category(kwargs.get("category"))
475
+ classification = classify_question(question_text=solver_input, category=category)
476
+ inferred_category = normalize_category(classification.get("category") or category)
477
+
478
+ question_topic = _normalize_classified_topic(
479
+ classification.get("topic"),
480
+ inferred_category,
481
+ solver_input,
482
+ )
483
+
484
+ resolved_intent = intent or detect_intent(user_text, help_mode)
485
+ resolved_help_mode = help_mode or intent_to_help_mode(resolved_intent)
486
+
487
+ is_quant = inferred_category == "Quantitative" or is_quant_question(solver_input)
488
+
489
+ result = SolverResult(
490
+ domain="quant" if is_quant else "general",
491
+ solved=False,
492
+ help_mode=resolved_help_mode,
493
+ answer_letter=None,
494
+ answer_value=None,
495
+ topic=question_topic,
496
+ used_retrieval=False,
497
+ used_generator=False,
498
+ internal_answer=None,
499
+ steps=[],
500
+ teaching_chunks=[],
501
+ meta={},
502
+ )
503
+
504
+ explainer_result = route_explainer(solver_input)
505
+ explainer_understood = bool(explainer_result is not None and getattr(explainer_result, "understood", False))
506
+ explainer_scaffold = _extract_explainer_scaffold(explainer_result) if explainer_understood else {}
507
+ explainer_summary = getattr(explainer_result, "summary", None) if explainer_understood else None
508
+ explainer_teaching_points = _safe_meta_list(getattr(explainer_result, "teaching_points", [])) if explainer_understood else []
509
+
510
+ if is_quant:
511
+ solved_result = route_solver(solver_input)
512
+ if solved_result is not None:
513
+ result = solved_result
514
+
515
+ result.help_mode = resolved_help_mode
516
+ result.domain = "quant"
517
+
518
+ if not result.topic or result.topic in {"general_quant", "general", "unknown"}:
519
+ result.topic = getattr(explainer_result, "topic", None) if explainer_understood else question_topic
520
+
521
+ result.meta = result.meta or {}
522
+
523
+ if explainer_understood:
524
+ result.meta["explainer_used"] = True
525
+ result.meta["bridge_ready"] = bool(getattr(explainer_result, "meta", {}).get("bridge_ready", False))
526
+ result.meta["hint_style"] = getattr(explainer_result, "meta", {}).get("hint_style")
527
+ result.meta["explainer_summary"] = explainer_summary
528
+ result.meta["explainer_teaching_points"] = explainer_teaching_points
529
+ result.meta["scaffold"] = explainer_scaffold
530
+
531
+ hint_stage = _resolve_hint_stage(
532
+ user_text=user_text or solver_input,
533
+ intent=resolved_intent,
534
+ help_mode=resolved_help_mode,
535
+ chat_history=chat_history,
536
+ )
537
+
538
+ result.meta["hint_stage"] = hint_stage
539
+ result.meta["max_stage"] = 3
540
+ result.meta["can_reveal_answer"] = bool(
541
+ result.solved and _is_direct_solve_request(user_text or solver_input, resolved_intent) and hint_stage >= 3
542
+ )
543
+
544
+ if explainer_understood:
545
+ reply = format_explainer_response(
546
+ result=explainer_result,
547
+ tone=tone,
548
+ verbosity=verbosity,
549
+ transparency=transparency,
550
+ hint_stage=hint_stage,
551
+ )
552
+ else:
553
+ reply = _compose_reply(
554
+ result=result,
555
+ intent=resolved_intent,
556
+ verbosity=verbosity,
557
+ category=inferred_category,
558
+ )
559
+ reply = format_reply(
560
+ reply,
561
+ tone=tone,
562
+ verbosity=verbosity,
563
+ transparency=transparency,
564
+ help_mode=resolved_help_mode,
565
+ hint_stage=hint_stage,
566
+ topic=result.topic,
567
+ )
568
+
569
+ if not result.meta.get("can_reveal_answer", False):
570
+ result.answer_letter = None
571
+ result.answer_value = None
572
+ result.internal_answer = None
573
+
574
+ result.reply = reply
575
+ result.help_mode = resolved_help_mode
576
+ result.meta["intent"] = resolved_intent
577
+ result.meta["question_text"] = question_text or ""
578
+ result.meta["options_count"] = len(options_text or [])
579
+ result.meta["category"] = inferred_category
580
+ result.meta["classified_topic"] = question_topic
581
+
582
+ return result