j-js commited on
Commit
dda1483
·
verified ·
1 Parent(s): 3198c6f

Update conversation_logic.py

Browse files
Files changed (1) hide show
  1. conversation_logic.py +496 -412
conversation_logic.py CHANGED
@@ -1,436 +1,520 @@
1
  from __future__ import annotations
2
 
3
- import math
4
  import re
5
- from statistics import mean, median
6
- from typing import Dict, List, Optional, Tuple
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
 
8
- try:
9
- import sympy as sp
10
- except Exception:
11
- sp = None
12
 
13
- from models import SolverResult
14
- from utils import clean_math_text, normalize_spaces
15
 
16
-
17
- def extract_choices(text: str) -> Dict[str, str]:
18
- text = text or ""
19
- matches = list(
20
- re.finditer(
21
- r"(?i)\b([A-E])[\)\.:]\s*(.*?)(?=\s+\b[A-E][\)\.:]\s*|$)",
22
- text,
23
- )
24
- )
25
- return {m.group(1).upper(): normalize_spaces(m.group(2)) for m in matches}
26
 
27
 
28
- def has_answer_choices(text: str) -> bool:
29
- return len(extract_choices(text)) >= 3
30
 
31
 
32
- def is_quant_question(text: str) -> bool:
33
- lower = clean_math_text(text).lower()
34
- keywords = [
35
- "solve", "equation", "percent", "ratio", "probability", "mean", "median",
36
- "average", "sum", "difference", "product", "quotient", "triangle", "circle",
37
- "rectangle", "area", "perimeter", "volume", "algebra", "integer", "divisible",
38
- "number", "fraction", "decimal", "geometry", "distance", "speed", "work",
39
- "remainder", "discount",
40
- ]
41
- if any(k in lower for k in keywords):
42
- return True
43
- if "=" in lower and re.search(r"[a-z]", lower):
44
- return True
45
- if re.search(r"\d", lower) and ("?" in lower or has_answer_choices(lower)):
46
  return True
 
 
 
 
 
47
  return False
48
 
49
 
50
- def _prepare_expression(expr: str) -> str:
51
- expr = clean_math_text(expr).strip()
52
- expr = expr.replace("^", "**")
53
- expr = re.sub(r"(\d)\s*\(", r"\1*(", expr)
54
- expr = re.sub(r"\)\s*(\d)", r")*\1", expr)
55
- expr = re.sub(r"(\d)([a-zA-Z])", r"\1*\2", expr)
56
- return expr
57
-
58
-
59
- def _extract_equation(text: str) -> Optional[str]:
60
- cleaned = clean_math_text(text)
61
- if "=" not in cleaned:
62
- return None
63
-
64
- patterns = [
65
- r"([A-Za-z0-9\.\+\-\*/\^\(\)\s]*[a-zA-Z][A-Za-z0-9\.\+\-\*/\^\(\)\s]*=[A-Za-z0-9\.\+\-\*/\^\(\)\s]+)",
66
- r"([0-9A-Za-z\.\+\-\*/\^\(\)\s]+=[0-9A-Za-z\.\+\-\*/\^\(\)\s]+)",
67
- ]
68
-
69
- for pattern in patterns:
70
- for m in re.finditer(pattern, cleaned):
71
- candidate = m.group(1).strip()
72
- if re.search(r"[a-z]", candidate.lower()) and not candidate.lower().startswith(
73
- ("how do", "can you", "please", "what is", "solve ")
74
- ):
75
- return candidate
76
-
77
- eq_index = cleaned.find("=")
78
- left = re.findall(r"[A-Za-z0-9\.\+\-\*/\^\(\)\s]+$", cleaned[:eq_index])
79
- right = re.findall(r"^[A-Za-z0-9\.\+\-\*/\^\(\)\s]+", cleaned[eq_index + 1:])
80
- if left and right:
81
- candidate = left[0].strip().split()[-1] + " = " + right[0].strip().split()[0]
82
- if re.search(r"[a-z]", candidate.lower()):
83
- return candidate
84
- return None
85
-
86
-
87
- def _parse_number(text: str) -> Optional[float]:
88
- raw = clean_math_text(text).strip().lower()
89
-
90
- pct = re.fullmatch(r"(-?\d+(?:\.\d+)?)%", raw.replace(" ", ""))
91
- if pct:
92
- return float(pct.group(1)) / 100.0
93
-
94
- frac = re.fullmatch(r"(-?\d+)\s*/\s*(-?\d+)", raw)
95
- if frac:
96
- den = float(frac.group(2))
97
- if den == 0:
98
- return None
99
- return float(frac.group(1)) / den
100
-
101
- try:
102
- return float(
103
- eval(
104
- _prepare_expression(raw),
105
- {"__builtins__": {}},
106
- {"sqrt": math.sqrt, "pi": math.pi},
107
- )
108
- )
109
- except Exception:
110
- return None
111
-
112
-
113
- def _best_choice(answer_value: float, choices: Dict[str, str]) -> Optional[str]:
114
- best_letter = None
115
- best_diff = float("inf")
116
-
117
- for letter, raw in choices.items():
118
- parsed = _parse_number(raw)
119
- if parsed is None:
120
- continue
121
- diff = abs(parsed - answer_value)
122
- if diff < best_diff:
123
- best_diff = diff
124
- best_letter = letter
125
-
126
- if best_letter is not None and best_diff <= 1e-6:
127
- return best_letter
128
- return None
129
-
130
-
131
- def _make_result(
132
- *,
133
- topic: str,
134
- answer_value: str,
135
- internal_answer: Optional[str] = None,
136
- steps: Optional[List[str]] = None,
137
- choices_text: str = "",
138
- ) -> SolverResult:
139
- answer_float = _parse_number(answer_value)
140
- choices = extract_choices(choices_text)
141
- answer_letter = _best_choice(answer_float, choices) if (answer_float is not None and choices) else None
142
-
143
- return SolverResult(
144
- domain="quant",
145
- solved=True,
146
- topic=topic,
147
- answer_value=answer_value,
148
- answer_letter=answer_letter,
149
- internal_answer=internal_answer or answer_value,
150
- steps=steps or [],
151
- )
152
-
153
-
154
- def _solve_successive_percent(text: str) -> Optional[SolverResult]:
155
- lower = clean_math_text(text).lower()
156
-
157
- pattern = re.findall(
158
- r"(increase|decrease|discount|mark(?:ed)?\s*up|mark(?:ed)?\s*down|rise|fall)\s+by\s+(\d+(?:\.\d+)?)\s*(?:%|percent)",
159
- lower,
160
- )
161
- if len(pattern) < 2:
162
- pattern = re.findall(
163
- r"(\d+(?:\.\d+)?)\s*(?:%|percent)\s+(increase|decrease|discount|rise|fall)",
164
- lower,
165
- )
166
- pattern = [(op, pct) for pct, op in pattern]
167
-
168
- if len(pattern) < 2:
169
- return None
170
-
171
- multiplier = 1.0
172
- step_lines: List[str] = []
173
-
174
- for op, pct_raw in pattern:
175
- pct = float(pct_raw)
176
- if any(k in op for k in ["decrease", "discount", "down", "fall"]):
177
- factor = 1 - pct / 100.0
178
- step_lines.append(f"A {pct:g}% decrease means multiply by {factor:g}.")
179
- else:
180
- factor = 1 + pct / 100.0
181
- step_lines.append(f"A {pct:g}% increase means multiply by {factor:g}.")
182
- multiplier *= factor
183
-
184
- net_change = (multiplier - 1.0) * 100.0
185
- direction = "increase" if net_change >= 0 else "decrease"
186
- magnitude = abs(net_change)
187
-
188
- return _make_result(
189
- topic="percent",
190
- answer_value=f"{magnitude:g}%",
191
- internal_answer=f"net {direction} of {magnitude:g}%",
192
- steps=step_lines + [f"The combined multiplier gives a net {direction} of {magnitude:g}%."],
193
- choices_text=text,
194
- )
195
-
196
-
197
- def _extract_ratio_labels(text: str) -> Optional[Tuple[str, str]]:
198
- m = re.search(r"ratio of ([a-z ]+?) to ([a-z ]+?) is \d+\s*:\s*\d+", text.lower())
199
- if not m:
200
- return None
201
- left = normalize_spaces(m.group(1)).rstrip("s")
202
- right = normalize_spaces(m.group(2)).rstrip("s")
203
- return left, right
204
-
205
-
206
- def _solve_ratio_total(text: str) -> Optional[SolverResult]:
207
- lower = clean_math_text(text).lower()
208
-
209
- ratio_match = re.search(r"(\d+)\s*:\s*(\d+)", lower)
210
- total_match = re.search(r"(?:total|altogether|in all|sum)\s*(?:is|=|of)?\s*(\d+)", lower)
211
-
212
- if not ratio_match or not total_match:
213
- return None
214
-
215
- a = int(ratio_match.group(1))
216
- b = int(ratio_match.group(2))
217
- total = int(total_match.group(1))
218
-
219
- part_sum = a + b
220
- if part_sum == 0:
221
- return None
222
-
223
- unit = total / part_sum
224
- left_value = a * unit
225
- right_value = b * unit
226
-
227
- labels = _extract_ratio_labels(lower)
228
- requested_value = left_value
229
- requested_label = "first quantity"
230
-
231
- if labels:
232
- left_label, right_label = labels
233
- if left_label in lower and re.search(rf"how many {re.escape(left_label)}", lower):
234
- requested_value = left_value
235
- requested_label = left_label
236
- elif right_label in lower and re.search(rf"how many {re.escape(right_label)}", lower):
237
- requested_value = right_value
238
- requested_label = right_label
239
- else:
240
- requested_value = left_value
241
- requested_label = left_label
242
-
243
- return _make_result(
244
- topic="ratio",
245
- answer_value=f"{requested_value:g}",
246
- internal_answer=f"{requested_label} = {requested_value:g}",
247
- steps=[
248
- f"Add the ratio parts: {a} + {b} = {part_sum}.",
249
- f"Each ratio unit is {total} / {part_sum} = {unit:g}.",
250
- f"Multiply by the required ratio part to get {requested_value:g}.",
251
- ],
252
- choices_text=text,
253
- )
254
-
255
-
256
- def _solve_remainder(text: str) -> Optional[SolverResult]:
257
- lower = clean_math_text(text).lower()
258
-
259
- m = re.search(r"remainder .*? when (\d+) is divided by (\d+)", lower)
260
- if not m:
261
- m = re.search(r"(\d+)\s*(?:mod|%)\s*(\d+)", lower)
262
- if not m:
263
- return None
264
-
265
- a = int(m.group(1))
266
- b = int(m.group(2))
267
- if b == 0:
268
- return None
269
-
270
- r = a % b
271
-
272
- return _make_result(
273
- topic="number_theory",
274
- answer_value=str(r),
275
- internal_answer=str(r),
276
- steps=[
277
- f"Divide {a} by {b}.",
278
- f"The remainder is {a} mod {b} = {r}.",
279
- ],
280
- choices_text=text,
281
- )
282
-
283
-
284
- def _solve_percent(text: str) -> Optional[SolverResult]:
285
- lower = clean_math_text(text).lower()
286
- choices = extract_choices(text)
287
-
288
- m = re.search(r"(\d+(?:\.\d+)?)\s*(?:%|percent)\s+of\s+(?:a\s+)?number\s+is\s+(\d+(?:\.\d+)?)", lower)
289
- if m:
290
- p = float(m.group(1))
291
- value = float(m.group(2))
292
- ans = value / (p / 100.0)
293
- answer_letter = _best_choice(ans, choices) if choices else None
294
-
295
- return SolverResult(
296
- domain="quant",
297
- solved=True,
298
- topic="percent",
299
- answer_value=f"{ans:g}",
300
- answer_letter=answer_letter,
301
- internal_answer=f"{ans:g}",
302
- steps=[
303
- "Let the number be n.",
304
- f"Write {p}% of n as {p / 100:g}n.",
305
- f"Set {p / 100:g}n = {value} and solve for n.",
306
- ],
307
- )
308
 
309
- m = re.search(r"what is\s+(\d+(?:\.\d+)?)\s*(?:%|percent)\s+of\s+(\d+(?:\.\d+)?)", lower)
310
- if m:
311
- p = float(m.group(1))
312
- n = float(m.group(2))
313
- ans = p / 100.0 * n
314
- answer_letter = _best_choice(ans, choices) if choices else None
315
-
316
- return SolverResult(
317
- domain="quant",
318
- solved=True,
319
- topic="percent",
320
- answer_value=f"{ans:g}",
321
- answer_letter=answer_letter,
322
- internal_answer=f"{ans:g}",
323
- steps=[
324
- f"Convert {p}% to {p / 100:g}.",
325
- f"Multiply by {n}.",
326
- ],
327
- )
328
 
329
- return None
 
330
 
 
 
331
 
332
- def _solve_mean_median(text: str) -> Optional[SolverResult]:
333
- lower = clean_math_text(text).lower()
334
- nums = [float(n) for n in re.findall(r"-?\d+(?:\.\d+)?", lower)]
335
- if not nums:
336
- return None
337
 
338
- if "mean" in lower or "average" in lower:
339
- ans = mean(nums)
340
- return SolverResult(
341
- domain="quant",
342
- solved=True,
343
- topic="statistics",
344
- answer_value=f"{ans:g}",
345
- internal_answer=f"{ans:g}",
346
- steps=["Add the values.", f"Divide by {len(nums)}."],
347
- )
348
 
349
- if "median" in lower:
350
- ans = median(nums)
351
- return SolverResult(
352
- domain="quant",
353
- solved=True,
354
- topic="statistics",
355
- answer_value=f"{ans:g}",
356
- internal_answer=f"{ans:g}",
357
- steps=["Order the values.", "Take the middle value."],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
358
  )
359
 
360
- return None
361
-
362
-
363
- def _solve_linear_equation(text: str) -> Optional[SolverResult]:
364
- if sp is None:
365
- return None
366
-
367
- expr = _extract_equation(text)
368
- if not expr:
369
- return None
370
-
371
- try:
372
- lhs, rhs = expr.split("=", 1)
373
- symbols = sorted(set(re.findall(r"\b[a-z]\b", expr)))
374
- if not symbols:
375
- return None
376
-
377
- var_name = symbols[0]
378
- var = sp.symbols(var_name)
379
- sol = sp.solve(
380
- sp.Eq(sp.sympify(_prepare_expression(lhs)), sp.sympify(_prepare_expression(rhs))),
381
- var,
382
  )
383
- if not sol:
384
- return None
385
-
386
- value = sol[0]
387
- try:
388
- as_float = float(value)
389
- except Exception:
390
- as_float = None
391
-
392
- choices = extract_choices(text)
393
-
394
- return SolverResult(
395
- domain="quant",
396
- solved=True,
397
- topic="algebra",
398
- answer_value=str(value),
399
- answer_letter=_best_choice(as_float, choices) if (as_float is not None and choices) else None,
400
- internal_answer=f"{var_name} = {value}",
401
- steps=[
402
- "Treat the statement as an equation.",
403
- "Undo operations on both sides to isolate the variable.",
404
- f"That gives {var_name} = {value}.",
405
- ],
406
  )
407
- except Exception:
408
- return None
409
-
410
-
411
- def solve_quant(text: str) -> SolverResult:
412
- text = text or ""
413
-
414
- for fn in (
415
- _solve_successive_percent,
416
- _solve_ratio_total,
417
- _solve_remainder,
418
- _solve_percent,
419
- _solve_mean_median,
420
- _solve_linear_equation,
421
- ):
422
- result = fn(text)
423
- if result is not None:
424
- return result
425
-
426
- return SolverResult(
427
- domain="quant",
428
- solved=False,
429
- topic="general_quant",
430
- reply="This looks quantitative, but it does not match a strong rule-based pattern yet.",
431
- steps=[
432
- "Identify the quantity the question wants.",
433
- "Translate the wording into an equation, ratio, or diagram.",
434
- "Carry out the calculation carefully.",
435
- ],
436
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
8
+ from generator_engine import GeneratorEngine
9
+ from models import RetrievedChunk, SolverResult
10
+ from quant_solver import is_quant_question, solve_quant
11
+ from question_classifier import classify_question, normalize_category
12
+ from retrieval_engine import RetrievalEngine
13
+
14
+
15
+ RETRIEVAL_ALLOWED_INTENTS = {
16
+ "walkthrough",
17
+ "step_by_step",
18
+ "explain",
19
+ "method",
20
+ "hint",
21
+ "definition",
22
+ "concept",
23
+ "instruction",
24
+ }
25
+
26
+ DIRECT_SOLVE_PATTERNS = [
27
+ r"\bsolve\b",
28
+ r"\bwhat is\b",
29
+ r"\bfind\b",
30
+ r"\bgive (?:me )?the answer\b",
31
+ r"\bjust the answer\b",
32
+ r"\banswer only\b",
33
+ r"\bcalculate\b",
34
+ ]
35
+
36
+ STRUCTURE_KEYWORDS = {
37
+ "algebra": ["equation", "solve", "isolate", "variable", "linear", "expression", "unknown", "algebra"],
38
+ "percent": ["percent", "%", "percentage", "increase", "decrease"],
39
+ "ratio": ["ratio", "proportion", "part", "share"],
40
+ "statistics": ["mean", "median", "mode", "range", "average"],
41
+ "probability": ["probability", "chance", "odds"],
42
+ "geometry": ["triangle", "circle", "angle", "area", "perimeter", "radius", "diameter"],
43
+ "number_theory": ["integer", "odd", "even", "prime", "divisible", "factor", "multiple", "remainder"],
44
+ "sequence": ["sequence", "geometric", "arithmetic", "term", "series"],
45
+ "quant": ["equation", "solve", "value", "integer", "ratio", "percent"],
46
+ "data": ["data", "mean", "median", "trend", "chart", "table", "correlation"],
47
+ "verbal": ["grammar", "meaning", "author", "argument", "sentence", "word"],
48
+ }
49
+
50
+ INTENT_KEYWORDS = {
51
+ "walkthrough": ["walkthrough", "work through", "step by step", "full working"],
52
+ "step_by_step": ["step", "first step", "next step", "step by step"],
53
+ "explain": ["explain", "why", "understand"],
54
+ "method": ["method", "approach", "how do i solve", "how to solve"],
55
+ "hint": ["hint", "nudge", "clue"],
56
+ "definition": ["define", "definition", "what does", "what is meant by"],
57
+ "concept": ["concept", "idea", "principle", "rule"],
58
+ "instruction": ["how do i", "how to", "what should i do first", "what step", "first step"],
59
+ }
60
+
61
+ MISMATCH_TERMS = {
62
+ "algebra": ["absolute value", "modulus", "square root", "quadratic", "inequality", "roots", "parabola"],
63
+ "percent": ["triangle", "circle", "prime", "absolute value"],
64
+ "ratio": ["absolute value", "quadratic", "circle"],
65
+ "statistics": ["absolute value", "prime", "triangle"],
66
+ "probability": ["absolute value", "circle area", "quadratic"],
67
+ "geometry": ["absolute value", "prime", "median salary"],
68
+ "number_theory": ["circle", "triangle", "median salary"],
69
+ }
70
+
71
+
72
+ def _normalize_classified_topic(topic: Optional[str], category: Optional[str], question_text: str) -> str:
73
+ t = (topic or "").strip().lower()
74
+ q = (question_text or "").lower()
75
+ c = normalize_category(category)
76
+
77
+ if t not in {"general_quant", "general", "unknown", ""}:
78
+ return t
79
+
80
+ if "%" in q or "percent" in q:
81
+ return "percent"
82
+ if "ratio" in q or ":" in q:
83
+ return "ratio"
84
+ if "probability" in q or "chosen at random" in q:
85
+ return "probability"
86
+ if "divisible" in q or "remainder" in q or "prime" in q or "factor" in q:
87
+ return "number_theory"
88
+ if any(k in q for k in ["circle", "triangle", "perimeter", "area", "circumference"]):
89
+ return "geometry"
90
+ if any(k in q for k in ["mean", "median", "average", "sales", "revenue"]):
91
+ return "statistics" if c == "Quantitative" else "data"
92
+ if "=" in q or "what is x" in q or "what is y" in q or "integer" in q:
93
+ return "algebra"
94
+
95
+ if c == "DataInsight":
96
+ return "data"
97
+ if c == "Verbal":
98
+ return "verbal"
99
+ if c == "Quantitative":
100
+ return "quant"
101
+
102
+ return "general"
103
+
104
+
105
+ def _teaching_lines(chunks: List[RetrievedChunk]) -> List[str]:
106
+ lines: List[str] = []
107
+ for chunk in chunks:
108
+ text = (chunk.text or "").strip().replace("\n", " ")
109
+ if len(text) > 220:
110
+ text = text[:217].rstrip() + "…"
111
+ topic = chunk.topic or "general"
112
+ lines.append(f"- {topic}: {text}")
113
+ return lines
114
+
115
+
116
+ def _compose_reply(
117
+ result: SolverResult,
118
+ intent: str,
119
+ reveal_answer: bool,
120
+ verbosity: float,
121
+ category: Optional[str] = None,
122
+ ) -> str:
123
+ steps = result.steps or []
124
+ internal = result.internal_answer or result.answer_value or ""
125
+
126
+ if intent == "hint":
127
+ return steps[0] if steps else "Start by identifying what the question is really asking."
128
+
129
+ if intent == "instruction":
130
+ if steps:
131
+ return f"First step: {steps[0]}"
132
+ return "First, identify the key relationship or comparison in the question."
133
+
134
+ if intent == "definition":
135
+ if steps:
136
+ return f"Here is the idea in context:\n- {steps[0]}"
137
+ return "This is asking for the meaning of the term or idea in the question."
138
+
139
+ if intent in {"walkthrough", "step_by_step", "explain", "method", "concept"}:
140
+ if not steps:
141
+ if reveal_answer and internal:
142
+ return f"The result is {internal}."
143
+ return "I can explain the method, but I do not have enough structured steps yet."
144
+
145
+ shown_steps = steps if verbosity >= 0.66 else steps[: min(3, len(steps))]
146
+ body = "\n".join(f"- {s}" for s in shown_steps)
147
+
148
+ if reveal_answer and internal:
149
+ return f"{body}\n\nThat gives {internal}."
150
+ return body
151
+
152
+ if reveal_answer and internal:
153
+ if result.answer_value:
154
+ return f"The answer is {result.answer_value}."
155
+ return f"The result is {internal}."
156
+
157
+ if steps:
158
+ return steps[0]
159
+
160
+ if normalize_category(category) == "Verbal":
161
+ return "I can help analyse the wording or logic, but I do not have a full verbal solver yet."
162
+
163
+ if normalize_category(category) == "DataInsight":
164
+ return "I can help reason through the data, but I cannot confidently solve this from the current parse alone yet."
165
+
166
+ return "I can help with this, but I cannot confidently solve it from the current parse alone yet."
167
+
168
+
169
+ def _normalize_text(text: str) -> str:
170
+ return re.sub(r"\s+", " ", (text or "").strip().lower())
171
+
172
+
173
+ def _extract_keywords(text: str) -> Set[str]:
174
+ raw = re.findall(r"[a-zA-Z][a-zA-Z0-9_+-]*", (text or "").lower())
175
+ stop = {
176
+ "the", "a", "an", "is", "are", "to", "of", "for", "and", "or", "in", "on", "at", "by", "this", "that",
177
+ "it", "be", "do", "i", "me", "my", "you", "how", "what", "why", "give", "show", "please", "can",
178
+ }
179
+ return {w for w in raw if len(w) > 2 and w not in stop}
180
+
181
+
182
+ def _infer_structure_terms(question_text: str, topic: Optional[str], question_type: Optional[str]) -> List[str]:
183
+ terms: List[str] = []
184
+
185
+ if topic and topic in STRUCTURE_KEYWORDS:
186
+ terms.extend(STRUCTURE_KEYWORDS[topic])
187
+
188
+ if question_type:
189
+ terms.extend(question_type.replace("_", " ").split())
190
+
191
+ q = (question_text or "").lower()
192
+ if "=" in q:
193
+ terms.extend(["equation", "solve"])
194
+ if "x" in q or "y" in q:
195
+ terms.extend(["variable", "isolate"])
196
+ if "/" in q or "divide" in q:
197
+ terms.extend(["divide", "undo operations"])
198
+ if "*" in q or "times" in q or "multiply" in q:
199
+ terms.extend(["multiply", "undo operations"])
200
+ if "%" in q or "percent" in q:
201
+ terms.extend(["percent", "percentage"])
202
+ if "ratio" in q:
203
+ terms.extend(["ratio", "proportion"])
204
+ if "mean" in q or "average" in q:
205
+ terms.extend(["mean", "average"])
206
+ if "median" in q:
207
+ terms.extend(["median"])
208
+ if "probability" in q:
209
+ terms.extend(["probability"])
210
+ if "remainder" in q or "divisible" in q:
211
+ terms.extend(["remainder", "divisible"])
212
 
213
+ return list(dict.fromkeys(terms))
 
 
 
214
 
 
 
215
 
216
+ def _infer_mismatch_terms(topic: Optional[str], question_text: str) -> List[str]:
217
+ if not topic or topic not in MISMATCH_TERMS:
218
+ return []
219
+ q = (question_text or "").lower()
220
+ return [term for term in MISMATCH_TERMS[topic] if term not in q]
 
 
 
 
 
221
 
222
 
223
+ def _intent_keywords(intent: str) -> List[str]:
224
+ return INTENT_KEYWORDS.get(intent, [])
225
 
226
 
227
+ def _is_direct_solve_request(text: str, intent: str) -> bool:
228
+ if intent == "answer":
 
 
 
 
 
 
 
 
 
 
 
 
229
  return True
230
+
231
+ t = _normalize_text(text)
232
+ if any(re.search(p, t) for p in DIRECT_SOLVE_PATTERNS):
233
+ if not any(word in t for word in ["how", "explain", "why", "method", "hint", "define", "definition", "step"]):
234
+ return True
235
  return False
236
 
237
 
238
+ def should_retrieve(intent: str, solved: bool, raw_user_text: str, category: Optional[str] = None) -> bool:
239
+ normalized_category = normalize_category(category)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
240
 
241
+ if _is_direct_solve_request(raw_user_text, intent):
242
+ return (not solved) and normalized_category in {"Verbal", "DataInsight"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
243
 
244
+ if intent in RETRIEVAL_ALLOWED_INTENTS:
245
+ return True
246
 
247
+ if not solved and normalized_category in {"Verbal", "DataInsight"}:
248
+ return True
249
 
250
+ return False
 
 
 
 
251
 
 
 
 
 
 
 
 
 
 
 
252
 
253
+ def _score_chunk(
254
+ chunk: RetrievedChunk,
255
+ intent: str,
256
+ topic: Optional[str],
257
+ question_text: str,
258
+ question_type: Optional[str] = None,
259
+ ) -> float:
260
+ text = f"{chunk.topic} {chunk.text}".lower()
261
+ score = 0.0
262
+
263
+ if topic:
264
+ chunk_topic = (chunk.topic or "").lower()
265
+ if chunk_topic == topic.lower():
266
+ score += 4.0
267
+ elif topic.lower() in text:
268
+ score += 2.0
269
+
270
+ for term in _infer_structure_terms(question_text, topic, question_type):
271
+ if term.lower() in text:
272
+ score += 1.5
273
+
274
+ for term in _intent_keywords(intent):
275
+ if term.lower() in text:
276
+ score += 1.2
277
+
278
+ overlap = sum(1 for kw in _extract_keywords(question_text) if kw in text)
279
+ score += min(overlap * 0.4, 3.0)
280
+
281
+ for bad in _infer_mismatch_terms(topic, question_text):
282
+ if bad.lower() in text:
283
+ score -= 2.5
284
+
285
+ return score
286
+
287
+
288
+ def _filter_retrieved_chunks(
289
+ chunks: List[RetrievedChunk],
290
+ intent: str,
291
+ topic: Optional[str],
292
+ question_text: str,
293
+ question_type: Optional[str] = None,
294
+ min_score: float = 3.2,
295
+ max_chunks: int = 3,
296
+ ) -> List[RetrievedChunk]:
297
+ scored: List[tuple[float, RetrievedChunk]] = []
298
+ normalized_topic = (topic or "").lower()
299
+
300
+ for chunk in chunks:
301
+ chunk_topic = (chunk.topic or "").lower()
302
+
303
+ if normalized_topic and normalized_topic not in {"general", "unknown", "general_quant"}:
304
+ if chunk_topic == "general":
305
+ continue
306
+
307
+ s = _score_chunk(chunk, intent, topic, question_text, question_type)
308
+ if s >= min_score:
309
+ scored.append((s, chunk))
310
+
311
+ scored.sort(key=lambda x: x[0], reverse=True)
312
+ filtered = [chunk for _, chunk in scored[:max_chunks]]
313
+ if filtered:
314
+ return filtered
315
+
316
+ fallback: List[tuple[float, RetrievedChunk]] = []
317
+ for chunk in chunks:
318
+ s = _score_chunk(chunk, intent, topic, question_text, question_type)
319
+ if s >= 2.0:
320
+ fallback.append((s, chunk))
321
+
322
+ fallback.sort(key=lambda x: x[0], reverse=True)
323
+ return [chunk for _, chunk in fallback[:max_chunks]]
324
+
325
+
326
+ def _build_retrieval_query(
327
+ raw_user_text: str,
328
+ question_text: str,
329
+ intent: str,
330
+ topic: Optional[str],
331
+ solved: bool,
332
+ question_type: Optional[str] = None,
333
+ category: Optional[str] = None,
334
+ ) -> str:
335
+ parts: List[str] = []
336
+
337
+ base = (question_text or "").strip() or (raw_user_text or "").strip()
338
+ if base:
339
+ parts.append(base)
340
+
341
+ normalized_category = normalize_category(category)
342
+ if normalized_category and normalized_category != "General":
343
+ parts.append(normalized_category)
344
+
345
+ if topic:
346
+ parts.append(topic)
347
+
348
+ if question_type:
349
+ parts.append(question_type.replace("_", " "))
350
+
351
+ if intent in {"definition", "concept"}:
352
+ parts.append("definition concept explanation")
353
+ elif intent in {"walkthrough", "step_by_step", "method", "instruction"}:
354
+ parts.append("method steps worked example")
355
+ elif intent == "hint":
356
+ parts.append("hint strategy first step")
357
+ elif intent == "explain":
358
+ parts.append("explanation reasoning")
359
+ elif not solved:
360
+ parts.append("teaching explanation method")
361
+
362
+ return " ".join(parts).strip()
363
+
364
+
365
+ class ConversationEngine:
366
+ def __init__(
367
+ self,
368
+ retriever: Optional[RetrievalEngine] = None,
369
+ generator: Optional[GeneratorEngine] = None,
370
+ **kwargs,
371
+ ) -> None:
372
+ self.retriever = retriever
373
+ self.generator = generator
374
+
375
+ def generate_response(
376
+ self,
377
+ raw_user_text: Optional[str] = None,
378
+ tone: float = 0.5,
379
+ verbosity: float = 0.5,
380
+ transparency: float = 0.5,
381
+ intent: Optional[str] = None,
382
+ help_mode: Optional[str] = None,
383
+ retrieval_context: Optional[List[RetrievedChunk]] = None,
384
+ chat_history: Optional[List[Dict[str, Any]]] = None,
385
+ question_text: Optional[str] = None,
386
+ options_text: Optional[List[str]] = None,
387
+ **kwargs,
388
+ ) -> SolverResult:
389
+ solver_input = (question_text or raw_user_text or "").strip()
390
+ user_text = (raw_user_text or "").strip()
391
+
392
+ category = normalize_category(kwargs.get("category"))
393
+ classification = classify_question(question_text=solver_input, category=category)
394
+ inferred_category = normalize_category(classification.get("category") or category)
395
+
396
+ question_topic = _normalize_classified_topic(
397
+ classification.get("topic"),
398
+ inferred_category,
399
+ solver_input,
400
+ )
401
+ question_type = classification.get("type")
402
+
403
+ resolved_intent = intent or detect_intent(user_text, help_mode)
404
+ resolved_help_mode = help_mode or intent_to_help_mode(resolved_intent)
405
+ reveal_answer = resolved_help_mode == "answer" or transparency >= 0.8
406
+
407
+ result = SolverResult(
408
+ domain="general",
409
+ solved=False,
410
+ help_mode=resolved_help_mode,
411
+ answer_letter=None,
412
+ answer_value=None,
413
+ topic=question_topic,
414
+ used_retrieval=False,
415
+ used_generator=False,
416
+ internal_answer=None,
417
+ steps=[],
418
+ teaching_chunks=[],
419
+ meta={},
420
  )
421
 
422
+ selected_chunks: List[RetrievedChunk] = []
423
+
424
+ if inferred_category == "Quantitative" or is_quant_question(solver_input):
425
+ solved_result = solve_quant(solver_input)
426
+ if solved_result is not None:
427
+ result = solved_result
428
+ result.help_mode = resolved_help_mode
429
+ if not result.topic or result.topic in {"general_quant", "general", "unknown"}:
430
+ result.topic = question_topic
431
+ result.domain = "quant"
432
+
433
+ reply = _compose_reply(
434
+ result=result,
435
+ intent=resolved_intent,
436
+ reveal_answer=reveal_answer,
437
+ verbosity=verbosity,
438
+ category=inferred_category,
 
 
 
 
 
439
  )
440
+
441
+ allow_retrieval = should_retrieve(
442
+ intent=resolved_intent,
443
+ solved=bool(result.solved),
444
+ raw_user_text=user_text or solver_input,
445
+ category=inferred_category,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
446
  )
447
+
448
+ if allow_retrieval and retrieval_context:
449
+ filtered = _filter_retrieved_chunks(
450
+ chunks=retrieval_context,
451
+ intent=resolved_intent,
452
+ topic=result.topic,
453
+ question_text=solver_input,
454
+ question_type=question_type,
455
+ )
456
+ if filtered:
457
+ selected_chunks = filtered
458
+ result.used_retrieval = True
459
+ result.teaching_chunks = filtered
460
+
461
+ elif allow_retrieval and self.retriever is not None:
462
+ retrieved = self.retriever.search(
463
+ query=_build_retrieval_query(
464
+ raw_user_text=user_text,
465
+ question_text=solver_input,
466
+ intent=resolved_intent,
467
+ topic=result.topic,
468
+ solved=bool(result.solved),
469
+ question_type=question_type,
470
+ category=inferred_category,
471
+ ),
472
+ topic=result.topic or "",
473
+ intent=resolved_intent,
474
+ k=6,
475
+ )
476
+ filtered = _filter_retrieved_chunks(
477
+ chunks=retrieved,
478
+ intent=resolved_intent,
479
+ topic=result.topic,
480
+ question_text=solver_input,
481
+ question_type=question_type,
482
+ )
483
+ if filtered:
484
+ selected_chunks = filtered
485
+ result.used_retrieval = True
486
+ result.teaching_chunks = filtered
487
+
488
+ if selected_chunks and resolved_help_mode != "answer":
489
+ reply = f"{reply}\n\nRelevant study notes:\n" + "\n".join(_teaching_lines(selected_chunks))
490
+
491
+ if not result.solved and self.generator is not None:
492
+ try:
493
+ generated = self.generator.generate(
494
+ user_text=user_text or solver_input,
495
+ question_text=solver_input,
496
+ topic=result.topic or "",
497
+ intent=resolved_intent,
498
+ retrieval_context=selected_chunks,
499
+ chat_history=chat_history or [],
500
+ )
501
+ if generated and generated.strip():
502
+ reply = generated.strip()
503
+ result.used_generator = True
504
+ except Exception:
505
+ pass
506
+
507
+ reply = format_reply(reply, tone, verbosity, transparency, resolved_help_mode)
508
+
509
+ result.reply = reply
510
+ result.help_mode = resolved_help_mode
511
+ result.meta = {
512
+ "intent": resolved_intent,
513
+ "question_text": question_text or "",
514
+ "options_count": len(options_text or []),
515
+ "category": inferred_category,
516
+ "question_type": question_type,
517
+ "classified_topic": question_topic,
518
+ }
519
+
520
+ return result