j-js commited on
Commit
2ea6ddc
·
verified ·
1 Parent(s): 2ae624c

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +535 -274
app.py CHANGED
@@ -5,11 +5,10 @@ import ast
5
  import json
6
  import math
7
  import os
8
- import random
9
  import re
10
  from dataclasses import dataclass
11
  from statistics import mean, median
12
- from typing import Any, Dict, List, Optional, Tuple
13
 
14
  from fastapi import FastAPI, Request
15
  from fastapi.middleware.cors import CORSMiddleware
@@ -26,7 +25,7 @@ except Exception:
26
  # App setup
27
  # =========================================================
28
 
29
- app = FastAPI(title="GMAT Solver v2", version="2.0.0")
30
 
31
  app.add_middleware(
32
  CORSMiddleware,
@@ -52,23 +51,40 @@ class ChatRequest(BaseModel):
52
  verbosity: Optional[float] = 0.5
53
  transparency: Optional[float] = 0.5
54
 
55
- help_mode: Optional[str] = "answer" # hint | walkthrough | answer
56
  history: Optional[List[Dict[str, Any]]] = None
57
 
58
 
59
  # =========================================================
60
- # Utilities
61
  # =========================================================
62
 
63
- CHOICE_LETTERS = ["A", "B", "C", "D", "E"]
64
-
65
-
66
  @dataclass
67
  class SolverResult:
68
  reply: str
69
  domain: str
70
  solved: bool
71
  help_mode: str
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
 
73
 
74
  def clamp01(x: Any, default: float = 0.5) -> float:
@@ -79,13 +95,6 @@ def clamp01(x: Any, default: float = 0.5) -> float:
79
  return default
80
 
81
 
82
- def safe_float(x: str) -> Optional[float]:
83
- try:
84
- return float(x)
85
- except Exception:
86
- return None
87
-
88
-
89
  def normalize_spaces(text: str) -> str:
90
  return re.sub(r"\s+", " ", text).strip()
91
 
@@ -94,26 +103,16 @@ def clean_math_text(text: str) -> str:
94
  t = text
95
  t = t.replace("×", "*").replace("÷", "/").replace("–", "-").replace("—", "-")
96
  t = t.replace("−", "-").replace("^", "**")
97
- t = re.sub(r"(\d)\s*%", r"(\1/100)", t)
98
  return t
99
 
100
 
101
  def extract_text_from_any_payload(payload: Any) -> str:
102
- """
103
- Robust extractor so the backend still works even if Unity/browser sends:
104
- - plain string
105
- - {"message":"..."}
106
- - {"prompt":"..."}
107
- - {"input":{"message":"..."}}
108
- - "{\"message\":\"...\"}" (JSON string)
109
- """
110
  if payload is None:
111
  return ""
112
 
113
  if isinstance(payload, str):
114
  s = payload.strip()
115
 
116
- # Try JSON string
117
  if (s.startswith("{") and s.endswith("}")) or (s.startswith("[") and s.endswith("]")):
118
  try:
119
  decoded = json.loads(s)
@@ -121,7 +120,6 @@ def extract_text_from_any_payload(payload: Any) -> str:
121
  except Exception:
122
  pass
123
 
124
- # Try Python dict string
125
  try:
126
  decoded = ast.literal_eval(s)
127
  if isinstance(decoded, (dict, list)):
@@ -140,7 +138,7 @@ def extract_text_from_any_payload(payload: Any) -> str:
140
  extracted = extract_text_from_any_payload(payload[key])
141
  if extracted:
142
  return extracted
143
- # Fallback: flatten all string values
144
  strings: List[str] = []
145
  for v in payload.values():
146
  if isinstance(v, (str, dict, list)):
@@ -157,25 +155,42 @@ def extract_text_from_any_payload(payload: Any) -> str:
157
 
158
 
159
  def get_user_text(req: ChatRequest, raw_body: Any = None) -> str:
160
- # First use typed fields
161
  for field in ["message", "prompt", "query", "text", "user_message"]:
162
  value = getattr(req, field, None)
163
  if isinstance(value, str) and value.strip():
164
  return value.strip()
165
 
166
- # Then raw request body
167
  return extract_text_from_any_payload(raw_body).strip()
168
 
169
 
170
- def detect_help_mode(text: str, supplied: Optional[str]) -> str:
171
- if supplied in {"hint", "walkthrough", "answer"}:
172
- return supplied
173
- lower = text.lower()
174
- if "hint" in lower:
175
- return "hint"
176
- if "walkthrough" in lower or "step by step" in lower or "work through" in lower:
177
- return "walkthrough"
178
- return "answer"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
179
 
180
 
181
  def has_answer_choices(text: str) -> bool:
@@ -190,12 +205,6 @@ def has_answer_choices(text: str) -> bool:
190
 
191
 
192
  def extract_choices(text: str) -> Dict[str, str]:
193
- """
194
- Supports:
195
- A) 5
196
- B. 7
197
- C: 9
198
- """
199
  matches = list(
200
  re.finditer(
201
  r"(?im)(?:^|\n|\s)([A-E])[\)\.\:]\s*(.*?)(?=(?:\n?\s*[A-E][\)\.\:]\s)|$)",
@@ -221,6 +230,160 @@ def extract_numbers(text: str) -> List[float]:
221
  return out
222
 
223
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
224
  def is_quant_question(text: str) -> bool:
225
  lower = text.lower()
226
  quant_keywords = [
@@ -238,51 +401,16 @@ def is_quant_question(text: str) -> bool:
238
  return False
239
 
240
 
241
- def style_prefix(tone: float) -> str:
242
- if tone < 0.33:
243
- return "Let's solve it efficiently."
244
- if tone < 0.66:
245
- return "Let's work through it."
246
- return "You’ve got this — let’s solve it cleanly."
247
-
248
-
249
- def format_reply(
250
- core: str,
251
- tone: float,
252
- verbosity: float,
253
- transparency: float,
254
- help_mode: str,
255
- ) -> str:
256
- prefix = style_prefix(tone)
257
-
258
- if help_mode == "hint":
259
- if transparency < 0.4:
260
- return f"{prefix}\n\nHint: {core}"
261
- return f"{prefix}\n\nHint:\n{core}"
262
-
263
- if help_mode == "walkthrough":
264
- if verbosity < 0.35:
265
- return f"{prefix}\n\n{core}"
266
- return f"{prefix}\n\nWalkthrough:\n{core}"
267
-
268
- # answer
269
- if verbosity < 0.33:
270
- return f"{prefix}\n\n{core}"
271
- if transparency >= 0.5:
272
- return f"{prefix}\n\nAnswer:\n{core}"
273
- return f"{prefix}\n\n{core}"
274
-
275
-
276
  # =========================================================
277
- # Simple quant engine
278
  # =========================================================
279
 
280
  def try_eval_expression(expr: str) -> Optional[float]:
281
  expr = clean_math_text(expr)
282
  expr = expr.strip()
283
-
284
- # very light cleanup
285
  expr = re.sub(r"[^0-9\.\+\-\*\/\(\)\s]", "", expr)
 
286
  if not expr:
287
  return None
288
 
@@ -293,11 +421,6 @@ def try_eval_expression(expr: str) -> Optional[float]:
293
 
294
 
295
  def parse_direct_expression_question(text: str) -> Optional[str]:
296
- """
297
- Examples:
298
- - What is 12*8 - 5?
299
- - Calculate (15 + 3)/2
300
- """
301
  lower = text.lower()
302
  m = re.search(
303
  r"(?:what is|calculate|compute|evaluate|find)\s+([0-9\.\+\-\*\/\(\)\s]+)\??",
@@ -311,51 +434,61 @@ def parse_direct_expression_question(text: str) -> Optional[str]:
311
  return expr
312
 
313
 
314
- def solve_basic_expression(text: str, help_mode: str) -> Optional[str]:
315
  expr = parse_direct_expression_question(text)
316
  if not expr:
317
  return None
 
318
  value = try_eval_expression(expr)
319
  if value is None:
320
  return None
321
 
322
  if help_mode == "hint":
323
- return "Focus on order of operations: parentheses, multiplication/division, then addition/subtraction."
324
- if help_mode == "walkthrough":
325
- return f"Evaluate the expression {expr} using order of operations.\nThat gives {value:g}."
326
- return f"The value is {value:g}."
 
 
 
327
 
328
 
329
- def solve_percent_question(text: str, help_mode: str) -> Optional[str]:
330
  lower = text.lower()
331
 
332
- # "what is 20% of 150"
333
  m = re.search(r"what is\s+(\d+(?:\.\d+)?)\s*%\s+of\s+(\d+(?:\.\d+)?)", lower)
334
  if m:
335
  p = float(m.group(1))
336
  n = float(m.group(2))
337
  ans = p / 100 * n
 
338
  if help_mode == "hint":
339
- return "Convert the percent to a decimal, then multiply."
340
- if help_mode == "walkthrough":
341
- return f"{p}% of {n} = {p/100:g} × {n} = {ans:g}."
342
- return f"The answer is {ans:g}."
 
 
 
343
 
344
- # "x is what percent of y"
345
  m = re.search(r"(\d+(?:\.\d+)?)\s+is\s+what percent of\s+(\d+(?:\.\d+)?)", lower)
346
  if m:
347
  x = float(m.group(1))
348
  y = float(m.group(2))
349
  if y == 0:
350
  return None
 
351
  ans = x / y * 100
 
352
  if help_mode == "hint":
353
- return "Use part ÷ whole × 100."
354
- if help_mode == "walkthrough":
355
- return f"Percent = ({x} / {y}) × 100 = {ans:g}%."
356
- return f"The answer is {ans:g}%."
 
 
 
357
 
358
- # "increased by 20%" / "decreased by 20%"
359
  m = re.search(
360
  r"(\d+(?:\.\d+)?)\s+(?:is\s+)?(increased|decreased)\s+by\s+(\d+(?:\.\d+)?)\s*%",
361
  lower,
@@ -365,24 +498,33 @@ def solve_percent_question(text: str, help_mode: str) -> Optional[str]:
365
  direction = m.group(2)
366
  p = float(m.group(3)) / 100
367
  ans = base * (1 + p) if direction == "increased" else base * (1 - p)
 
368
  if help_mode == "hint":
369
- return "Use multiplier form: increase → 1 + p, decrease → 1 - p."
370
- if help_mode == "walkthrough":
371
  mult = 1 + p if direction == "increased" else 1 - p
372
- return f"Multiplier = {mult:g}, so {base:g} × {mult:g} = {ans:g}."
373
- return f"The result is {ans:g}."
 
 
 
374
 
375
  return None
376
 
377
 
378
- def solve_ratio_question(text: str, help_mode: str) -> Optional[str]:
379
  lower = text.lower()
380
 
381
- # "ratio of a to b is 3:5 and total is 32"
382
  m = re.search(
383
  r"ratio of .*? is\s+(\d+)\s*:\s*(\d+).*?total (?:is|of)\s+(\d+(?:\.\d+)?)",
384
  lower
385
  )
 
 
 
 
 
 
386
  if m:
387
  a = float(m.group(1))
388
  b = float(m.group(2))
@@ -390,123 +532,144 @@ def solve_ratio_question(text: str, help_mode: str) -> Optional[str]:
390
  parts = a + b
391
  if parts == 0:
392
  return None
 
393
  first = total * a / parts
394
  second = total * b / parts
 
395
  if help_mode == "hint":
396
- return "Add the ratio parts, then convert each part into a fraction of the total."
397
- if help_mode == "walkthrough":
398
- return (
399
  f"Total parts = {a:g} + {b:g} = {parts:g}.\n"
400
  f"First quantity = {a:g}/{parts:g} × {total:g} = {first:g}.\n"
401
  f"Second quantity = {b:g}/{parts:g} × {total:g} = {second:g}."
402
  )
403
- return f"The two quantities are {first:g} and {second:g}."
 
 
 
404
 
405
  return None
406
 
407
 
408
- def solve_average_question(text: str, help_mode: str) -> Optional[str]:
409
  lower = text.lower()
410
 
411
  if "mean" in lower or "average" in lower:
412
  nums = extract_numbers(text)
413
  if len(nums) >= 2:
414
  ans = mean(nums)
 
415
  if help_mode == "hint":
416
- return "Add the numbers, then divide by how many there are."
417
- if help_mode == "walkthrough":
418
- return f"The numbers are {', '.join(f'{x:g}' for x in nums)}.\nTheir average is {ans:g}."
419
- return f"The average is {ans:g}."
 
 
 
420
 
421
  if "median" in lower:
422
  nums = extract_numbers(text)
423
  if len(nums) >= 2:
424
  ans = median(nums)
 
425
  if help_mode == "hint":
426
- return "Sort the numbers first, then take the middle value."
427
- if help_mode == "walkthrough":
428
  s = sorted(nums)
429
- return f"Sorted numbers: {', '.join(f'{x:g}' for x in s)}.\nThe median is {ans:g}."
430
- return f"The median is {ans:g}."
 
 
 
431
 
432
  return None
433
 
434
 
435
- def solve_probability_question(text: str, help_mode: str) -> Optional[str]:
436
  lower = text.lower()
437
 
438
- # Simple "probability of selecting one red from 3 red and 5 blue"
439
- m = re.search(
440
- r"(\d+)\s+red.*?(\d+)\s+blue.*?probability",
441
- lower
442
- )
443
  if m:
444
  r = float(m.group(1))
445
  b = float(m.group(2))
446
  total = r + b
447
  if total == 0:
448
  return None
 
449
  ans = r / total
 
450
  if help_mode == "hint":
451
- return "Probability = favorable outcomes ÷ total outcomes."
452
- if help_mode == "walkthrough":
453
- return f"Favorable outcomes = {r:g}, total outcomes = {total:g}, so probability = {r:g}/{total:g} = {ans:g}."
454
- return f"The probability is {ans:g}."
 
 
 
455
 
456
  return None
457
 
458
 
459
- def solve_geometry_question(text: str, help_mode: str) -> Optional[str]:
460
  lower = text.lower()
461
 
462
- # Rectangle area
463
  m = re.search(r"rectangle.*?length\s*(?:is|=)?\s*(\d+(?:\.\d+)?)\D+width\s*(?:is|=)?\s*(\d+(?:\.\d+)?)", lower)
464
  if m and "area" in lower:
465
  l = float(m.group(1))
466
  w = float(m.group(2))
467
  ans = l * w
 
468
  if help_mode == "hint":
469
- return "For a rectangle, area = length × width."
470
- if help_mode == "walkthrough":
471
- return f"Area = {l:g} × {w:g} = {ans:g}."
472
- return f"The area is {ans:g}."
 
 
 
473
 
474
- # Circle area
475
  m = re.search(r"circle.*?radius\s*(?:is|=)?\s*(\d+(?:\.\d+)?)", lower)
476
  if m and "area" in lower:
477
  r = float(m.group(1))
478
  ans = math.pi * r * r
 
479
  if help_mode == "hint":
480
- return "For a circle, area = πr²."
481
- if help_mode == "walkthrough":
482
- return f"Area = π({r:g})² = {ans:.4f}."
483
- return f"The area is {ans:.4f}."
 
 
 
484
 
485
- # Triangle area
486
  m = re.search(r"triangle.*?base\s*(?:is|=)?\s*(\d+(?:\.\d+)?)\D+height\s*(?:is|=)?\s*(\d+(?:\.\d+)?)", lower)
487
  if m and "area" in lower:
488
  b = float(m.group(1))
489
  h = float(m.group(2))
490
  ans = 0.5 * b * h
 
491
  if help_mode == "hint":
492
- return "Triangle area = 1/2 × base × height."
493
- if help_mode == "walkthrough":
494
- return f"Area = 1/2 × {b:g} × {h:g} = {ans:g}."
495
- return f"The area is {ans:g}."
 
 
 
496
 
497
  return None
498
 
499
 
500
- def solve_linear_equation(text: str, help_mode: str) -> Optional[str]:
501
  if sp is None:
502
  return None
503
 
504
  lower = text.lower().replace("^", "**")
505
 
506
- # Find "solve for x: 2x + 3 = 11"
507
  m = re.search(r"(?:solve for x|find x|value of x).*?([\-0-9a-z\+\*/\s\(\)]+=[\-0-9a-z\+\*/\s\(\)]+)", lower)
508
  if not m:
509
- # Or just something like "2x + 3 = 11"
510
  m = re.search(r"([\-0-9a-z\+\*/\s\(\)]+=[\-0-9a-z\+\*/\s\(\)]+)", lower)
511
  if not m:
512
  return None
@@ -530,10 +693,13 @@ def solve_linear_equation(text: str, help_mode: str) -> Optional[str]:
530
  sol = sols[0]
531
 
532
  if help_mode == "hint":
533
- return "Collect the x-terms on one side and constants on the other."
534
- if help_mode == "walkthrough":
535
- return f"Solve {eq_text} for x.\nThis gives x = {sp.simplify(sol)}."
536
- return f"x = {sp.simplify(sol)}."
 
 
 
537
  except Exception:
538
  return None
539
 
@@ -543,13 +709,14 @@ def compare_to_choices_numeric(answer_value: float, choices: Dict[str, str]) ->
543
  best_diff = float("inf")
544
 
545
  for letter, raw in choices.items():
546
- # pick first numeric expression in the choice
547
  expr = raw.strip()
548
  expr_val = try_eval_expression(expr)
 
549
  if expr_val is None:
550
  nums = extract_numbers(expr)
551
  if len(nums) == 1:
552
  expr_val = nums[0]
 
553
  if expr_val is None:
554
  continue
555
 
@@ -558,17 +725,14 @@ def compare_to_choices_numeric(answer_value: float, choices: Dict[str, str]) ->
558
  best_diff = diff
559
  best_letter = letter
560
 
561
- if best_letter is None:
562
- return None
563
  return best_letter
564
 
565
 
566
- def solve_by_option_checking(text: str, help_mode: str) -> Optional[str]:
567
  choices = extract_choices(text)
568
  if len(choices) < 3:
569
  return None
570
 
571
- # Try direct expression in stem
572
  expr = parse_direct_expression_question(text)
573
  if expr:
574
  val = try_eval_expression(expr)
@@ -576,12 +740,14 @@ def solve_by_option_checking(text: str, help_mode: str) -> Optional[str]:
576
  letter = compare_to_choices_numeric(val, choices)
577
  if letter:
578
  if help_mode == "hint":
579
- return "Evaluate the expression first, then match it to the answer choices."
580
- if help_mode == "walkthrough":
581
- return f"The expression evaluates to {val:g}, which matches choice {letter}."
582
- return f"The correct choice is {letter}."
 
 
 
583
 
584
- # Try percent
585
  lower = text.lower()
586
  m = re.search(r"what is\s+(\d+(?:\.\d+)?)\s*%\s+of\s+(\d+(?:\.\d+)?)", lower)
587
  if m:
@@ -589,29 +755,15 @@ def solve_by_option_checking(text: str, help_mode: str) -> Optional[str]:
589
  letter = compare_to_choices_numeric(val, choices)
590
  if letter:
591
  if help_mode == "hint":
592
- return "Compute the percentage, then match it to the options."
593
- if help_mode == "walkthrough":
594
- return f"{m.group(1)}% of {m.group(2)} = {val:g}, so the correct choice is {letter}."
595
- return f"The correct choice is {letter}."
596
-
597
- return None
598
 
 
599
 
600
- def fallback_quant_response(help_mode: str) -> str:
601
- if help_mode == "hint":
602
- return (
603
- "Identify the question type first: arithmetic, percent, ratio, algebra, probability, "
604
- "statistics, or geometry. Then extract the given numbers and set up the core relation."
605
- )
606
- if help_mode == "walkthrough":
607
- return (
608
- "I can help, but this question needs the full stem to solve properly.\n"
609
- "Please paste the complete GMAT-style question including any answer choices A-E."
610
- )
611
- return (
612
- "Please paste the full GMAT-style question, ideally with answer choices A-E, "
613
- "so I can solve it directly."
614
- )
615
 
616
 
617
  def solve_quant(text: str, help_mode: str) -> SolverResult:
@@ -630,110 +782,225 @@ def solve_quant(text: str, help_mode: str) -> SolverResult:
630
  try:
631
  out = solver(text, help_mode)
632
  if out:
633
- return SolverResult(
634
- reply=out,
635
- domain="quant",
636
- solved=True,
637
- help_mode=help_mode,
638
- )
639
  except Exception:
640
  pass
641
 
642
- return SolverResult(
643
- reply=fallback_quant_response(help_mode),
644
- domain="quant",
645
- solved=False,
646
- help_mode=help_mode,
647
- )
 
 
 
 
 
 
 
 
 
 
648
 
649
 
650
  # =========================================================
651
- # Light verbal / general GMAT support
652
  # =========================================================
653
 
654
- def solve_verbal_or_general(text: str, help_mode: str) -> SolverResult:
655
- lower = text.lower()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
656
 
657
  if any(k in lower for k in ["sentence correction", "grammar", "verbal", "critical reasoning", "reading comprehension"]):
658
  if help_mode == "hint":
659
  reply = (
660
- "For GMAT verbal, first identify the task:\n"
661
  "- Sentence Correction: grammar + meaning\n"
662
  "- Critical Reasoning: conclusion, evidence, assumption\n"
663
  "- Reading Comprehension: passage role, inference, detail"
664
  )
665
  elif help_mode == "walkthrough":
666
  reply = (
667
- "Paste the full verbal question and all answer choices A-E.\n"
668
- "I can then eliminate choices based on grammar, logic, scope, or passage support."
669
  )
670
  else:
671
- reply = (
672
- "Paste the full verbal question with answer choices A-E and I’ll help eliminate the options."
673
- )
674
  return SolverResult(reply=reply, domain="verbal", solved=False, help_mode=help_mode)
675
 
676
- return SolverResult(
677
- reply=(
678
- "Paste the full GMAT-style question, ideally with answer choices A-E.\n"
679
- "I can handle:\n"
680
- "- arithmetic, percentages, ratios, algebra, basic statistics, probability, geometry\n"
681
- "- option checking for multiple-choice quant questions\n"
682
- "- strategy help for verbal questions\n"
683
- "Also tell me whether you want a hint, a walkthrough, or just the final answer."
684
- ),
685
- domain="fallback",
686
- solved=False,
687
- help_mode=help_mode,
688
- )
689
 
 
690
 
691
- # =========================================================
692
- # Main chat logic
693
- # =========================================================
694
 
695
  def generate_response(
696
- user_text: str,
697
  tone: float,
698
  verbosity: float,
699
  transparency: float,
700
  help_mode: str,
701
  ) -> SolverResult:
702
- text = user_text.strip()
 
 
703
 
704
- if not text:
705
- return SolverResult(
706
- reply=(
707
- "Paste the GMAT-style question and I’ll solve it.\n"
708
- "Include answer choices A-E if it’s multiple choice."
709
- ),
710
  domain="fallback",
711
  solved=False,
712
  help_mode=help_mode,
713
  )
714
-
715
- # If the user asks for modes explicitly
716
- if text.lower() in {"hint", "walkthrough", "answer"}:
717
- return SolverResult(
718
- reply="Got it. Now paste the full question.",
719
- domain="control",
720
- solved=False,
721
- help_mode=text.lower(),
722
- )
723
-
724
- # Quant vs verbal/fallback
725
- if is_quant_question(text):
726
- result = solve_quant(text, help_mode)
727
- else:
728
- result = solve_verbal_or_general(text, help_mode)
729
-
730
- result.reply = format_reply(
731
- core=result.reply,
732
- tone=tone,
733
- verbosity=verbosity,
734
- transparency=transparency,
735
- help_mode=help_mode,
736
- )
737
  return result
738
 
739
 
@@ -743,7 +1010,7 @@ def generate_response(
743
 
744
  @app.get("/health")
745
  def health() -> Dict[str, Any]:
746
- return {"ok": True, "app": "GMAT Solver v2"}
747
 
748
 
749
  @app.get("/", response_class=HTMLResponse)
@@ -753,7 +1020,7 @@ def home() -> str:
753
  <html>
754
  <head>
755
  <meta charset="utf-8">
756
- <title>GMAT Solver v2</title>
757
  <style>
758
  body { font-family: Arial, sans-serif; max-width: 900px; margin: 40px auto; padding: 0 16px; }
759
  textarea { width: 100%; min-height: 220px; padding: 12px; font-size: 16px; }
@@ -766,16 +1033,14 @@ def home() -> str:
766
  </style>
767
  </head>
768
  <body>
769
- <h1>GMAT Solver v2</h1>
770
- <p>Paste a full GMAT-style question. Quant works best. Include answer choices A-E when possible.</p>
771
 
772
- <label for="message">Question</label>
773
- <textarea id="message" placeholder="Example: What is 20% of 150?
774
- A) 20
775
- B) 25
776
- C) 30
777
- D) 35
778
- E) 40"></textarea>
779
 
780
  <div class="row">
781
  <div class="card">
@@ -835,12 +1100,6 @@ E) 40"></textarea>
835
 
836
  @app.post("/chat")
837
  async def chat(request: Request) -> JSONResponse:
838
- """
839
- Robust endpoint:
840
- - accepts normal JSON body
841
- - survives if body is a raw string
842
- - survives nested payload structures
843
- """
844
  raw_body: Any = None
845
 
846
  try:
@@ -851,8 +1110,8 @@ async def chat(request: Request) -> JSONResponse:
851
  except Exception:
852
  raw_body = None
853
 
854
- # Build typed request if possible
855
  req_data: Dict[str, Any] = raw_body if isinstance(raw_body, dict) else {}
 
856
  try:
857
  req = ChatRequest(**req_data)
858
  except Exception:
@@ -865,7 +1124,7 @@ async def chat(request: Request) -> JSONResponse:
865
  help_mode = detect_help_mode(user_text, req_data.get("help_mode", req.help_mode))
866
 
867
  result = generate_response(
868
- user_text=user_text,
869
  tone=tone,
870
  verbosity=verbosity,
871
  transparency=transparency,
@@ -879,6 +1138,8 @@ async def chat(request: Request) -> JSONResponse:
879
  "domain": result.domain,
880
  "solved": result.solved,
881
  "help_mode": result.help_mode,
 
 
882
  },
883
  }
884
  )
 
5
  import json
6
  import math
7
  import os
 
8
  import re
9
  from dataclasses import dataclass
10
  from statistics import mean, median
11
+ from typing import Any, Dict, List, Optional
12
 
13
  from fastapi import FastAPI, Request
14
  from fastapi.middleware.cors import CORSMiddleware
 
25
  # App setup
26
  # =========================================================
27
 
28
+ app = FastAPI(title="GMAT Solver v3", version="3.0.0")
29
 
30
  app.add_middleware(
31
  CORSMiddleware,
 
51
  verbosity: Optional[float] = 0.5
52
  transparency: Optional[float] = 0.5
53
 
54
+ help_mode: Optional[str] = "answer"
55
  history: Optional[List[Dict[str, Any]]] = None
56
 
57
 
58
  # =========================================================
59
+ # Data classes
60
  # =========================================================
61
 
 
 
 
62
  @dataclass
63
  class SolverResult:
64
  reply: str
65
  domain: str
66
  solved: bool
67
  help_mode: str
68
+ answer_letter: Optional[str] = None
69
+ answer_value: Optional[str] = None
70
+
71
+
72
+ @dataclass
73
+ class ParsedContext:
74
+ raw_message: str
75
+ visible_user_text: str
76
+ full_context_text: str
77
+ question_text: str
78
+ options_text: str
79
+ combined_question_block: str
80
+ recent_conversation: str
81
+
82
+
83
+ # =========================================================
84
+ # Utilities
85
+ # =========================================================
86
+
87
+ CHOICE_LETTERS = ["A", "B", "C", "D", "E"]
88
 
89
 
90
  def clamp01(x: Any, default: float = 0.5) -> float:
 
95
  return default
96
 
97
 
 
 
 
 
 
 
 
98
  def normalize_spaces(text: str) -> str:
99
  return re.sub(r"\s+", " ", text).strip()
100
 
 
103
  t = text
104
  t = t.replace("×", "*").replace("÷", "/").replace("–", "-").replace("—", "-")
105
  t = t.replace("−", "-").replace("^", "**")
 
106
  return t
107
 
108
 
109
  def extract_text_from_any_payload(payload: Any) -> str:
 
 
 
 
 
 
 
 
110
  if payload is None:
111
  return ""
112
 
113
  if isinstance(payload, str):
114
  s = payload.strip()
115
 
 
116
  if (s.startswith("{") and s.endswith("}")) or (s.startswith("[") and s.endswith("]")):
117
  try:
118
  decoded = json.loads(s)
 
120
  except Exception:
121
  pass
122
 
 
123
  try:
124
  decoded = ast.literal_eval(s)
125
  if isinstance(decoded, (dict, list)):
 
138
  extracted = extract_text_from_any_payload(payload[key])
139
  if extracted:
140
  return extracted
141
+
142
  strings: List[str] = []
143
  for v in payload.values():
144
  if isinstance(v, (str, dict, list)):
 
155
 
156
 
157
  def get_user_text(req: ChatRequest, raw_body: Any = None) -> str:
 
158
  for field in ["message", "prompt", "query", "text", "user_message"]:
159
  value = getattr(req, field, None)
160
  if isinstance(value, str) and value.strip():
161
  return value.strip()
162
 
 
163
  return extract_text_from_any_payload(raw_body).strip()
164
 
165
 
166
+ def style_prefix(tone: float) -> str:
167
+ if tone < 0.33:
168
+ return "Let’s solve it efficiently."
169
+ if tone < 0.66:
170
+ return "Let’s work through it."
171
+ return "You’ve got this — let’s solve it cleanly."
172
+
173
+
174
+ def format_reply(core: str, tone: float, verbosity: float, transparency: float, help_mode: str) -> str:
175
+ prefix = style_prefix(tone)
176
+
177
+ if help_mode == "hint":
178
+ if transparency < 0.4:
179
+ return f"{prefix}\n\nHint: {core}"
180
+ return f"{prefix}\n\nHint:\n{core}"
181
+
182
+ if help_mode == "walkthrough":
183
+ if verbosity < 0.35:
184
+ return f"{prefix}\n\n{core}"
185
+ return f"{prefix}\n\nWalkthrough:\n{core}"
186
+
187
+ if verbosity < 0.33:
188
+ return f"{prefix}\n\n{core}"
189
+
190
+ if transparency >= 0.5:
191
+ return f"{prefix}\n\n{core}"
192
+
193
+ return f"{prefix}\n\n{core}"
194
 
195
 
196
  def has_answer_choices(text: str) -> bool:
 
205
 
206
 
207
  def extract_choices(text: str) -> Dict[str, str]:
 
 
 
 
 
 
208
  matches = list(
209
  re.finditer(
210
  r"(?im)(?:^|\n|\s)([A-E])[\)\.\:]\s*(.*?)(?=(?:\n?\s*[A-E][\)\.\:]\s)|$)",
 
230
  return out
231
 
232
 
233
+ def extract_section(text: str, start_label: str, end_labels: List[str]) -> str:
234
+ start_idx = text.find(start_label)
235
+ if start_idx == -1:
236
+ return ""
237
+
238
+ start_idx += len(start_label)
239
+ remaining = text[start_idx:]
240
+
241
+ end_positions = []
242
+ for label in end_labels:
243
+ idx = remaining.find(label)
244
+ if idx != -1:
245
+ end_positions.append(idx)
246
+
247
+ if end_positions:
248
+ end_idx = min(end_positions)
249
+ return remaining[:end_idx].strip()
250
+
251
+ return remaining.strip()
252
+
253
+
254
+ def parse_hidden_context(message: str) -> ParsedContext:
255
+ raw = message or ""
256
+
257
+ visible_user_text = ""
258
+ question_text = ""
259
+ options_text = ""
260
+ recent_conversation = ""
261
+
262
+ if "Latest player message:" in raw:
263
+ visible_user_text = extract_section(raw, "Latest player message:", [])
264
+ elif "Player message:" in raw:
265
+ visible_user_text = extract_section(raw, "Player message:", [])
266
+ else:
267
+ visible_user_text = raw.strip()
268
+
269
+ if "Question:" in raw:
270
+ question_text = extract_section(
271
+ raw,
272
+ "Question:",
273
+ ["\nOptions:", "\nPlayer balance:", "\nLast outcome:", "\nRecent conversation:", "\nLatest player message:", "\nPlayer message:"]
274
+ )
275
+
276
+ if "Options:" in raw:
277
+ options_text = extract_section(
278
+ raw,
279
+ "Options:",
280
+ ["\nPlayer balance:", "\nLast outcome:", "\nRecent conversation:", "\nLatest player message:", "\nPlayer message:"]
281
+ )
282
+
283
+ if "Recent conversation:" in raw:
284
+ recent_conversation = extract_section(
285
+ raw,
286
+ "Recent conversation:",
287
+ ["\nLatest player message:", "\nPlayer message:"]
288
+ )
289
+
290
+ combined_parts = []
291
+ if question_text:
292
+ combined_parts.append(question_text.strip())
293
+ if options_text:
294
+ combined_parts.append(options_text.strip())
295
+
296
+ combined_question_block = "\n".join([p for p in combined_parts if p]).strip()
297
+ if not combined_question_block:
298
+ combined_question_block = raw.strip()
299
+
300
+ return ParsedContext(
301
+ raw_message=raw,
302
+ visible_user_text=visible_user_text.strip(),
303
+ full_context_text=raw.strip(),
304
+ question_text=question_text.strip(),
305
+ options_text=options_text.strip(),
306
+ combined_question_block=combined_question_block.strip(),
307
+ recent_conversation=recent_conversation.strip(),
308
+ )
309
+
310
+
311
+ def detect_help_mode(user_text: str, supplied: Optional[str]) -> str:
312
+ lower = (user_text or "").lower().strip()
313
+
314
+ if supplied in {"hint", "walkthrough", "answer"}:
315
+ if lower not in {"hint", "walkthrough", "answer"}:
316
+ if "hint" in lower:
317
+ return "hint"
318
+ if "walkthrough" in lower or "step by step" in lower or "work through" in lower:
319
+ return "walkthrough"
320
+ if "explain" in lower and "why" in lower:
321
+ return "walkthrough"
322
+ return supplied
323
+ return supplied
324
+
325
+ if lower in {"hint", "just a hint", "hint please", "small hint"}:
326
+ return "hint"
327
+
328
+ if "hint" in lower:
329
+ return "hint"
330
+
331
+ if (
332
+ "walkthrough" in lower
333
+ or "step by step" in lower
334
+ or "work through" in lower
335
+ or "explain step by step" in lower
336
+ ):
337
+ return "walkthrough"
338
+
339
+ if "why" in lower or "explain" in lower:
340
+ return "walkthrough"
341
+
342
+ return "answer"
343
+
344
+
345
+ def user_is_referring_to_existing_question(user_text: str, question_text: str) -> bool:
346
+ lower = (user_text or "").lower().strip()
347
+
348
+ if not question_text:
349
+ return False
350
+
351
+ short_refs = {
352
+ "help", "hint", "why", "why?", "explain", "explain it", "explain this",
353
+ "answer", "what's the answer", "what is the answer", "is it a", "is it b",
354
+ "is it c", "is it d", "is it e", "which one", "which option",
355
+ "what do i do first", "what do i do", "how do i start", "are you sure",
356
+ "can you help", "help me", "i don't get it", "i dont get it",
357
+ "why is it c", "why is it b", "why is it a", "why is it d", "why is it e"
358
+ }
359
+
360
+ if lower in short_refs:
361
+ return True
362
+
363
+ if len(lower) <= 25 and any(phrase in lower for phrase in [
364
+ "is it ", "why", "hint", "explain", "answer", "help", "first step"
365
+ ]):
366
+ return True
367
+
368
+ return False
369
+
370
+
371
+ def mentions_choice_letter(user_text: str) -> Optional[str]:
372
+ lower = (user_text or "").lower().strip()
373
+ m = re.search(r"\b(?:is it|answer is|it's|its|option|choice)\s*([a-e])\b", lower)
374
+ if m:
375
+ return m.group(1).upper()
376
+
377
+ m = re.search(r"\bwhy is it\s*([a-e])\b", lower)
378
+ if m:
379
+ return m.group(1).upper()
380
+
381
+ if lower in {"a", "b", "c", "d", "e"}:
382
+ return lower.upper()
383
+
384
+ return None
385
+
386
+
387
  def is_quant_question(text: str) -> bool:
388
  lower = text.lower()
389
  quant_keywords = [
 
401
  return False
402
 
403
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
404
  # =========================================================
405
+ # Quant engine
406
  # =========================================================
407
 
408
  def try_eval_expression(expr: str) -> Optional[float]:
409
  expr = clean_math_text(expr)
410
  expr = expr.strip()
411
+ expr = expr.replace("%", "/100")
 
412
  expr = re.sub(r"[^0-9\.\+\-\*\/\(\)\s]", "", expr)
413
+
414
  if not expr:
415
  return None
416
 
 
421
 
422
 
423
  def parse_direct_expression_question(text: str) -> Optional[str]:
 
 
 
 
 
424
  lower = text.lower()
425
  m = re.search(
426
  r"(?:what is|calculate|compute|evaluate|find)\s+([0-9\.\+\-\*\/\(\)\s]+)\??",
 
434
  return expr
435
 
436
 
437
+ def solve_basic_expression(text: str, help_mode: str) -> Optional[SolverResult]:
438
  expr = parse_direct_expression_question(text)
439
  if not expr:
440
  return None
441
+
442
  value = try_eval_expression(expr)
443
  if value is None:
444
  return None
445
 
446
  if help_mode == "hint":
447
+ reply = "Focus on order of operations: parentheses, multiplication/division, then addition/subtraction."
448
+ elif help_mode == "walkthrough":
449
+ reply = f"Evaluate the expression {expr} using order of operations.\nThat gives {value:g}."
450
+ else:
451
+ reply = f"The value is {value:g}."
452
+
453
+ return SolverResult(reply=reply, domain="quant", solved=True, help_mode=help_mode, answer_value=f"{value:g}")
454
 
455
 
456
+ def solve_percent_question(text: str, help_mode: str) -> Optional[SolverResult]:
457
  lower = text.lower()
458
 
 
459
  m = re.search(r"what is\s+(\d+(?:\.\d+)?)\s*%\s+of\s+(\d+(?:\.\d+)?)", lower)
460
  if m:
461
  p = float(m.group(1))
462
  n = float(m.group(2))
463
  ans = p / 100 * n
464
+
465
  if help_mode == "hint":
466
+ reply = "Convert the percent to a decimal, then multiply."
467
+ elif help_mode == "walkthrough":
468
+ reply = f"{p}% of {n} = {p/100:g} × {n} = {ans:g}."
469
+ else:
470
+ reply = f"The answer is {ans:g}."
471
+
472
+ return SolverResult(reply=reply, domain="quant", solved=True, help_mode=help_mode, answer_value=f"{ans:g}")
473
 
 
474
  m = re.search(r"(\d+(?:\.\d+)?)\s+is\s+what percent of\s+(\d+(?:\.\d+)?)", lower)
475
  if m:
476
  x = float(m.group(1))
477
  y = float(m.group(2))
478
  if y == 0:
479
  return None
480
+
481
  ans = x / y * 100
482
+
483
  if help_mode == "hint":
484
+ reply = "Use part ÷ whole × 100."
485
+ elif help_mode == "walkthrough":
486
+ reply = f"Percent = ({x} / {y}) × 100 = {ans:g}%."
487
+ else:
488
+ reply = f"The answer is {ans:g}%."
489
+
490
+ return SolverResult(reply=reply, domain="quant", solved=True, help_mode=help_mode, answer_value=f"{ans:g}%")
491
 
 
492
  m = re.search(
493
  r"(\d+(?:\.\d+)?)\s+(?:is\s+)?(increased|decreased)\s+by\s+(\d+(?:\.\d+)?)\s*%",
494
  lower,
 
498
  direction = m.group(2)
499
  p = float(m.group(3)) / 100
500
  ans = base * (1 + p) if direction == "increased" else base * (1 - p)
501
+
502
  if help_mode == "hint":
503
+ reply = "Use multiplier form: increase → 1 + p, decrease → 1 - p."
504
+ elif help_mode == "walkthrough":
505
  mult = 1 + p if direction == "increased" else 1 - p
506
+ reply = f"Multiplier = {mult:g}, so {base:g} × {mult:g} = {ans:g}."
507
+ else:
508
+ reply = f"The result is {ans:g}."
509
+
510
+ return SolverResult(reply=reply, domain="quant", solved=True, help_mode=help_mode, answer_value=f"{ans:g}")
511
 
512
  return None
513
 
514
 
515
+ def solve_ratio_question(text: str, help_mode: str) -> Optional[SolverResult]:
516
  lower = text.lower()
517
 
 
518
  m = re.search(
519
  r"ratio of .*? is\s+(\d+)\s*:\s*(\d+).*?total (?:is|of)\s+(\d+(?:\.\d+)?)",
520
  lower
521
  )
522
+ if not m:
523
+ m = re.search(
524
+ r"(\d+)\s*:\s*(\d+).*?total (?:is|of)\s+(\d+(?:\.\d+)?)",
525
+ lower
526
+ )
527
+
528
  if m:
529
  a = float(m.group(1))
530
  b = float(m.group(2))
 
532
  parts = a + b
533
  if parts == 0:
534
  return None
535
+
536
  first = total * a / parts
537
  second = total * b / parts
538
+
539
  if help_mode == "hint":
540
+ reply = "Add the ratio parts, then convert each part into a fraction of the total."
541
+ elif help_mode == "walkthrough":
542
+ reply = (
543
  f"Total parts = {a:g} + {b:g} = {parts:g}.\n"
544
  f"First quantity = {a:g}/{parts:g} × {total:g} = {first:g}.\n"
545
  f"Second quantity = {b:g}/{parts:g} × {total:g} = {second:g}."
546
  )
547
+ else:
548
+ reply = f"The two quantities are {first:g} and {second:g}."
549
+
550
+ return SolverResult(reply=reply, domain="quant", solved=True, help_mode=help_mode, answer_value=f"{first:g}, {second:g}")
551
 
552
  return None
553
 
554
 
555
+ def solve_average_question(text: str, help_mode: str) -> Optional[SolverResult]:
556
  lower = text.lower()
557
 
558
  if "mean" in lower or "average" in lower:
559
  nums = extract_numbers(text)
560
  if len(nums) >= 2:
561
  ans = mean(nums)
562
+
563
  if help_mode == "hint":
564
+ reply = "Add the numbers, then divide by how many there are."
565
+ elif help_mode == "walkthrough":
566
+ reply = f"The numbers are {', '.join(f'{x:g}' for x in nums)}.\nTheir average is {ans:g}."
567
+ else:
568
+ reply = f"The average is {ans:g}."
569
+
570
+ return SolverResult(reply=reply, domain="quant", solved=True, help_mode=help_mode, answer_value=f"{ans:g}")
571
 
572
  if "median" in lower:
573
  nums = extract_numbers(text)
574
  if len(nums) >= 2:
575
  ans = median(nums)
576
+
577
  if help_mode == "hint":
578
+ reply = "Sort the numbers first, then take the middle value."
579
+ elif help_mode == "walkthrough":
580
  s = sorted(nums)
581
+ reply = f"Sorted numbers: {', '.join(f'{x:g}' for x in s)}.\nThe median is {ans:g}."
582
+ else:
583
+ reply = f"The median is {ans:g}."
584
+
585
+ return SolverResult(reply=reply, domain="quant", solved=True, help_mode=help_mode, answer_value=f"{ans:g}")
586
 
587
  return None
588
 
589
 
590
+ def solve_probability_question(text: str, help_mode: str) -> Optional[SolverResult]:
591
  lower = text.lower()
592
 
593
+ m = re.search(r"(\d+)\s+red.*?(\d+)\s+blue.*?probability", lower)
 
 
 
 
594
  if m:
595
  r = float(m.group(1))
596
  b = float(m.group(2))
597
  total = r + b
598
  if total == 0:
599
  return None
600
+
601
  ans = r / total
602
+
603
  if help_mode == "hint":
604
+ reply = "Probability = favorable outcomes ÷ total outcomes."
605
+ elif help_mode == "walkthrough":
606
+ reply = f"Favorable outcomes = {r:g}, total outcomes = {total:g}, so probability = {r:g}/{total:g} = {ans:g}."
607
+ else:
608
+ reply = f"The probability is {ans:g}."
609
+
610
+ return SolverResult(reply=reply, domain="quant", solved=True, help_mode=help_mode, answer_value=f"{ans:g}")
611
 
612
  return None
613
 
614
 
615
+ def solve_geometry_question(text: str, help_mode: str) -> Optional[SolverResult]:
616
  lower = text.lower()
617
 
 
618
  m = re.search(r"rectangle.*?length\s*(?:is|=)?\s*(\d+(?:\.\d+)?)\D+width\s*(?:is|=)?\s*(\d+(?:\.\d+)?)", lower)
619
  if m and "area" in lower:
620
  l = float(m.group(1))
621
  w = float(m.group(2))
622
  ans = l * w
623
+
624
  if help_mode == "hint":
625
+ reply = "For a rectangle, area = length × width."
626
+ elif help_mode == "walkthrough":
627
+ reply = f"Area = {l:g} × {w:g} = {ans:g}."
628
+ else:
629
+ reply = f"The area is {ans:g}."
630
+
631
+ return SolverResult(reply=reply, domain="quant", solved=True, help_mode=help_mode, answer_value=f"{ans:g}")
632
 
 
633
  m = re.search(r"circle.*?radius\s*(?:is|=)?\s*(\d+(?:\.\d+)?)", lower)
634
  if m and "area" in lower:
635
  r = float(m.group(1))
636
  ans = math.pi * r * r
637
+
638
  if help_mode == "hint":
639
+ reply = "For a circle, area = πr²."
640
+ elif help_mode == "walkthrough":
641
+ reply = f"Area = π({r:g})² = {ans:.4f}."
642
+ else:
643
+ reply = f"The area is {ans:.4f}."
644
+
645
+ return SolverResult(reply=reply, domain="quant", solved=True, help_mode=help_mode, answer_value=f"{ans:.4f}")
646
 
 
647
  m = re.search(r"triangle.*?base\s*(?:is|=)?\s*(\d+(?:\.\d+)?)\D+height\s*(?:is|=)?\s*(\d+(?:\.\d+)?)", lower)
648
  if m and "area" in lower:
649
  b = float(m.group(1))
650
  h = float(m.group(2))
651
  ans = 0.5 * b * h
652
+
653
  if help_mode == "hint":
654
+ reply = "Triangle area = 1/2 × base × height."
655
+ elif help_mode == "walkthrough":
656
+ reply = f"Area = 1/2 × {b:g} × {h:g} = {ans:g}."
657
+ else:
658
+ reply = f"The area is {ans:g}."
659
+
660
+ return SolverResult(reply=reply, domain="quant", solved=True, help_mode=help_mode, answer_value=f"{ans:g}")
661
 
662
  return None
663
 
664
 
665
+ def solve_linear_equation(text: str, help_mode: str) -> Optional[SolverResult]:
666
  if sp is None:
667
  return None
668
 
669
  lower = text.lower().replace("^", "**")
670
 
 
671
  m = re.search(r"(?:solve for x|find x|value of x).*?([\-0-9a-z\+\*/\s\(\)]+=[\-0-9a-z\+\*/\s\(\)]+)", lower)
672
  if not m:
 
673
  m = re.search(r"([\-0-9a-z\+\*/\s\(\)]+=[\-0-9a-z\+\*/\s\(\)]+)", lower)
674
  if not m:
675
  return None
 
693
  sol = sols[0]
694
 
695
  if help_mode == "hint":
696
+ reply = "Collect the x-terms on one side and constants on the other."
697
+ elif help_mode == "walkthrough":
698
+ reply = f"Solve {eq_text} for x.\nThis gives x = {sp.simplify(sol)}."
699
+ else:
700
+ reply = f"x = {sp.simplify(sol)}."
701
+
702
+ return SolverResult(reply=reply, domain="quant", solved=True, help_mode=help_mode, answer_value=str(sp.simplify(sol)))
703
  except Exception:
704
  return None
705
 
 
709
  best_diff = float("inf")
710
 
711
  for letter, raw in choices.items():
 
712
  expr = raw.strip()
713
  expr_val = try_eval_expression(expr)
714
+
715
  if expr_val is None:
716
  nums = extract_numbers(expr)
717
  if len(nums) == 1:
718
  expr_val = nums[0]
719
+
720
  if expr_val is None:
721
  continue
722
 
 
725
  best_diff = diff
726
  best_letter = letter
727
 
 
 
728
  return best_letter
729
 
730
 
731
+ def solve_by_option_checking(text: str, help_mode: str) -> Optional[SolverResult]:
732
  choices = extract_choices(text)
733
  if len(choices) < 3:
734
  return None
735
 
 
736
  expr = parse_direct_expression_question(text)
737
  if expr:
738
  val = try_eval_expression(expr)
 
740
  letter = compare_to_choices_numeric(val, choices)
741
  if letter:
742
  if help_mode == "hint":
743
+ reply = "Evaluate the expression first, then match it to the answer choices."
744
+ elif help_mode == "walkthrough":
745
+ reply = f"The expression evaluates to {val:g}, which matches choice {letter}."
746
+ else:
747
+ reply = f"The correct choice is {letter}."
748
+
749
+ return SolverResult(reply=reply, domain="quant", solved=True, help_mode=help_mode, answer_letter=letter, answer_value=f"{val:g}")
750
 
 
751
  lower = text.lower()
752
  m = re.search(r"what is\s+(\d+(?:\.\d+)?)\s*%\s+of\s+(\d+(?:\.\d+)?)", lower)
753
  if m:
 
755
  letter = compare_to_choices_numeric(val, choices)
756
  if letter:
757
  if help_mode == "hint":
758
+ reply = "Compute the percentage, then match it to the options."
759
+ elif help_mode == "walkthrough":
760
+ reply = f"{m.group(1)}% of {m.group(2)} = {val:g}, so the correct choice is {letter}."
761
+ else:
762
+ reply = f"The correct choice is {letter}."
 
763
 
764
+ return SolverResult(reply=reply, domain="quant", solved=True, help_mode=help_mode, answer_letter=letter, answer_value=f"{val:g}")
765
 
766
+ return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
767
 
768
 
769
  def solve_quant(text: str, help_mode: str) -> SolverResult:
 
782
  try:
783
  out = solver(text, help_mode)
784
  if out:
785
+ return out
 
 
 
 
 
786
  except Exception:
787
  pass
788
 
789
+ if help_mode == "hint":
790
+ reply = (
791
+ "Identify the question type first: arithmetic, percent, ratio, algebra, probability, "
792
+ "statistics, or geometry. Then extract the given numbers and set up the core relation."
793
+ )
794
+ elif help_mode == "walkthrough":
795
+ reply = (
796
+ "I can help with this, but I cannot confidently solve it from the current parse alone. "
797
+ "I can still talk through the first step or eliminate options if needed."
798
+ )
799
+ else:
800
+ reply = (
801
+ "I can help with this, but I can’t confidently solve it from the current parse alone yet."
802
+ )
803
+
804
+ return SolverResult(reply=reply, domain="quant", solved=False, help_mode=help_mode)
805
 
806
 
807
  # =========================================================
808
+ # Conversational layer
809
  # =========================================================
810
 
811
+ def build_choice_explanation(chosen_letter: str, result: SolverResult, choices: Dict[str, str], help_mode: str) -> str:
812
+ choice_text = choices.get(chosen_letter, "").strip()
813
+ value_text = result.answer_value.strip() if result.answer_value else ""
814
+
815
+ if help_mode == "hint":
816
+ if value_text and choice_text:
817
+ return f"Work out the value first, then compare it with choice {chosen_letter} ({choice_text})."
818
+ if choice_text:
819
+ return f"Focus on why choice {chosen_letter} fits the calculation better than the others."
820
+ return f"Focus on why choice {chosen_letter} matches the calculation."
821
+
822
+ if help_mode == "walkthrough":
823
+ if value_text and choice_text:
824
+ return (
825
+ f"The calculation gives {value_text}.\n"
826
+ f"Choice {chosen_letter} is {choice_text}, so it matches.\n"
827
+ f"That is why {chosen_letter} is the correct answer."
828
+ )
829
+ if choice_text:
830
+ return f"Choice {chosen_letter} matches the result of the calculation, so that is why it is correct."
831
+ return f"The solved result matches choice {chosen_letter}, so that is why it is correct."
832
+
833
+ if value_text and choice_text:
834
+ return f"Yes — it’s {chosen_letter}, because the calculation gives {value_text}, and choice {chosen_letter} is {choice_text}."
835
+ if choice_text:
836
+ return f"Yes — it’s {chosen_letter}, because that option matches the result."
837
+ return f"Yes — it’s {chosen_letter}."
838
+
839
+
840
+ def handle_conversational_followup(ctx: ParsedContext, help_mode: str) -> Optional[SolverResult]:
841
+ user_text = ctx.visible_user_text
842
+ lower = user_text.lower().strip()
843
+ question_block = ctx.combined_question_block
844
+
845
+ if not question_block:
846
+ return None
847
+
848
+ if not user_is_referring_to_existing_question(user_text, ctx.question_text):
849
+ return None
850
+
851
+ solved = solve_quant(question_block, "answer")
852
+
853
+ asked_letter = mentions_choice_letter(user_text)
854
+ choices = extract_choices(question_block)
855
+
856
+ if asked_letter and solved.answer_letter and asked_letter == solved.answer_letter:
857
+ reply = build_choice_explanation(asked_letter, solved, choices, help_mode)
858
+ return SolverResult(
859
+ reply=reply,
860
+ domain="quant",
861
+ solved=solved.solved,
862
+ help_mode=help_mode,
863
+ answer_letter=solved.answer_letter,
864
+ answer_value=solved.answer_value,
865
+ )
866
+
867
+ if asked_letter and solved.answer_letter and asked_letter != solved.answer_letter:
868
+ correct_choice_text = choices.get(solved.answer_letter, "").strip()
869
+ if help_mode == "hint":
870
+ reply = f"Check the calculation again and compare your result with choice {solved.answer_letter}, not {asked_letter}."
871
+ elif help_mode == "walkthrough":
872
+ reply = (
873
+ f"It is not {asked_letter}.\n"
874
+ f"The calculation leads to choice {solved.answer_letter}"
875
+ + (f", which is {correct_choice_text}." if correct_choice_text else ".")
876
+ )
877
+ else:
878
+ reply = f"It is not {asked_letter} — the correct choice is {solved.answer_letter}."
879
+ return SolverResult(
880
+ reply=reply,
881
+ domain="quant",
882
+ solved=solved.solved,
883
+ help_mode=help_mode,
884
+ answer_letter=solved.answer_letter,
885
+ answer_value=solved.answer_value,
886
+ )
887
+
888
+ if lower in {"help", "can you help", "help me", "i dont get it", "i don't get it"}:
889
+ if help_mode == "hint":
890
+ reply = solve_quant(question_block, "hint").reply
891
+ elif help_mode == "walkthrough":
892
+ reply = solve_quant(question_block, "walkthrough").reply
893
+ else:
894
+ reply = solve_quant(question_block, "answer").reply
895
+ return SolverResult(reply=reply, domain="quant", solved=solved.solved, help_mode=help_mode, answer_letter=solved.answer_letter, answer_value=solved.answer_value)
896
+
897
+ if "first step" in lower or "how do i start" in lower or "what do i do first" in lower:
898
+ hint_result = solve_quant(question_block, "hint")
899
+ return SolverResult(
900
+ reply=hint_result.reply,
901
+ domain="quant",
902
+ solved=hint_result.solved,
903
+ help_mode="hint",
904
+ answer_letter=hint_result.answer_letter,
905
+ answer_value=hint_result.answer_value,
906
+ )
907
+
908
+ if "why" in lower or "explain" in lower:
909
+ walkthrough_result = solve_quant(question_block, "walkthrough")
910
+ if solved.answer_letter and "why is it" not in lower:
911
+ choices = extract_choices(question_block)
912
+ choice_text = choices.get(solved.answer_letter, "").strip()
913
+ if choice_text:
914
+ extra = f"\n\nSo the correct choice is {solved.answer_letter} ({choice_text})."
915
+ else:
916
+ extra = f"\n\nSo the correct choice is {solved.answer_letter}."
917
+ walkthrough_result.reply += extra
918
+ return walkthrough_result
919
+
920
+ if "hint" in lower:
921
+ hint_result = solve_quant(question_block, "hint")
922
+ return hint_result
923
+
924
+ if "answer" in lower or "which one" in lower or "are you sure" in lower:
925
+ answer_result = solve_quant(question_block, "answer")
926
+ return answer_result
927
+
928
+ return None
929
+
930
+
931
+ # =========================================================
932
+ # Main response logic
933
+ # =========================================================
934
+
935
+ def solve_verbal_or_general(user_text: str, help_mode: str) -> SolverResult:
936
+ lower = user_text.lower()
937
 
938
  if any(k in lower for k in ["sentence correction", "grammar", "verbal", "critical reasoning", "reading comprehension"]):
939
  if help_mode == "hint":
940
  reply = (
941
+ "First identify the task:\n"
942
  "- Sentence Correction: grammar + meaning\n"
943
  "- Critical Reasoning: conclusion, evidence, assumption\n"
944
  "- Reading Comprehension: passage role, inference, detail"
945
  )
946
  elif help_mode == "walkthrough":
947
  reply = (
948
+ "I can help verbally too, but for now this backend is strongest on quant-style items. "
949
+ "For verbal, I’d use elimination based on grammar, logic, scope, or passage support."
950
  )
951
  else:
952
+ reply = "I can help with verbal strategy, but this version is strongest on quant-style questions right now."
953
+
 
954
  return SolverResult(reply=reply, domain="verbal", solved=False, help_mode=help_mode)
955
 
956
+ if help_mode == "hint":
957
+ reply = "I can help. Ask for a hint, an explanation, or the answer, and I’ll use the current in-game question context when available."
958
+ elif help_mode == "walkthrough":
959
+ reply = "I can talk it through step by step using the current in-game question context."
960
+ else:
961
+ reply = "Send a message naturally and I’ll respond using the current question context."
 
 
 
 
 
 
 
962
 
963
+ return SolverResult(reply=reply, domain="fallback", solved=False, help_mode=help_mode)
964
 
 
 
 
965
 
966
  def generate_response(
967
+ raw_user_text: str,
968
  tone: float,
969
  verbosity: float,
970
  transparency: float,
971
  help_mode: str,
972
  ) -> SolverResult:
973
+ ctx = parse_hidden_context(raw_user_text)
974
+ visible_user_text = ctx.visible_user_text.strip()
975
+ question_block = ctx.combined_question_block.strip()
976
 
977
+ if not raw_user_text.strip():
978
+ result = SolverResult(
979
+ reply="Ask a question and I’ll help using the current in-game context.",
 
 
 
980
  domain="fallback",
981
  solved=False,
982
  help_mode=help_mode,
983
  )
984
+ result.reply = format_reply(result.reply, tone, verbosity, transparency, help_mode)
985
+ return result
986
+
987
+ followup = handle_conversational_followup(ctx, help_mode)
988
+ if followup is not None:
989
+ followup.reply = format_reply(followup.reply, tone, verbosity, transparency, followup.help_mode)
990
+ return followup
991
+
992
+ if question_block and is_quant_question(question_block):
993
+ result = solve_quant(question_block, help_mode)
994
+ result.reply = format_reply(result.reply, tone, verbosity, transparency, help_mode)
995
+ return result
996
+
997
+ if is_quant_question(visible_user_text):
998
+ result = solve_quant(visible_user_text, help_mode)
999
+ result.reply = format_reply(result.reply, tone, verbosity, transparency, help_mode)
1000
+ return result
1001
+
1002
+ result = solve_verbal_or_general(visible_user_text or raw_user_text, help_mode)
1003
+ result.reply = format_reply(result.reply, tone, verbosity, transparency, help_mode)
 
 
 
1004
  return result
1005
 
1006
 
 
1010
 
1011
  @app.get("/health")
1012
  def health() -> Dict[str, Any]:
1013
+ return {"ok": True, "app": "GMAT Solver v3"}
1014
 
1015
 
1016
  @app.get("/", response_class=HTMLResponse)
 
1020
  <html>
1021
  <head>
1022
  <meta charset="utf-8">
1023
+ <title>GMAT Solver v3</title>
1024
  <style>
1025
  body { font-family: Arial, sans-serif; max-width: 900px; margin: 40px auto; padding: 0 16px; }
1026
  textarea { width: 100%; min-height: 220px; padding: 12px; font-size: 16px; }
 
1033
  </style>
1034
  </head>
1035
  <body>
1036
+ <h1>GMAT Solver v3</h1>
1037
+ <p>This version supports natural chat and hidden Unity context.</p>
1038
 
1039
+ <label for="message">Message</label>
1040
+ <textarea id="message" placeholder="Try:
1041
+ why is it c
1042
+
1043
+ Or paste a whole question."></textarea>
 
 
1044
 
1045
  <div class="row">
1046
  <div class="card">
 
1100
 
1101
  @app.post("/chat")
1102
  async def chat(request: Request) -> JSONResponse:
 
 
 
 
 
 
1103
  raw_body: Any = None
1104
 
1105
  try:
 
1110
  except Exception:
1111
  raw_body = None
1112
 
 
1113
  req_data: Dict[str, Any] = raw_body if isinstance(raw_body, dict) else {}
1114
+
1115
  try:
1116
  req = ChatRequest(**req_data)
1117
  except Exception:
 
1124
  help_mode = detect_help_mode(user_text, req_data.get("help_mode", req.help_mode))
1125
 
1126
  result = generate_response(
1127
+ raw_user_text=user_text,
1128
  tone=tone,
1129
  verbosity=verbosity,
1130
  transparency=transparency,
 
1138
  "domain": result.domain,
1139
  "solved": result.solved,
1140
  "help_mode": result.help_mode,
1141
+ "answer_letter": result.answer_letter,
1142
+ "answer_value": result.answer_value,
1143
  },
1144
  }
1145
  )