j-js commited on
Commit
cb44e66
·
verified ·
1 Parent(s): 5498923

Update quant_solver.py

Browse files
Files changed (1) hide show
  1. quant_solver.py +293 -57
quant_solver.py CHANGED
@@ -7,6 +7,7 @@ from statistics import mean, median
7
  from typing import Dict, List, Optional
8
 
9
  from models import SolverResult
 
10
  from utils import clean_math_text, normalize_spaces
11
 
12
  try:
@@ -58,12 +59,43 @@ def extract_numbers(text: str) -> List[float]:
58
  def is_quant_question(text: str) -> bool:
59
  lower = text.lower()
60
  quant_keywords = [
61
- "solve", "equation", "integer", "percent", "percentage", "ratio", "probability",
62
- "mean", "median", "average", "sum", "difference", "product", "quotient",
63
- "triangle", "circle", "rectangle", "perimeter", "area", "volume",
64
- "number line", "positive", "negative", "multiple", "factor", "prime",
65
- "distance", "speed", "work", "mixture", "consecutive", "algebra",
66
- "value of x", "value of y", "what is x", "what is y", "divisible"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  ]
68
  if any(k in lower for k in quant_keywords):
69
  return True
@@ -101,7 +133,11 @@ def parse_direct_expression_question(text: str) -> Optional[str]:
101
  return expr
102
 
103
 
104
- def compare_to_choices_numeric(answer_value: float, choices: Dict[str, str], tolerance: float = 1e-9) -> Optional[str]:
 
 
 
 
105
  best_letter = None
106
  best_diff = float("inf")
107
 
@@ -127,7 +163,10 @@ def compare_to_choices_numeric(answer_value: float, choices: Dict[str, str], tol
127
  return None
128
 
129
 
130
- def compare_fraction_to_choices(fraction_text: str, choices: Dict[str, str]) -> Optional[str]:
 
 
 
131
  target = fraction_text.strip()
132
 
133
  for letter, raw in choices.items():
@@ -160,14 +199,19 @@ def solve_basic_expression(text: str, help_mode: str) -> Optional[SolverResult]:
160
  else:
161
  reply = f"The value is {value:g}."
162
 
163
- return SolverResult(reply=reply, domain="quant", solved=True, help_mode=help_mode, answer_value=f"{value:g}")
 
 
 
 
 
 
164
 
165
 
166
  def solve_percent_question(text: str, help_mode: str) -> Optional[SolverResult]:
167
  lower = text.lower()
168
  choices = extract_choices(text)
169
 
170
- # percent of number
171
  m = re.search(r"what is\s+(\d+(?:\.\d+)?)\s*%\s+of\s+(\d+(?:\.\d+)?)", lower)
172
  if m:
173
  p = float(m.group(1))
@@ -195,7 +239,6 @@ def solve_percent_question(text: str, help_mode: str) -> Optional[SolverResult]:
195
  answer_value=f"{ans:g}",
196
  )
197
 
198
- # x is what percent of y
199
  m = re.search(r"(\d+(?:\.\d+)?)\s+is\s+what percent of\s+(\d+(?:\.\d+)?)", lower)
200
  if m:
201
  x = float(m.group(1))
@@ -204,17 +247,28 @@ def solve_percent_question(text: str, help_mode: str) -> Optional[SolverResult]:
204
  return None
205
 
206
  ans = x / y * 100
 
207
 
208
  if help_mode == "hint":
209
  reply = "Use part ÷ whole × 100."
210
  elif help_mode == "walkthrough":
211
  reply = f"Percent = ({x} / {y}) × 100 = {ans:g}%."
 
 
212
  else:
213
  reply = f"The answer is {ans:g}%."
 
 
214
 
215
- return SolverResult(reply=reply, domain="quant", solved=True, help_mode=help_mode, answer_value=f"{ans:g}%")
 
 
 
 
 
 
 
216
 
217
- # convert percent to fraction
218
  m = re.search(r"(\d+(?:\.\d+)?)\s*%.*?(?:equals|as a fraction|fraction)", lower)
219
  if not m:
220
  m = re.search(r"what is\s+(\d+(?:\.\d+)?)\s*%\s+as a fraction", lower)
@@ -246,7 +300,6 @@ def solve_percent_question(text: str, help_mode: str) -> Optional[SolverResult]:
246
  answer_value=fraction_text,
247
  )
248
 
249
- # increased/decreased by percent
250
  m = re.search(
251
  r"(\d+(?:\.\d+)?)\s+(?:is\s+)?(increased|decreased)\s+by\s+(\d+(?:\.\d+)?)\s*%",
252
  lower,
@@ -256,16 +309,28 @@ def solve_percent_question(text: str, help_mode: str) -> Optional[SolverResult]:
256
  direction = m.group(2)
257
  p = float(m.group(3)) / 100
258
  ans = base * (1 + p) if direction == "increased" else base * (1 - p)
 
259
 
260
  if help_mode == "hint":
261
  reply = "Use multiplier form: increase → 1 + p, decrease → 1 - p."
262
  elif help_mode == "walkthrough":
263
  mult = 1 + p if direction == "increased" else 1 - p
264
  reply = f"Multiplier = {mult:g}, so {base:g} × {mult:g} = {ans:g}."
 
 
265
  else:
266
  reply = f"The result is {ans:g}."
 
 
267
 
268
- return SolverResult(reply=reply, domain="quant", solved=True, help_mode=help_mode, answer_value=f"{ans:g}")
 
 
 
 
 
 
 
269
 
270
  return None
271
 
@@ -275,12 +340,12 @@ def solve_ratio_question(text: str, help_mode: str) -> Optional[SolverResult]:
275
 
276
  m = re.search(
277
  r"ratio of .*? is\s+(\d+)\s*:\s*(\d+).*?total (?:is|of)\s+(\d+(?:\.\d+)?)",
278
- lower
279
  )
280
  if not m:
281
  m = re.search(
282
  r"(\d+)\s*:\s*(\d+).*?total (?:is|of)\s+(\d+(?:\.\d+)?)",
283
- lower
284
  )
285
 
286
  if m:
@@ -305,48 +370,80 @@ def solve_ratio_question(text: str, help_mode: str) -> Optional[SolverResult]:
305
  else:
306
  reply = f"The two quantities are {first:g} and {second:g}."
307
 
308
- return SolverResult(reply=reply, domain="quant", solved=True, help_mode=help_mode, answer_value=f"{first:g}, {second:g}")
 
 
 
 
 
 
309
 
310
  return None
311
 
312
 
313
  def solve_average_question(text: str, help_mode: str) -> Optional[SolverResult]:
314
  lower = text.lower()
 
315
 
316
  if "mean" in lower or "average" in lower:
317
  nums = extract_numbers(text)
318
  if len(nums) >= 2:
319
  ans = mean(nums)
 
320
 
321
  if help_mode == "hint":
322
  reply = "Add the numbers, then divide by how many there are."
323
  elif help_mode == "walkthrough":
324
  reply = f"The numbers are {', '.join(f'{x:g}' for x in nums)}.\nTheir average is {ans:g}."
 
 
325
  else:
326
  reply = f"The average is {ans:g}."
 
 
327
 
328
- return SolverResult(reply=reply, domain="quant", solved=True, help_mode=help_mode, answer_value=f"{ans:g}")
 
 
 
 
 
 
 
329
 
330
  if "median" in lower:
331
  nums = extract_numbers(text)
332
  if len(nums) >= 2:
333
  ans = median(nums)
 
334
 
335
  if help_mode == "hint":
336
  reply = "Sort the numbers first, then take the middle value."
337
  elif help_mode == "walkthrough":
338
  s = sorted(nums)
339
  reply = f"Sorted numbers: {', '.join(f'{x:g}' for x in s)}.\nThe median is {ans:g}."
 
 
340
  else:
341
  reply = f"The median is {ans:g}."
 
 
342
 
343
- return SolverResult(reply=reply, domain="quant", solved=True, help_mode=help_mode, answer_value=f"{ans:g}")
 
 
 
 
 
 
 
344
 
345
  return None
346
 
347
 
348
  def solve_probability_question(text: str, help_mode: str) -> Optional[SolverResult]:
349
  lower = text.lower()
 
350
 
351
  m = re.search(r"(\d+)\s+red.*?(\d+)\s+blue.*?probability", lower)
352
  if m:
@@ -357,15 +454,30 @@ def solve_probability_question(text: str, help_mode: str) -> Optional[SolverResu
357
  return None
358
 
359
  ans = r / total
 
360
 
361
  if help_mode == "hint":
362
  reply = "Probability = favorable outcomes ÷ total outcomes."
363
  elif help_mode == "walkthrough":
364
- reply = f"Favorable outcomes = {r:g}, total outcomes = {total:g}, so probability = {r:g}/{total:g} = {ans:g}."
 
 
 
 
 
365
  else:
366
  reply = f"The probability is {ans:g}."
 
 
367
 
368
- return SolverResult(reply=reply, domain="quant", solved=True, help_mode=help_mode, answer_value=f"{ans:g}")
 
 
 
 
 
 
 
369
 
370
  return None
371
 
@@ -374,7 +486,6 @@ def solve_geometry_question(text: str, help_mode: str) -> Optional[SolverResult]
374
  lower = text.lower()
375
  choices = extract_choices(text)
376
 
377
- # rectangle perimeter with one side known -> find other side
378
  m = re.search(
379
  r"rectangle.*?perimeter\s*(?:is|=)?\s*(\d+(?:\.\d+)?).*?length\s*(?:is|=)?\s*(\d+(?:\.\d+)?)",
380
  lower,
@@ -389,11 +500,6 @@ def solve_geometry_question(text: str, help_mode: str) -> Optional[SolverResult]
389
  reply = "Use the perimeter formula for a rectangle: P = 2(L + W)."
390
  elif help_mode == "walkthrough":
391
  reply = (
392
- f"For a rectangle, P = 2(L + W).\n"
393
- f"So 30 = 2(9 + W).\n"
394
- f"Divide by 2: 15 = 9 + W.\n"
395
- f"So W = {ans:g}."
396
- ) if p == 30 and l == 9 else (
397
  f"For a rectangle, P = 2(L + W).\n"
398
  f"So {p:g} = 2({l:g} + W).\n"
399
  f"Divide by 2: {p/2:g} = {l:g} + W.\n"
@@ -415,49 +521,91 @@ def solve_geometry_question(text: str, help_mode: str) -> Optional[SolverResult]
415
  answer_value=f"{ans:g}",
416
  )
417
 
418
- m = re.search(r"rectangle.*?length\s*(?:is|=)?\s*(\d+(?:\.\d+)?)\D+width\s*(?:is|=)?\s*(\d+(?:\.\d+)?)", lower)
 
 
 
419
  if m and "area" in lower:
420
  l = float(m.group(1))
421
  w = float(m.group(2))
422
  ans = l * w
 
423
 
424
  if help_mode == "hint":
425
  reply = "For a rectangle, area = length × width."
426
  elif help_mode == "walkthrough":
427
  reply = f"Area = {l:g} × {w:g} = {ans:g}."
 
 
428
  else:
429
  reply = f"The area is {ans:g}."
 
 
430
 
431
- return SolverResult(reply=reply, domain="quant", solved=True, help_mode=help_mode, answer_value=f"{ans:g}")
 
 
 
 
 
 
 
432
 
433
  m = re.search(r"circle.*?radius\s*(?:is|=)?\s*(\d+(?:\.\d+)?)", lower)
434
  if m and "area" in lower:
435
  r = float(m.group(1))
436
  ans = math.pi * r * r
 
437
 
438
  if help_mode == "hint":
439
  reply = "For a circle, area = πr²."
440
  elif help_mode == "walkthrough":
441
  reply = f"Area = π({r:g})² = {ans:.4f}."
 
 
442
  else:
443
  reply = f"The area is {ans:.4f}."
 
 
444
 
445
- return SolverResult(reply=reply, domain="quant", solved=True, help_mode=help_mode, answer_value=f"{ans:.4f}")
 
 
 
 
 
 
 
446
 
447
- m = re.search(r"triangle.*?base\s*(?:is|=)?\s*(\d+(?:\.\d+)?)\D+height\s*(?:is|=)?\s*(\d+(?:\.\d+)?)", lower)
 
 
 
448
  if m and "area" in lower:
449
  b = float(m.group(1))
450
  h = float(m.group(2))
451
  ans = 0.5 * b * h
 
452
 
453
  if help_mode == "hint":
454
  reply = "Triangle area = 1/2 × base × height."
455
  elif help_mode == "walkthrough":
456
  reply = f"Area = 1/2 × {b:g} × {h:g} = {ans:g}."
 
 
457
  else:
458
  reply = f"The area is {ans:g}."
 
 
459
 
460
- return SolverResult(reply=reply, domain="quant", solved=True, help_mode=help_mode, answer_value=f"{ans:g}")
 
 
 
 
 
 
 
461
 
462
  return None
463
 
@@ -488,7 +636,7 @@ def solve_divisibility_question(text: str, help_mode: str) -> Optional[SolverRes
488
  elif help_mode == "walkthrough":
489
  reply = (
490
  f"Substitute the choices into 3x + 2.\n"
491
- f"When x = {valid_x}, 3({valid_x}) + 2 = {3*valid_x+2}, which is divisible by 5.\n"
492
  f"So the correct choice is {valid_letter}."
493
  )
494
  else:
@@ -511,8 +659,12 @@ def solve_linear_equation(text: str, help_mode: str) -> Optional[SolverResult]:
511
  return None
512
 
513
  lower = text.lower().replace("^", "**")
 
514
 
515
- m = re.search(r"(?:solve for x|find x|value of x).*?([\-0-9a-z\+\*/\s\(\)]+=[\-0-9a-z\+\*/\s\(\)]+)", lower)
 
 
 
516
  if not m:
517
  m = re.search(r"([\-0-9a-z\+\*/\s\(\)]+=[\-0-9a-z\+\*/\s\(\)]+)", lower)
518
  if not m:
@@ -534,16 +686,37 @@ def solve_linear_equation(text: str, help_mode: str) -> Optional[SolverResult]:
534
  if not sols:
535
  return None
536
 
537
- sol = sols[0]
 
 
 
 
 
 
 
 
 
 
538
 
539
  if help_mode == "hint":
540
  reply = "Collect the x-terms on one side and constants on the other."
541
  elif help_mode == "walkthrough":
542
- reply = f"Solve {eq_text} for x.\nThis gives x = {sp.simplify(sol)}."
 
 
543
  else:
544
- reply = f"x = {sp.simplify(sol)}."
 
 
545
 
546
- return SolverResult(reply=reply, domain="quant", solved=True, help_mode=help_mode, answer_value=str(sp.simplify(sol)))
 
 
 
 
 
 
 
547
  except Exception:
548
  return None
549
 
@@ -566,7 +739,14 @@ def solve_by_option_checking(text: str, help_mode: str) -> Optional[SolverResult
566
  else:
567
  reply = f"The correct choice is {letter}."
568
 
569
- return SolverResult(reply=reply, domain="quant", solved=True, help_mode=help_mode, answer_letter=letter, answer_value=f"{val:g}")
 
 
 
 
 
 
 
570
 
571
  lower = text.lower()
572
  m = re.search(r"what is\s+(\d+(?:\.\d+)?)\s*%\s+of\s+(\d+(?:\.\d+)?)", lower)
@@ -581,23 +761,67 @@ def solve_by_option_checking(text: str, help_mode: str) -> Optional[SolverResult
581
  else:
582
  reply = f"The correct choice is {letter}."
583
 
584
- return SolverResult(reply=reply, domain="quant", solved=True, help_mode=help_mode, answer_letter=letter, answer_value=f"{val:g}")
 
 
 
 
 
 
 
585
 
586
  return None
587
 
588
 
589
  def solve_quant(text: str, help_mode: str) -> SolverResult:
590
- solvers = [
591
- solve_by_option_checking,
592
- solve_percent_question,
593
- solve_ratio_question,
594
- solve_average_question,
595
- solve_probability_question,
596
- solve_geometry_question,
597
- solve_divisibility_question,
598
- solve_linear_equation,
599
- solve_basic_expression,
600
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
601
 
602
  for solver in solvers:
603
  try:
@@ -607,6 +831,15 @@ def solve_quant(text: str, help_mode: str) -> SolverResult:
607
  except Exception:
608
  pass
609
 
 
 
 
 
 
 
 
 
 
610
  if help_mode == "hint":
611
  reply = (
612
  "Identify the question type first, then use the key relationship or formula. "
@@ -618,8 +851,11 @@ def solve_quant(text: str, help_mode: str) -> SolverResult:
618
  "I can still talk through the first step or eliminate options if needed."
619
  )
620
  else:
621
- reply = (
622
- "I can help with this, but I can’t confidently solve it from the current parse alone yet."
623
- )
624
-
625
- return SolverResult(reply=reply, domain="quant", solved=False, help_mode=help_mode)
 
 
 
 
7
  from typing import Dict, List, Optional
8
 
9
  from models import SolverResult
10
+ from solver_router import route_solver
11
  from utils import clean_math_text, normalize_spaces
12
 
13
  try:
 
59
  def is_quant_question(text: str) -> bool:
60
  lower = text.lower()
61
  quant_keywords = [
62
+ "solve",
63
+ "equation",
64
+ "integer",
65
+ "percent",
66
+ "percentage",
67
+ "ratio",
68
+ "probability",
69
+ "mean",
70
+ "median",
71
+ "average",
72
+ "sum",
73
+ "difference",
74
+ "product",
75
+ "quotient",
76
+ "triangle",
77
+ "circle",
78
+ "rectangle",
79
+ "perimeter",
80
+ "area",
81
+ "volume",
82
+ "number line",
83
+ "positive",
84
+ "negative",
85
+ "multiple",
86
+ "factor",
87
+ "prime",
88
+ "distance",
89
+ "speed",
90
+ "work",
91
+ "mixture",
92
+ "consecutive",
93
+ "algebra",
94
+ "value of x",
95
+ "value of y",
96
+ "what is x",
97
+ "what is y",
98
+ "divisible",
99
  ]
100
  if any(k in lower for k in quant_keywords):
101
  return True
 
133
  return expr
134
 
135
 
136
+ def compare_to_choices_numeric(
137
+ answer_value: float,
138
+ choices: Dict[str, str],
139
+ tolerance: float = 1e-9,
140
+ ) -> Optional[str]:
141
  best_letter = None
142
  best_diff = float("inf")
143
 
 
163
  return None
164
 
165
 
166
+ def compare_fraction_to_choices(
167
+ fraction_text: str,
168
+ choices: Dict[str, str],
169
+ ) -> Optional[str]:
170
  target = fraction_text.strip()
171
 
172
  for letter, raw in choices.items():
 
199
  else:
200
  reply = f"The value is {value:g}."
201
 
202
+ return SolverResult(
203
+ reply=reply,
204
+ domain="quant",
205
+ solved=True,
206
+ help_mode=help_mode,
207
+ answer_value=f"{value:g}",
208
+ )
209
 
210
 
211
  def solve_percent_question(text: str, help_mode: str) -> Optional[SolverResult]:
212
  lower = text.lower()
213
  choices = extract_choices(text)
214
 
 
215
  m = re.search(r"what is\s+(\d+(?:\.\d+)?)\s*%\s+of\s+(\d+(?:\.\d+)?)", lower)
216
  if m:
217
  p = float(m.group(1))
 
239
  answer_value=f"{ans:g}",
240
  )
241
 
 
242
  m = re.search(r"(\d+(?:\.\d+)?)\s+is\s+what percent of\s+(\d+(?:\.\d+)?)", lower)
243
  if m:
244
  x = float(m.group(1))
 
247
  return None
248
 
249
  ans = x / y * 100
250
+ letter = compare_to_choices_numeric(ans, choices) if choices else None
251
 
252
  if help_mode == "hint":
253
  reply = "Use part ÷ whole × 100."
254
  elif help_mode == "walkthrough":
255
  reply = f"Percent = ({x} / {y}) × 100 = {ans:g}%."
256
+ if letter:
257
+ reply += f"\nThat matches choice {letter}."
258
  else:
259
  reply = f"The answer is {ans:g}%."
260
+ if letter:
261
+ reply += f" So the correct choice is {letter}."
262
 
263
+ return SolverResult(
264
+ reply=reply,
265
+ domain="quant",
266
+ solved=True,
267
+ help_mode=help_mode,
268
+ answer_letter=letter,
269
+ answer_value=f"{ans:g}%",
270
+ )
271
 
 
272
  m = re.search(r"(\d+(?:\.\d+)?)\s*%.*?(?:equals|as a fraction|fraction)", lower)
273
  if not m:
274
  m = re.search(r"what is\s+(\d+(?:\.\d+)?)\s*%\s+as a fraction", lower)
 
300
  answer_value=fraction_text,
301
  )
302
 
 
303
  m = re.search(
304
  r"(\d+(?:\.\d+)?)\s+(?:is\s+)?(increased|decreased)\s+by\s+(\d+(?:\.\d+)?)\s*%",
305
  lower,
 
309
  direction = m.group(2)
310
  p = float(m.group(3)) / 100
311
  ans = base * (1 + p) if direction == "increased" else base * (1 - p)
312
+ letter = compare_to_choices_numeric(ans, choices) if choices else None
313
 
314
  if help_mode == "hint":
315
  reply = "Use multiplier form: increase → 1 + p, decrease → 1 - p."
316
  elif help_mode == "walkthrough":
317
  mult = 1 + p if direction == "increased" else 1 - p
318
  reply = f"Multiplier = {mult:g}, so {base:g} × {mult:g} = {ans:g}."
319
+ if letter:
320
+ reply += f"\nThat matches choice {letter}."
321
  else:
322
  reply = f"The result is {ans:g}."
323
+ if letter:
324
+ reply += f" So the correct choice is {letter}."
325
 
326
+ return SolverResult(
327
+ reply=reply,
328
+ domain="quant",
329
+ solved=True,
330
+ help_mode=help_mode,
331
+ answer_letter=letter,
332
+ answer_value=f"{ans:g}",
333
+ )
334
 
335
  return None
336
 
 
340
 
341
  m = re.search(
342
  r"ratio of .*? is\s+(\d+)\s*:\s*(\d+).*?total (?:is|of)\s+(\d+(?:\.\d+)?)",
343
+ lower,
344
  )
345
  if not m:
346
  m = re.search(
347
  r"(\d+)\s*:\s*(\d+).*?total (?:is|of)\s+(\d+(?:\.\d+)?)",
348
+ lower,
349
  )
350
 
351
  if m:
 
370
  else:
371
  reply = f"The two quantities are {first:g} and {second:g}."
372
 
373
+ return SolverResult(
374
+ reply=reply,
375
+ domain="quant",
376
+ solved=True,
377
+ help_mode=help_mode,
378
+ answer_value=f"{first:g}, {second:g}",
379
+ )
380
 
381
  return None
382
 
383
 
384
  def solve_average_question(text: str, help_mode: str) -> Optional[SolverResult]:
385
  lower = text.lower()
386
+ choices = extract_choices(text)
387
 
388
  if "mean" in lower or "average" in lower:
389
  nums = extract_numbers(text)
390
  if len(nums) >= 2:
391
  ans = mean(nums)
392
+ letter = compare_to_choices_numeric(ans, choices) if choices else None
393
 
394
  if help_mode == "hint":
395
  reply = "Add the numbers, then divide by how many there are."
396
  elif help_mode == "walkthrough":
397
  reply = f"The numbers are {', '.join(f'{x:g}' for x in nums)}.\nTheir average is {ans:g}."
398
+ if letter:
399
+ reply += f"\nThat matches choice {letter}."
400
  else:
401
  reply = f"The average is {ans:g}."
402
+ if letter:
403
+ reply += f" So the correct choice is {letter}."
404
 
405
+ return SolverResult(
406
+ reply=reply,
407
+ domain="quant",
408
+ solved=True,
409
+ help_mode=help_mode,
410
+ answer_letter=letter,
411
+ answer_value=f"{ans:g}",
412
+ )
413
 
414
  if "median" in lower:
415
  nums = extract_numbers(text)
416
  if len(nums) >= 2:
417
  ans = median(nums)
418
+ letter = compare_to_choices_numeric(ans, choices) if choices else None
419
 
420
  if help_mode == "hint":
421
  reply = "Sort the numbers first, then take the middle value."
422
  elif help_mode == "walkthrough":
423
  s = sorted(nums)
424
  reply = f"Sorted numbers: {', '.join(f'{x:g}' for x in s)}.\nThe median is {ans:g}."
425
+ if letter:
426
+ reply += f"\nThat matches choice {letter}."
427
  else:
428
  reply = f"The median is {ans:g}."
429
+ if letter:
430
+ reply += f" So the correct choice is {letter}."
431
 
432
+ return SolverResult(
433
+ reply=reply,
434
+ domain="quant",
435
+ solved=True,
436
+ help_mode=help_mode,
437
+ answer_letter=letter,
438
+ answer_value=f"{ans:g}",
439
+ )
440
 
441
  return None
442
 
443
 
444
  def solve_probability_question(text: str, help_mode: str) -> Optional[SolverResult]:
445
  lower = text.lower()
446
+ choices = extract_choices(text)
447
 
448
  m = re.search(r"(\d+)\s+red.*?(\d+)\s+blue.*?probability", lower)
449
  if m:
 
454
  return None
455
 
456
  ans = r / total
457
+ letter = compare_to_choices_numeric(ans, choices) if choices else None
458
 
459
  if help_mode == "hint":
460
  reply = "Probability = favorable outcomes ÷ total outcomes."
461
  elif help_mode == "walkthrough":
462
+ reply = (
463
+ f"Favorable outcomes = {r:g}, total outcomes = {total:g}, "
464
+ f"so probability = {r:g}/{total:g} = {ans:g}."
465
+ )
466
+ if letter:
467
+ reply += f"\nThat matches choice {letter}."
468
  else:
469
  reply = f"The probability is {ans:g}."
470
+ if letter:
471
+ reply += f" So the correct choice is {letter}."
472
 
473
+ return SolverResult(
474
+ reply=reply,
475
+ domain="quant",
476
+ solved=True,
477
+ help_mode=help_mode,
478
+ answer_letter=letter,
479
+ answer_value=f"{ans:g}",
480
+ )
481
 
482
  return None
483
 
 
486
  lower = text.lower()
487
  choices = extract_choices(text)
488
 
 
489
  m = re.search(
490
  r"rectangle.*?perimeter\s*(?:is|=)?\s*(\d+(?:\.\d+)?).*?length\s*(?:is|=)?\s*(\d+(?:\.\d+)?)",
491
  lower,
 
500
  reply = "Use the perimeter formula for a rectangle: P = 2(L + W)."
501
  elif help_mode == "walkthrough":
502
  reply = (
 
 
 
 
 
503
  f"For a rectangle, P = 2(L + W).\n"
504
  f"So {p:g} = 2({l:g} + W).\n"
505
  f"Divide by 2: {p/2:g} = {l:g} + W.\n"
 
521
  answer_value=f"{ans:g}",
522
  )
523
 
524
+ m = re.search(
525
+ r"rectangle.*?length\s*(?:is|=)?\s*(\d+(?:\.\d+)?)\D+width\s*(?:is|=)?\s*(\d+(?:\.\d+)?)",
526
+ lower,
527
+ )
528
  if m and "area" in lower:
529
  l = float(m.group(1))
530
  w = float(m.group(2))
531
  ans = l * w
532
+ letter = compare_to_choices_numeric(ans, choices) if choices else None
533
 
534
  if help_mode == "hint":
535
  reply = "For a rectangle, area = length × width."
536
  elif help_mode == "walkthrough":
537
  reply = f"Area = {l:g} × {w:g} = {ans:g}."
538
+ if letter:
539
+ reply += f"\nThat matches choice {letter}."
540
  else:
541
  reply = f"The area is {ans:g}."
542
+ if letter:
543
+ reply += f" So the correct choice is {letter}."
544
 
545
+ return SolverResult(
546
+ reply=reply,
547
+ domain="quant",
548
+ solved=True,
549
+ help_mode=help_mode,
550
+ answer_letter=letter,
551
+ answer_value=f"{ans:g}",
552
+ )
553
 
554
  m = re.search(r"circle.*?radius\s*(?:is|=)?\s*(\d+(?:\.\d+)?)", lower)
555
  if m and "area" in lower:
556
  r = float(m.group(1))
557
  ans = math.pi * r * r
558
+ letter = compare_to_choices_numeric(ans, choices) if choices else None
559
 
560
  if help_mode == "hint":
561
  reply = "For a circle, area = πr²."
562
  elif help_mode == "walkthrough":
563
  reply = f"Area = π({r:g})² = {ans:.4f}."
564
+ if letter:
565
+ reply += f"\nThat matches choice {letter}."
566
  else:
567
  reply = f"The area is {ans:.4f}."
568
+ if letter:
569
+ reply += f" So the correct choice is {letter}."
570
 
571
+ return SolverResult(
572
+ reply=reply,
573
+ domain="quant",
574
+ solved=True,
575
+ help_mode=help_mode,
576
+ answer_letter=letter,
577
+ answer_value=f"{ans:.4f}",
578
+ )
579
 
580
+ m = re.search(
581
+ r"triangle.*?base\s*(?:is|=)?\s*(\d+(?:\.\d+)?)\D+height\s*(?:is|=)?\s*(\d+(?:\.\d+)?)",
582
+ lower,
583
+ )
584
  if m and "area" in lower:
585
  b = float(m.group(1))
586
  h = float(m.group(2))
587
  ans = 0.5 * b * h
588
+ letter = compare_to_choices_numeric(ans, choices) if choices else None
589
 
590
  if help_mode == "hint":
591
  reply = "Triangle area = 1/2 × base × height."
592
  elif help_mode == "walkthrough":
593
  reply = f"Area = 1/2 × {b:g} × {h:g} = {ans:g}."
594
+ if letter:
595
+ reply += f"\nThat matches choice {letter}."
596
  else:
597
  reply = f"The area is {ans:g}."
598
+ if letter:
599
+ reply += f" So the correct choice is {letter}."
600
 
601
+ return SolverResult(
602
+ reply=reply,
603
+ domain="quant",
604
+ solved=True,
605
+ help_mode=help_mode,
606
+ answer_letter=letter,
607
+ answer_value=f"{ans:g}",
608
+ )
609
 
610
  return None
611
 
 
636
  elif help_mode == "walkthrough":
637
  reply = (
638
  f"Substitute the choices into 3x + 2.\n"
639
+ f"When x = {valid_x}, 3({valid_x}) + 2 = {3 * valid_x + 2}, which is divisible by 5.\n"
640
  f"So the correct choice is {valid_letter}."
641
  )
642
  else:
 
659
  return None
660
 
661
  lower = text.lower().replace("^", "**")
662
+ choices = extract_choices(text)
663
 
664
+ m = re.search(
665
+ r"(?:solve for x|find x|value of x).*?([\-0-9a-z\+\*/\s\(\)]+=[\-0-9a-z\+\*/\s\(\)]+)",
666
+ lower,
667
+ )
668
  if not m:
669
  m = re.search(r"([\-0-9a-z\+\*/\s\(\)]+=[\-0-9a-z\+\*/\s\(\)]+)", lower)
670
  if not m:
 
686
  if not sols:
687
  return None
688
 
689
+ sol = sp.simplify(sols[0])
690
+
691
+ sol_float = None
692
+ try:
693
+ sol_float = float(sol)
694
+ except Exception:
695
+ pass
696
+
697
+ letter = None
698
+ if choices and sol_float is not None:
699
+ letter = compare_to_choices_numeric(sol_float, choices)
700
 
701
  if help_mode == "hint":
702
  reply = "Collect the x-terms on one side and constants on the other."
703
  elif help_mode == "walkthrough":
704
+ reply = f"Solve {eq_text} for x.\nThis gives x = {sol}."
705
+ if letter:
706
+ reply += f"\nThat matches choice {letter}."
707
  else:
708
+ reply = f"x = {sol}."
709
+ if letter:
710
+ reply += f" So the correct choice is {letter}."
711
 
712
+ return SolverResult(
713
+ reply=reply,
714
+ domain="quant",
715
+ solved=True,
716
+ help_mode=help_mode,
717
+ answer_letter=letter,
718
+ answer_value=str(sol),
719
+ )
720
  except Exception:
721
  return None
722
 
 
739
  else:
740
  reply = f"The correct choice is {letter}."
741
 
742
+ return SolverResult(
743
+ reply=reply,
744
+ domain="quant",
745
+ solved=True,
746
+ help_mode=help_mode,
747
+ answer_letter=letter,
748
+ answer_value=f"{val:g}",
749
+ )
750
 
751
  lower = text.lower()
752
  m = re.search(r"what is\s+(\d+(?:\.\d+)?)\s*%\s+of\s+(\d+(?:\.\d+)?)", lower)
 
761
  else:
762
  reply = f"The correct choice is {letter}."
763
 
764
+ return SolverResult(
765
+ reply=reply,
766
+ domain="quant",
767
+ solved=True,
768
+ help_mode=help_mode,
769
+ answer_letter=letter,
770
+ answer_value=f"{val:g}",
771
+ )
772
 
773
  return None
774
 
775
 
776
  def solve_quant(text: str, help_mode: str) -> SolverResult:
777
+ solver_type = route_solver(text)
778
+
779
+ routed_solvers = {
780
+ "percent_solver": [
781
+ solve_by_option_checking,
782
+ solve_percent_question,
783
+ solve_basic_expression,
784
+ ],
785
+ "ratio_solver": [
786
+ solve_by_option_checking,
787
+ solve_ratio_question,
788
+ solve_basic_expression,
789
+ ],
790
+ "algebra_solver": [
791
+ solve_by_option_checking,
792
+ solve_linear_equation,
793
+ solve_divisibility_question,
794
+ solve_basic_expression,
795
+ ],
796
+ "statistics_solver": [
797
+ solve_by_option_checking,
798
+ solve_average_question,
799
+ solve_basic_expression,
800
+ ],
801
+ "probability_solver": [
802
+ solve_by_option_checking,
803
+ solve_probability_question,
804
+ solve_basic_expression,
805
+ ],
806
+ "geometry_solver": [
807
+ solve_by_option_checking,
808
+ solve_geometry_question,
809
+ solve_basic_expression,
810
+ ],
811
+ "basic_solver": [
812
+ solve_by_option_checking,
813
+ solve_percent_question,
814
+ solve_ratio_question,
815
+ solve_average_question,
816
+ solve_probability_question,
817
+ solve_geometry_question,
818
+ solve_divisibility_question,
819
+ solve_linear_equation,
820
+ solve_basic_expression,
821
+ ],
822
+ }
823
+
824
+ solvers = routed_solvers.get(solver_type, routed_solvers["basic_solver"])
825
 
826
  for solver in solvers:
827
  try:
 
831
  except Exception:
832
  pass
833
 
834
+ if solver_type != "basic_solver":
835
+ for solver in routed_solvers["basic_solver"]:
836
+ try:
837
+ out = solver(text, help_mode)
838
+ if out:
839
+ return out
840
+ except Exception:
841
+ pass
842
+
843
  if help_mode == "hint":
844
  reply = (
845
  "Identify the question type first, then use the key relationship or formula. "
 
851
  "I can still talk through the first step or eliminate options if needed."
852
  )
853
  else:
854
+ reply = "I can help with this, but I can’t confidently solve it from the current parse alone yet."
855
+
856
+ return SolverResult(
857
+ reply=reply,
858
+ domain="quant",
859
+ solved=False,
860
+ help_mode=help_mode,
861
+ )