j-js commited on
Commit
70a705d
·
verified ·
1 Parent(s): 5d2360a

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +7 -1074
app.py CHANGED
@@ -1,30 +1,19 @@
1
- # app.py
2
  from __future__ import annotations
3
 
4
- import ast
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
15
  from fastapi.responses import HTMLResponse, JSONResponse
16
- from pydantic import BaseModel
17
 
18
- try:
19
- import sympy as sp
20
- except Exception:
21
- sp = None
 
22
 
23
 
24
- # =========================================================
25
- # App setup
26
- # =========================================================
27
-
28
  app = FastAPI(title="GMAT Solver v3", version="3.0.0")
29
 
30
  app.add_middleware(
@@ -36,978 +25,6 @@ app.add_middleware(
36
  )
37
 
38
 
39
- # =========================================================
40
- # Request / response models
41
- # =========================================================
42
-
43
- class ChatRequest(BaseModel):
44
- message: Optional[str] = None
45
- prompt: Optional[str] = None
46
- query: Optional[str] = None
47
- text: Optional[str] = None
48
- user_message: Optional[str] = None
49
-
50
- tone: Optional[float] = 0.5
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:
91
- try:
92
- v = float(x)
93
- return max(0.0, min(1.0, v))
94
- except Exception:
95
- return default
96
-
97
-
98
- def normalize_spaces(text: str) -> str:
99
- return re.sub(r"\s+", " ", text).strip()
100
-
101
-
102
- def clean_math_text(text: str) -> str:
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)
119
- return extract_text_from_any_payload(decoded)
120
- except Exception:
121
- pass
122
-
123
- try:
124
- decoded = ast.literal_eval(s)
125
- if isinstance(decoded, (dict, list)):
126
- return extract_text_from_any_payload(decoded)
127
- except Exception:
128
- pass
129
-
130
- return s
131
-
132
- if isinstance(payload, dict):
133
- for key in [
134
- "message", "prompt", "query", "text", "user_message",
135
- "input", "data", "payload", "body", "content"
136
- ]:
137
- if key in payload:
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)):
145
- maybe = extract_text_from_any_payload(v)
146
- if maybe:
147
- strings.append(maybe)
148
- return "\n".join(strings).strip()
149
-
150
- if isinstance(payload, list):
151
- parts = [extract_text_from_any_payload(x) for x in payload]
152
- return "\n".join([p for p in parts if p]).strip()
153
-
154
- return str(payload).strip()
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:
197
- patterns = [
198
- r"\bA[\)\.\:]",
199
- r"\bB[\)\.\:]",
200
- r"\bC[\)\.\:]",
201
- r"\bD[\)\.\:]",
202
- r"\bE[\)\.\:]",
203
- ]
204
- return sum(bool(re.search(p, text, flags=re.I)) for p in patterns) >= 3
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)|$)",
211
- text,
212
- )
213
- )
214
- choices: Dict[str, str] = {}
215
- for m in matches:
216
- letter = m.group(1).upper()
217
- content = normalize_spaces(m.group(2))
218
- choices[letter] = content
219
- return choices
220
-
221
-
222
- def extract_numbers(text: str) -> List[float]:
223
- nums = re.findall(r"-?\d+(?:\.\d+)?", text.replace(",", ""))
224
- out: List[float] = []
225
- for n in nums:
226
- try:
227
- out.append(float(n))
228
- except Exception:
229
- pass
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 = [
390
- "solve", "equation", "integer", "percent", "percentage", "ratio", "probability",
391
- "mean", "median", "average", "sum", "difference", "product", "quotient",
392
- "triangle", "circle", "rectangle", "perimeter", "area", "volume",
393
- "number line", "positive", "negative", "multiple", "factor", "prime",
394
- "distance", "speed", "work", "mixture", "consecutive", "algebra",
395
- "value of x", "value of y", "what is x", "what is y"
396
- ]
397
- if any(k in lower for k in quant_keywords):
398
- return True
399
- if re.search(r"[0-9]", text) and ("?" in text or has_answer_choices(text)):
400
- return True
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
-
417
- try:
418
- return float(eval(expr, {"__builtins__": {}}, {}))
419
- except Exception:
420
- return None
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]+)\??",
427
- lower,
428
- )
429
- if not m:
430
- return None
431
- expr = m.group(1).strip()
432
- if len(expr) < 1:
433
- return None
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,
495
- )
496
- if m:
497
- base = float(m.group(1))
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))
531
- total = float(m.group(3))
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
676
-
677
- eq_text = m.group(1).strip()
678
- eq_text = re.sub(r"(\d)([a-z])", r"\1*\2", eq_text)
679
- eq_text = re.sub(r"([a-z])(\d)", r"\1*\2", eq_text)
680
-
681
- parts = eq_text.split("=")
682
- if len(parts) != 2:
683
- return None
684
-
685
- try:
686
- x = sp.symbols("x")
687
- left = sp.sympify(parts[0])
688
- right = sp.sympify(parts[1])
689
- sols = sp.solve(sp.Eq(left, right), x)
690
- if not sols:
691
- return None
692
-
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
-
706
-
707
- def compare_to_choices_numeric(answer_value: float, choices: Dict[str, str]) -> Optional[str]:
708
- best_letter = None
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
-
723
- diff = abs(expr_val - answer_value)
724
- if diff < best_diff:
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)
739
- if val is not None:
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:
754
- val = float(m.group(1)) / 100 * float(m.group(2))
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:
770
- solvers = [
771
- solve_by_option_checking,
772
- solve_basic_expression,
773
- solve_percent_question,
774
- solve_ratio_question,
775
- solve_average_question,
776
- solve_probability_question,
777
- solve_geometry_question,
778
- solve_linear_equation,
779
- ]
780
-
781
- for solver in solvers:
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
-
1007
- # =========================================================
1008
- # Routes
1009
- # =========================================================
1010
-
1011
  @app.get("/health")
1012
  def health() -> Dict[str, Any]:
1013
  return {"ok": True, "app": "GMAT Solver v3"}
@@ -1015,87 +32,7 @@ def health() -> Dict[str, Any]:
1015
 
1016
  @app.get("/", response_class=HTMLResponse)
1017
  def home() -> str:
1018
- return """
1019
- <!doctype html>
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; }
1027
- .row { display: flex; gap: 16px; margin: 12px 0; flex-wrap: wrap; }
1028
- .card { border: 1px solid #ddd; border-radius: 10px; padding: 12px; flex: 1 1 220px; }
1029
- button { padding: 10px 16px; font-size: 16px; cursor: pointer; }
1030
- pre { white-space: pre-wrap; background: #f7f7f7; padding: 16px; border-radius: 10px; }
1031
- label { display: block; margin-bottom: 8px; font-weight: bold; }
1032
- select, input[type="range"] { width: 100%; }
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">
1047
- <label for="help_mode">Help mode</label>
1048
- <select id="help_mode">
1049
- <option value="answer" selected>answer</option>
1050
- <option value="hint">hint</option>
1051
- <option value="walkthrough">walkthrough</option>
1052
- </select>
1053
- </div>
1054
-
1055
- <div class="card">
1056
- <label for="tone">Tone</label>
1057
- <input id="tone" type="range" min="0" max="1" step="0.01" value="0.5">
1058
- </div>
1059
-
1060
- <div class="card">
1061
- <label for="verbosity">Verbosity</label>
1062
- <input id="verbosity" type="range" min="0" max="1" step="0.01" value="0.5">
1063
- </div>
1064
-
1065
- <div class="card">
1066
- <label for="transparency">Transparency</label>
1067
- <input id="transparency" type="range" min="0" max="1" step="0.01" value="0.5">
1068
- </div>
1069
- </div>
1070
-
1071
- <button onclick="send()">Send</button>
1072
-
1073
- <h2>Response</h2>
1074
- <pre id="out">Waiting...</pre>
1075
-
1076
- <script>
1077
- async function send() {
1078
- const payload = {
1079
- message: document.getElementById('message').value,
1080
- help_mode: document.getElementById('help_mode').value,
1081
- tone: parseFloat(document.getElementById('tone').value),
1082
- verbosity: parseFloat(document.getElementById('verbosity').value),
1083
- transparency: parseFloat(document.getElementById('transparency').value)
1084
- };
1085
-
1086
- const res = await fetch('/chat', {
1087
- method: 'POST',
1088
- headers: {'Content-Type': 'application/json'},
1089
- body: JSON.stringify(payload)
1090
- });
1091
-
1092
- const data = await res.json();
1093
- document.getElementById('out').textContent = JSON.stringify(data, null, 2);
1094
- }
1095
- </script>
1096
- </body>
1097
- </html>
1098
- """
1099
 
1100
 
1101
  @app.post("/chat")
@@ -1145,10 +82,6 @@ async def chat(request: Request) -> JSONResponse:
1145
  )
1146
 
1147
 
1148
- # =========================================================
1149
- # Local dev entry
1150
- # =========================================================
1151
-
1152
  if __name__ == "__main__":
1153
  import uvicorn
1154
 
 
 
1
  from __future__ import annotations
2
 
 
 
 
3
  import os
4
+ from typing import Any, Dict
 
 
 
5
 
6
  from fastapi import FastAPI, Request
7
  from fastapi.middleware.cors import CORSMiddleware
8
  from fastapi.responses import HTMLResponse, JSONResponse
 
9
 
10
+ from context_parser import detect_help_mode
11
+ from conversation_logic import generate_response
12
+ from models import ChatRequest
13
+ from ui_html import HOME_HTML
14
+ from utils import clamp01, get_user_text
15
 
16
 
 
 
 
 
17
  app = FastAPI(title="GMAT Solver v3", version="3.0.0")
18
 
19
  app.add_middleware(
 
25
  )
26
 
27
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  @app.get("/health")
29
  def health() -> Dict[str, Any]:
30
  return {"ok": True, "app": "GMAT Solver v3"}
 
32
 
33
  @app.get("/", response_class=HTMLResponse)
34
  def home() -> str:
35
+ return HOME_HTML
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
 
37
 
38
  @app.post("/chat")
 
82
  )
83
 
84
 
 
 
 
 
85
  if __name__ == "__main__":
86
  import uvicorn
87