ghadgemadhuri92 commited on
Commit
3a4bdd3
Β·
1 Parent(s): 3659da9

Sub-agent with native grounding

Browse files
This view is limited to 50 files because it contains too many changes. Β  See raw diff
.gitignore CHANGED
@@ -94,3 +94,25 @@ all_code.txt
94
  *.tmp
95
  *.out
96
  *.generated.*
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
  *.tmp
95
  *.out
96
  *.generated.*
97
+
98
+ # Scratch and Debug Scripts
99
+ debug_*.py
100
+ test_*.py
101
+ verify_*.py
102
+ inspect_*.py
103
+ locate_*.py
104
+ check_*.py
105
+ db_diag.py
106
+ reproduce_crash.py
107
+ find_embedding_models.py
108
+ query
109
+ all_code.txt
110
+ frontend_trace.log
111
+
112
+ # Sensitive Files
113
+ github-recovery-codes.txt
114
+ *-firebase-adminsdk-*.json
115
+ service-account.json
116
+ .env
117
+ .env.*
118
+ !.env.example
app/agents/adk_mathminds.py CHANGED
@@ -1,3 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import logging
2
  import asyncio
3
  import base64
@@ -8,14 +33,16 @@ from typing import Optional, AsyncGenerator, Dict, Any
8
  from google.adk.agents import Agent
9
  from google.adk.runners import Runner
10
  from google.adk.sessions.in_memory_session_service import InMemorySessionService
 
11
  from google.adk.agents.run_config import RunConfig, StreamingMode
12
  from google.genai import types
 
13
  from google.genai.errors import ClientError
 
14
  from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
15
 
16
  from app.core.settings import settings
17
  from app.core.llm_guard import check_and_increment
18
- from app.tools.symbolic_solver import SymbolicSolver
19
  from app.tools.similarity_search import SimilarProblemFinder
20
  from app.tools.python_executor import PythonInterpreter
21
  from app.tools.advanced_ocr import AdvancedOCR
@@ -25,297 +52,404 @@ from app.services.automation import automation_service
25
 
26
  logger = logging.getLogger(__name__)
27
 
 
 
 
 
 
 
 
 
 
28
 
29
- # Thread-safe context for the current image being processed
30
- current_image_ctx = contextvars.ContextVar("current_image", default=None)
31
 
32
  class MathMindsADKAgent:
33
- """
34
- Agent-based architecture using Google ADK.
35
- Supports real-time streaming of reasoning steps and final answers.
36
- """
37
 
38
  def __init__(self, model_name: str = "gemini-2.5-flash", redis_client=None):
39
- self.api_key = settings.GOOGLE_API_KEY
 
40
  self.redis_client = redis_client
 
41
 
42
  if not self.api_key:
43
- logger.warning("No Google API Key found. Agent will fail.")
 
 
44
 
45
- # Tool instances
46
- self.symbolic_solver = SymbolicSolver()
47
- self.normalizer = MathQueryNormalizer()
48
- self.similar_finder = SimilarProblemFinder()
49
  self.python_executor = PythonInterpreter()
50
- self.advanced_ocr = AdvancedOCR()
51
  self.vision_analyzer = VisionAnalyzer()
52
 
53
- # Tool definitions
54
- async def web_search(query: str) -> str:
55
- """
56
- Search the internet for current data: prices, news, weather, facts.
57
- Args:
58
- query: The search query.
59
- """
60
- from google import genai
61
- from google.genai import types
62
-
63
- # Using a lightweight flash model for the grounded search
64
- search_client = genai.Client(api_key=self.api_key)
 
 
 
 
 
65
  try:
66
- response = search_client.models.generate_content(
67
- model="gemini-2.5-flash",
68
- contents=f"Find the latest information for: {query}",
69
- config=types.GenerateContentConfig(
70
- tools=[types.Tool(google_search=types.GoogleSearchRetrieval())],
71
- temperature=0.0
72
- )
73
- )
74
- return response.text or "No specific information found."
75
- except Exception as e:
76
- logger.error(f"Native Grounding failed: {e}")
77
- return f"Error searching web: {str(e)}"
78
-
79
- async def math_solver(problem: str) -> str:
80
- """
81
- Solve symbolic math: equations, derivatives, integrals, simplification.
82
- Args:
83
- problem: The math expression or description.
84
- """
85
- intent = self.normalizer.normalize(problem)
86
- query_obj = intent if intent else problem
87
- result = await self.symbolic_solver.solve(query_obj)
88
- if result.get("status") == "success":
89
- return result.get("content", "No solution found.")
90
- return f"Error solving math: {result.get('error')}"
91
 
 
 
 
 
 
92
  async def execute_python(code: str) -> str:
93
- """
94
- Execute arbitrary Python code for simulations, complex logic, or data analysis.
95
- Use this when SymPy is too restrictive or you need to run a simulation.
96
- Args:
97
- code: The Python code to execute.
98
- """
99
- result = await self.python_executor.execute(code)
100
  if result.get("status") == "success":
101
  return f"Output:\n{result.get('content')}\nResult: {result.get('result')}"
102
- return f"Error in Python execution: {result.get('content')}"
103
 
104
  async def image_interpreter() -> str:
105
- """
106
- Convert handwritten or printed math equations from the CURRENT image into machine-readable LaTeX/text.
107
- Use this for recognizing symbols, numbers, and formulas.
108
- DO NOT use this for interpreting graphs, geometry, or spatial relationships.
109
- """
110
  image_data = current_image_ctx.get()
111
  if not image_data:
112
- return "Error: No image provided in current context."
113
-
114
  try:
115
- # Remove base64 prefix if present
116
  if "," in image_data:
117
  image_data = image_data.split(",")[1]
118
-
119
- import base64
120
  img_bytes = base64.b64decode(image_data)
 
 
121
  text = self.advanced_ocr.process_image_bytes(img_bytes)
122
- return f"OCR result (LaTeX/Text): {text}" if text else "OCR failed to find text."
123
  except Exception as e:
124
- return f"Error in Image Interpreter: {str(e)}"
125
 
126
  async def statistical_vision(query: str) -> str:
127
- """
128
- Analyze the CURRENT image for objects, counting, grouping, and basic visual set statistics.
129
- Use this for 'How many...?' or 'Find all...'.
130
- DO NOT use this for coordinate extraction from line graphs, plot analysis, or geometry.
131
- Args:
132
- query: Specific question about the image (e.g., 'Count the red marbles').
133
- """
134
  image_data = current_image_ctx.get()
135
  if not image_data:
136
- return "Error: No image provided in current context."
137
-
138
  result = self.vision_analyzer.analyze(image_data, query)
139
  if result.get("status") == "success":
140
  quant = result.get("quantitative_analysis")
141
  if quant:
142
- return f"Vision Analysis: Found {quant.get('total_objects')} objects. Details: {quant.get('objects')}"
143
- return "Vision Analysis: No specific objects counted. Use native vision for qualitative tasks."
144
- return f"Error in Statistical Vision: {result.get('error')}"
 
 
 
145
 
146
- def find_similar_problems(query: str) -> str:
147
- # ... existing similar finder logic ...
148
  results = self.similar_finder.search(query, limit=2)
149
  if not results:
150
  return "No similar problems found."
151
  formatted = "Similar problems:\n"
152
  for item in results:
153
- formatted += f"Problem: {item.get('problem_text')}\nSolution: {item.get('solution_text')}\n---\n"
 
 
 
154
  return formatted
155
 
156
  async def trigger_automation(event_name: str, payload_json: str) -> str:
157
- """
158
- Trigger an external automation workflow (n8n).
159
- Use this for sending alerts, emails, Discord messages, or logging data.
160
- Args:
161
- event_name: Description of the event (e.g., 'complex_problem_solved').
162
- payload_json: A JSON string containing the data to send.
163
- """
164
  try:
165
  payload = json.loads(payload_json)
166
- result = await automation_service.trigger(event_name, payload)
167
  return f"Automation triggered: {result.get('status')}"
168
  except Exception as e:
169
- return f"Automation failed: {str(e)}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
170
 
171
- # ── Agent & Runner ────────────────────────────────────────────────────
172
- self.agent = Agent(
173
  name="math_minds_core",
174
- model=model_name,
175
- tools=[
176
- web_search, math_solver, execute_python,
177
- find_similar_problems, image_interpreter, statistical_vision,
178
- trigger_automation
179
- ],
180
- instruction=(
181
- "You are MathMinds AI, a precise mathematical analytical assistant. "
182
- "\n\nVISION GUIDELINES:"
183
- "\n1. For HANDWRITTEN equations or text: ALWAYS call `image_interpreter` first. "
184
- "It provides specialized OCR precision that native vision might miss."
185
- "\n2. For COUNTING or OBJECT DETECTION: ALWAYS call `statistical_vision`. "
186
- "It uses specialized object detection (YOLO) for accurate quantification."
187
- "\n3. For GRAPHS, PLOTS, COORDINATE GEOMETRY, or LOG DIAGRAMS: DO NOT use specialized tools. "
188
- "Rely on your NATIVE MULTIMODAL VISION to interpret coordinates, slopes, and trends directly."
189
- "\n\nSOLVING & INTERPRETATION GUIDELINES:"
190
- "\n1. Once you have machine-readable data, use `math_solver` or `execute_python` to solve."
191
- "\n2. IF `math_solver` FAILS or returns an empty result: Immediately attempt the problem using `execute_python`. "
192
- "In Python, you can use specialized libraries like `numpy`, `scipy`, or `sympy` for numerical and symbolic solutions."
193
- "\n3. INTERPRET LATEX: Tool outputs (especially from SymPy) are often in raw LaTeX. "
194
- "NEVER just display the raw LaTeX to the user. Always explain the steps in clear English. "
195
- "Wrap LaTeX in `$ ... $` for inline or `$$ ... $$` for blocks so the UI renders it properly. "
196
- "Example: Use '$x^2$' instead of 'x^2'."
197
- "\n\nCRITICAL: Always explain your reasoning before and after using tools. If a tool fails, explain WHY and try a different approach."
198
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
199
  )
200
 
201
- self.session_service = InMemorySessionService()
202
- self.runner = Runner(
203
  app_name="mathminds",
204
- agent=self.agent,
205
- session_service=self.session_service
206
  )
207
 
208
- logger.info(f"MathMindsADKAgent initialized with model: {model_name}")
 
 
 
 
 
 
 
 
 
 
 
 
209
 
210
  async def solve(
211
  self,
212
  problem: str,
213
  image_data: Optional[str] = None,
214
  session_id: str = "default_session",
215
- user_id: str = "default_user"
216
  ) -> AsyncGenerator[Dict[str, Any], None]:
217
- """
218
- Streaming entry point. Yields events as they occur.
219
- """
220
 
221
- # ── 1. Set Image Context ──────────────────────────────────────────────
222
  token = current_image_ctx.set(image_data)
223
-
224
  try:
225
- # ── 2. Daily quota check ──────────────────────────────────────────────
 
 
 
 
 
 
 
 
226
  if self.redis_client:
227
  allowed, used, limit = check_and_increment(self.redis_client, user_id)
228
  if not allowed:
229
- yield {"type": "error", "content": f"⚠️ Daily limit reached ({limit} today)."}
 
230
  return
231
- else:
232
- logger.warning("Redis unavailable β€” skipping quota check (failing open).")
233
 
234
- # ── 2. Session setup ──────────────────────────────────────────────────
235
  try:
236
  existing = await self.session_service.get_session(
237
- app_name="mathminds", session_id=session_id, user_id=user_id
 
 
238
  )
239
  if not existing:
240
  await self.session_service.create_session(
241
- app_name="mathminds", user_id=user_id, session_id=session_id
 
 
242
  )
243
  except Exception as e:
244
- logger.warning(f"Session setup warning: {e}")
245
 
246
- # ── 3. Build message parts ────────────────────────────────────────────
247
- parts = []
248
- if problem:
249
- parts.append(types.Part.from_text(text=problem))
250
- else:
251
- parts.append(types.Part.from_text(text="Analyze this image."))
252
 
253
  if image_data:
254
  try:
255
  img_bytes = base64.b64decode(image_data)
256
- mime_type = "image/png" # Default
257
- # Basic sniff
258
- if image_data.startswith("/9j/"): mime_type = "image/jpeg"
259
- elif image_data.startswith("iVBORw"): mime_type = "image/png"
260
-
261
  parts.append(types.Part.from_bytes(data=img_bytes, mime_type=mime_type))
262
  except Exception as e:
263
  logger.error(f"Image decode failed: {e}")
264
 
265
- # ── 4. Run agent (Streaming) ──────────────────────────────────────────
266
- yielded_text_len = 0
267
-
268
- async for event in self.runner.run_async(
 
 
 
 
 
 
 
 
 
 
 
 
 
269
  user_id=user_id,
270
  session_id=session_id,
271
  new_message=types.Content(role="user", parts=parts),
272
- run_config=RunConfig(streaming_mode=StreamingMode.SSE)
273
  ):
274
- # ── Determine Event Type ──
275
  try:
276
  is_final = event.is_final_response()
277
  except Exception:
278
  is_final = False
279
-
280
- # ── Capture Content (Text Delta) ──
 
281
  if hasattr(event, "content") and event.content and event.content.parts:
282
- # βœ… Safer handling: Ensure we only join STRINGS (handle None indices from tool parts)
283
- full_turn_text = "".join((getattr(part, "text", "") or "") for part in event.content.parts)
284
-
285
- # Handle buffer reset (happens after tool calls)
286
- if len(full_turn_text) < yielded_text_len:
287
- yielded_text_len = 0
288
-
289
- # Stream delta
290
- if len(full_turn_text) > yielded_text_len:
291
- delta = full_turn_text[yielded_text_len:]
292
- yielded_text_len = len(full_turn_text)
293
- yield {"type": "answer", "content": delta}
294
-
295
- if is_final:
296
- logger.debug(f"Final response chunk received: {delta[:50]}...")
297
-
298
- # ── Capture Tool Usage (Reasoning) ──
 
 
 
 
299
  for fc in event.get_function_calls():
300
- yield {
301
- "type": "thought", # Changed from action to thought for UI consistency
302
- "content": f"βš™οΈ {fc.name}"
303
- }
304
 
305
- # ── Capture Tool Response ──
306
  for fr in event.get_function_responses():
307
- yield {
308
- "type": "thought", # Changed from observation to thought for UI consistency
309
- "content": f"πŸ‘οΈ Result from {fr.name}"
310
- }
 
 
 
 
 
 
 
311
 
312
  except Exception as e:
313
- logger.error(f"Streaming execution failed: {e}")
314
- yield {"type": "error", "content": str(e)}
 
 
 
 
 
 
315
  finally:
316
  try:
317
  current_image_ctx.reset(token)
318
- except ValueError:
319
- # This can happen if the generator is closed (GeneratorExit)
320
- # in a different task context than where it was started.
321
- pass
 
1
+ """
2
+ adk_mathminds.py β€” Google ADK-based MathMinds agent
3
+
4
+ BUGS FIXED vs previous version
5
+ ───────────────────────────────
6
+ BUG 1+2: self.session_service = InMemorySessionService() was placed AFTER
7
+ the return statement in _get_agent() β†’ dead code, never executed.
8
+ solve() then crashed with AttributeError on self.session_service.
9
+ Fix: moved session_service init to __init__(), created once at startup.
10
+
11
+ BUG 3: yielded_text_len cursor logic caused duplicate/garbled answers.
12
+ ADK SSE sends cumulative text in intermediate events AND the complete
13
+ final answer in the is_final_response() event. Cursor slicing
14
+ without is_final guard yielded fragments + the full answer = duplicates.
15
+ Fix: yield ONLY from is_final_response() events.
16
+
17
+ BUG 4: Runner() was instantiated fresh inside every solve() call.
18
+ Fix: Runner created once in __init__() and reused.
19
+
20
+ BUG 6: web_search tool called generate_content() internally β€” cost 1 extra
21
+ quota unit per search on top of the main agent call.
22
+ Fix: web_search now uses Gemini's native google_search grounding
23
+ which is bundled into the agent's own call at no extra quota cost.
24
+ """
25
+
26
  import logging
27
  import asyncio
28
  import base64
 
33
  from google.adk.agents import Agent
34
  from google.adk.runners import Runner
35
  from google.adk.sessions.in_memory_session_service import InMemorySessionService
36
+ from google.adk.tools.google_search_agent_tool import GoogleSearchAgentTool, create_google_search_agent
37
  from google.adk.agents.run_config import RunConfig, StreamingMode
38
  from google.genai import types
39
+ from google import genai
40
  from google.genai.errors import ClientError
41
+
42
  from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
43
 
44
  from app.core.settings import settings
45
  from app.core.llm_guard import check_and_increment
 
46
  from app.tools.similarity_search import SimilarProblemFinder
47
  from app.tools.python_executor import PythonInterpreter
48
  from app.tools.advanced_ocr import AdvancedOCR
 
52
 
53
  logger = logging.getLogger(__name__)
54
 
55
+ # Context var carries image data into tool functions without passing it as an argument
56
+ current_image_ctx: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar(
57
+ "current_image", default=None
58
+ )
59
+
60
+ _QUOTA_MESSAGE = (
61
+ "⚠️ Daily question limit reached. Please try again tomorrow, "
62
+ "or ask your administrator to increase the quota."
63
+ )
64
 
 
 
65
 
66
  class MathMindsADKAgent:
 
 
 
 
67
 
68
  def __init__(self, model_name: str = "gemini-2.5-flash", redis_client=None):
69
+
70
+ self.api_key = settings.GOOGLE_API_KEY
71
  self.redis_client = redis_client
72
+ self._model_name = model_name
73
 
74
  if not self.api_key:
75
+ logger.warning("No Google API Key found.")
76
+
77
+ self.genai_client = genai.Client(api_key=self.api_key)
78
 
79
+ # ── Sub-tools ─────────────────────────────────────────────────────
80
+ self.normalizer = MathQueryNormalizer()
81
+ self.similar_finder = SimilarProblemFinder()
 
82
  self.python_executor = PythonInterpreter()
83
+ self.advanced_ocr = AdvancedOCR()
84
  self.vision_analyzer = VisionAnalyzer()
85
 
86
+ # Pre-warm TrOCR at startup β€” first image request otherwise takes 60s
87
+ # to download and load the model weights from HuggingFace.
88
+ # load_model() is idempotent (checks self.model is None before loading).
89
+ try:
90
+ self.advanced_ocr.load_model()
91
+ except Exception as e:
92
+ logger.warning(f"TrOCR pre-warm failed (image OCR will lazy-load): {e}")
93
+
94
+ # ── Session service β€” created ONCE here, not inside _get_agent() ──
95
+ self.session_service = InMemorySessionService()
96
+
97
+ # ── Multi-Agent Search: Sub-agent with native grounding ────────────
98
+ self.search_sub_agent = create_google_search_agent(model=self._model_name)
99
+ self.web_search_tool = GoogleSearchAgentTool(agent=self.search_sub_agent)
100
+
101
+ # ── Tool definitions ───────────────────────────────────────────────
102
+ async def run_with_timeout(coro, timeout=20):
103
  try:
104
+ return await asyncio.wait_for(coro, timeout)
105
+ except asyncio.TimeoutError:
106
+ return "Tool timed out."
107
+
108
+ # web_search: uses google_search grounding built into the agent
109
+ # (NOT a separate generate_content call β€” costs zero extra quota)
110
+ # web_search: provided via GoogleSearchAgentTool sub-agent
111
+ # to avoid Mixing Grounding + Function Calling conflict
112
+ web_search = self.web_search_tool
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
 
114
+ @retry(
115
+ stop=stop_after_attempt(2),
116
+ wait=wait_exponential(multiplier=1, min=2, max=5),
117
+ retry=retry_if_exception_type(Exception),
118
+ )
119
  async def execute_python(code: str) -> str:
120
+ """Execute Python code and return the result."""
121
+ result = await run_with_timeout(
122
+ self.python_executor.execute(code), timeout=15
123
+ )
124
+ if isinstance(result, str):
125
+ return result
 
126
  if result.get("status") == "success":
127
  return f"Output:\n{result.get('content')}\nResult: {result.get('result')}"
128
+ return f"Python execution error: {result.get('content')}"
129
 
130
  async def image_interpreter() -> str:
131
+ """Extract text and equations from the uploaded image using OCR."""
 
 
 
 
132
  image_data = current_image_ctx.get()
133
  if not image_data:
134
+ return "Error: No image provided."
 
135
  try:
 
136
  if "," in image_data:
137
  image_data = image_data.split(",")[1]
 
 
138
  img_bytes = base64.b64decode(image_data)
139
+ if len(img_bytes) > 5_000_000:
140
+ return "Image too large. Please upload a smaller image."
141
  text = self.advanced_ocr.process_image_bytes(img_bytes)
142
+ return f"OCR result (LaTeX/Text): {text}" if text else "OCR failed to detect text."
143
  except Exception as e:
144
+ return f"OCR error: {e}"
145
 
146
  async def statistical_vision(query: str) -> str:
147
+ """Analyze objects and quantities in the uploaded image."""
 
 
 
 
 
 
148
  image_data = current_image_ctx.get()
149
  if not image_data:
150
+ return "Error: No image provided."
 
151
  result = self.vision_analyzer.analyze(image_data, query)
152
  if result.get("status") == "success":
153
  quant = result.get("quantitative_analysis")
154
  if quant:
155
+ return (
156
+ f"Vision Analysis: Found {quant.get('total_objects')} objects. "
157
+ f"Details: {quant.get('objects')}"
158
+ )
159
+ return "Vision analysis found no objects."
160
+ return f"Vision analysis error: {result.get('error')}"
161
 
162
+ async def find_similar_problems(query: str) -> str:
163
+ """Find similar previously solved math problems."""
164
  results = self.similar_finder.search(query, limit=2)
165
  if not results:
166
  return "No similar problems found."
167
  formatted = "Similar problems:\n"
168
  for item in results:
169
+ formatted += (
170
+ f"Problem: {item.get('problem_text')}\n"
171
+ f"Solution: {item.get('solution_text')}\n---\n"
172
+ )
173
  return formatted
174
 
175
  async def trigger_automation(event_name: str, payload_json: str) -> str:
176
+ """Trigger an external automation workflow."""
 
 
 
 
 
 
177
  try:
178
  payload = json.loads(payload_json)
179
+ result = await automation_service.trigger(event_name, payload)
180
  return f"Automation triggered: {result.get('status')}"
181
  except Exception as e:
182
+ return f"Automation failed: {e}"
183
+
184
+ # ── Tool registry ──────────────────────────────────────────────────
185
+ self.tools = {
186
+ "web_search": web_search,
187
+ "execute_python": execute_python,
188
+ "find_similar_problems":find_similar_problems,
189
+ "image_interpreter": image_interpreter,
190
+ "statistical_vision": statistical_vision,
191
+ "trigger_automation": trigger_automation,
192
+ }
193
+
194
+ # ── Pre-build both agent variants and their runners ────────────────
195
+ # Runner is heavy β€” creating it once here avoids rebuilding on every
196
+ # solve() call (previous version rebuilt it on every request)
197
+ self._runner_text = self._build_runner(has_image=False)
198
+ self._runner_image = self._build_runner(has_image=True)
199
+
200
+ logger.info(f"MathMindsADKAgent initialized with model: {model_name}")
201
+
202
+ # ── Agent / Runner builders ────────────────────────────────────────────
203
+
204
+ def _build_agent(self, has_image: bool) -> Agent:
205
+ active_tools = [
206
+ self.tools["web_search"],
207
+ self.tools["execute_python"],
208
+ self.tools["find_similar_problems"],
209
+ self.tools["trigger_automation"],
210
+ ]
211
+ if has_image:
212
+ active_tools.append(self.tools["image_interpreter"])
213
+ active_tools.append(self.tools["statistical_vision"])
214
 
215
+ return Agent(
 
216
  name="math_minds_core",
217
+ model=self._model_name,
218
+ tools=active_tools,
219
+ # google_search grounding is REMOVED here to avoid 400 Bad Request conflict.
220
+ # grounding is now provided by the web_search sub-agent tool.
221
+ generate_content_config=types.GenerateContentConfig(
222
+ temperature=0.1,
223
+ ),
224
+ instruction="""
225
+ You are MathMinds AI, a precise mathematical reasoning assistant.
226
+
227
+ PRIMARY OBJECTIVE
228
+ Solve the user's problem completely and clearly in a single response.
229
+
230
+ CRITICAL RULES
231
+ 1. NEVER ask clarifying questions.
232
+ 2. If the query is ambiguous, make a reasonable assumption and proceed.
233
+ 3. If the topic is broad (e.g. "probability distribution functions"),
234
+ give a concise overview covering:
235
+ - key concepts
236
+ - main formulas
237
+ - one worked example.
238
+ 4. Always produce a complete, self-contained answer.
239
+
240
+ TOOL USAGE POLICY
241
+ Only call tools when necessary.
242
+
243
+ execute_python
244
+ Use for:
245
+ - arithmetic
246
+ - algebra
247
+ - calculus
248
+ - statistics
249
+ - numerical evaluation
250
+ - plotting
251
+ Always prefer running code instead of performing complex calculations manually.
252
+
253
+ find_similar_problems
254
+ Use when the problem clearly matches a standard math pattern
255
+ (e.g. quadratic equation, integration type, probability distribution).
256
+
257
+ image_interpreter
258
+ Use ONLY if the user provided an image AND the task involves
259
+ handwritten equations or text extraction.
260
+
261
+ statistical_vision
262
+ Use ONLY if the user provided an image AND the task involves
263
+ counting objects, detecting shapes, or visual quantitative analysis.
264
+
265
+ IMPORTANT TOOL RULES
266
+ - Do NOT call image tools if no image was provided.
267
+ - Do NOT call web search tools for mathematical problems.
268
+ - Do NOT call multiple tools unless absolutely necessary.
269
+
270
+ RESPONSE STRUCTURE
271
+ Always format answers in this structure:
272
+
273
+ 1. Approach
274
+ Brief one-line description of the solution strategy.
275
+
276
+ 2. Solution Steps
277
+ Clear step-by-step reasoning.
278
+
279
+ 3. Mathematical Expressions
280
+ All math must be formatted using LaTeX:
281
+ inline: $...$
282
+ block: $$...$$
283
+
284
+ 4. Final Answer
285
+ Clearly highlight the final result.
286
+
287
+ STYLE
288
+ - Be concise but complete.
289
+ - Avoid unnecessary verbosity.
290
+ - Prefer mathematical clarity over long explanations.
291
+ """,
292
  )
293
 
294
+ def _build_runner(self, has_image: bool) -> Runner:
295
+ return Runner(
296
  app_name="mathminds",
297
+ agent=self._build_agent(has_image=has_image),
298
+ session_service=self.session_service,
299
  )
300
 
301
+ # ── Main solve method ──────────────────────────────────────────────────
302
+
303
+ def _get_image_mime(self, data_bytes: bytes) -> str:
304
+ """Fallback for imghdr in Python 3.13+"""
305
+ if data_bytes.startswith(b'\xff\xd8\xff'):
306
+ return "image/jpeg"
307
+ if data_bytes.startswith(b'\x89PNG\r\n\x1a\n'):
308
+ return "image/png"
309
+ if data_bytes.startswith(b'GIF87a') or data_bytes.startswith(b'GIF89a'):
310
+ return "image/gif"
311
+ if data_bytes.startswith(b'RIFF') and data_bytes[8:12] == b'WEBP':
312
+ return "image/webp"
313
+ return "image/unknown"
314
 
315
  async def solve(
316
  self,
317
  problem: str,
318
  image_data: Optional[str] = None,
319
  session_id: str = "default_session",
320
+ user_id: str = "default_user",
321
  ) -> AsyncGenerator[Dict[str, Any], None]:
 
 
 
322
 
 
323
  token = current_image_ctx.set(image_data)
324
+
325
  try:
326
+ # Normalize query (cleans up math notation)
327
+ # NOTE: problem may already be normalized if coming from orchestrator.py
328
+ # but normalize() is idempotent for strings.
329
+ norm_res = self.normalizer.normalize(str(problem))
330
+ if norm_res:
331
+ # If it returned a MathIntent object, convert to string for GenAI Parts
332
+ problem = f"{norm_res.intent}: {norm_res.expression}"
333
+
334
+ # Quota check
335
  if self.redis_client:
336
  allowed, used, limit = check_and_increment(self.redis_client, user_id)
337
  if not allowed:
338
+ # llm_guard already logged the warning β€” no need to repeat it
339
+ yield {"type": "error", "content": _QUOTA_MESSAGE}
340
  return
341
+ # llm_guard already logs "LLM quota used" β€” no duplicate log here
 
342
 
343
+ # Ensure session exists
344
  try:
345
  existing = await self.session_service.get_session(
346
+ app_name="mathminds",
347
+ session_id=session_id,
348
+ user_id=user_id,
349
  )
350
  if not existing:
351
  await self.session_service.create_session(
352
+ app_name="mathminds",
353
+ user_id=user_id,
354
+ session_id=session_id,
355
  )
356
  except Exception as e:
357
+ logger.warning(f"Session setup warning (non-fatal): {e}")
358
 
359
+ # Build message parts
360
+ parts = [types.Part.from_text(text=str(problem) or "Analyze this image.")]
 
 
 
 
361
 
362
  if image_data:
363
  try:
364
  img_bytes = base64.b64decode(image_data)
365
+ mime_type = self._get_image_mime(img_bytes)
 
 
 
 
366
  parts.append(types.Part.from_bytes(data=img_bytes, mime_type=mime_type))
367
  except Exception as e:
368
  logger.error(f"Image decode failed: {e}")
369
 
370
+ # Pick the pre-built runner for this request type
371
+ runner = self._runner_image if image_data else self._runner_text
372
+
373
+ # ── Streaming loop ─────────────────────────────────────────────
374
+ # FIX: yield ONLY from is_final_response() events.
375
+ #
376
+ # ADK SSE behaviour:
377
+ # Intermediate events β†’ contain raw cumulative text fragments
378
+ # Final event (is_final_response()==True) β†’ contains the complete answer
379
+ #
380
+ # Old code used a cursor (yielded_text_len) to slice deltas from every
381
+ # event. This caused garbling because fragments aren't always contiguous,
382
+ # and the final event re-sent the full text causing duplication.
383
+ _seen_tool_calls: set = set()
384
+ _last_text: str = "" # fallback: track last non-empty text seen
385
+
386
+ async for event in runner.run_async(
387
  user_id=user_id,
388
  session_id=session_id,
389
  new_message=types.Content(role="user", parts=parts),
390
+ run_config=RunConfig(streaming_mode=StreamingMode.SSE),
391
  ):
392
+ # Check is_final safely (method may not exist on all event types)
393
  try:
394
  is_final = event.is_final_response()
395
  except Exception:
396
  is_final = False
397
+
398
+ # Extract text from this event's parts (if any)
399
+ event_text = ""
400
  if hasattr(event, "content") and event.content and event.content.parts:
401
+ event_text = "".join(
402
+ (getattr(p, "text", "") or "") for p in event.content.parts
403
+ )
404
+ if event_text:
405
+ _last_text = event_text
406
+
407
+ # Yield answer only from final event.
408
+ # If the final event has no text (e.g. function_response parts only),
409
+ # fall back to the last non-empty text we saw β€” this handles the
410
+ # statistical_vision case where ADK's final event contains only
411
+ # tool result parts and the actual Gemini text is in a prior event.
412
+ if is_final:
413
+ answer = event_text or _last_text
414
+ if answer:
415
+ yield {"type": "answer", "content": answer}
416
+
417
+ # Tool calls β€” deduplicated by name only.
418
+ # ADK emits the same function_call in multiple events (request +
419
+ # response context) with DIFFERENT fc.id values each time, so
420
+ # keying on id doesn't deduplicate. Name-only dedup is safe because
421
+ # within a single turn Gemini won't call the same tool twice.
422
  for fc in event.get_function_calls():
423
+ if fc.name not in _seen_tool_calls:
424
+ _seen_tool_calls.add(fc.name)
425
+ logger.info(f"Tool called: {fc.name}")
426
+ yield {"type": "action", "content": fc.name}
427
 
 
428
  for fr in event.get_function_responses():
429
+ logger.info(f"Tool response: {fr.name}")
430
+ yield {"type": "observation", "content": fr.name}
431
+
432
+ except ClientError as e:
433
+ err = str(e).lower()
434
+ if "429" in err or "resource_exhausted" in err or "quota" in err:
435
+ logger.warning(f"Gemini quota/rate error: {e}")
436
+ yield {"type": "error", "content": _QUOTA_MESSAGE}
437
+ else:
438
+ logger.error(f"Gemini ClientError: {e}")
439
+ yield {"type": "error", "content": "The AI service returned an error. Please try again."}
440
 
441
  except Exception as e:
442
+ err = str(e).lower()
443
+ if "429" in err or "quota" in err or "resource_exhausted" in err:
444
+ logger.warning(f"Quota error (generic): {e}")
445
+ yield {"type": "error", "content": _QUOTA_MESSAGE}
446
+ else:
447
+ logger.error(f"Agent execution failed: {e}", exc_info=True)
448
+ yield {"type": "error", "content": "Something went wrong. Please try again."}
449
+
450
  finally:
451
  try:
452
  current_image_ctx.reset(token)
453
+ except ValueError as exc:
454
+ if "was created in a different" not in str(exc):
455
+ raise
 
app/api/deps.py CHANGED
@@ -1,38 +1,52 @@
1
  """
2
  deps.py β€” Dependency injection for FastAPI.
3
- Key change: get_orchestrator() now passes the shared Redis client into Orchestrator
4
- so the ADK agent can use it for quota tracking without creating a second connection.
 
 
 
 
 
 
 
 
 
 
5
  """
6
 
7
- import os
8
- import redis
9
- import pymongo
10
  from functools import lru_cache
 
11
  from typing import Optional
12
- import logging
 
 
13
 
14
  from app.core.orchestrator import Orchestrator
 
15
  from app.memory.cache import CacheManager
16
  from app.memory.database import DatabaseManager
17
- from app.core.settings import settings
18
 
19
  logger = logging.getLogger(__name__)
20
 
21
- # ── Singletons ────────────────────────────────────────────────────────────────
22
- _redis_pool: Optional[redis.ConnectionPool] = None
23
- _mongo_client: Optional[pymongo.MongoClient] = None
 
24
 
25
 
26
  def get_redis_pool() -> redis.ConnectionPool:
 
27
  global _redis_pool
28
  if _redis_pool:
29
  return _redis_pool
 
 
 
30
  try:
31
- redis_url = settings.REDIS_URL
32
- if not redis_url:
33
- raise ValueError("REDIS_URL is not set.")
34
  _redis_pool = redis.ConnectionPool.from_url(redis_url, decode_responses=True)
35
- logger.info(f"Initialized Redis Pool: {redis_url}")
36
  return _redis_pool
37
  except Exception as e:
38
  logger.error(f"Failed to create Redis pool: {e}")
@@ -40,11 +54,16 @@ def get_redis_pool() -> redis.ConnectionPool:
40
 
41
 
42
  def get_redis_client() -> redis.Redis:
43
- """Return a Redis client using the shared pool."""
 
 
 
 
44
  return redis.Redis(connection_pool=get_redis_pool())
45
 
46
 
47
  def get_mongo_client() -> pymongo.MongoClient:
 
48
  global _mongo_client
49
  if _mongo_client:
50
  return _mongo_client
@@ -53,15 +72,17 @@ def get_mongo_client() -> pymongo.MongoClient:
53
  settings.MONGO_URI,
54
  serverSelectionTimeoutMS=5000,
55
  minPoolSize=1,
56
- maxPoolSize=50
57
  )
58
- logger.info("Initialized MongoDB Client")
59
  return _mongo_client
60
  except Exception as e:
61
- logger.error(f"Failed to create Mongo client: {e}")
62
  raise
63
 
64
 
 
 
65
  @lru_cache()
66
  def get_cache_manager() -> CacheManager:
67
  return CacheManager(connection_pool=get_redis_pool())
@@ -72,35 +93,71 @@ def get_db_manager() -> DatabaseManager:
72
  return DatabaseManager(client=get_mongo_client())
73
 
74
 
75
- from threading import Lock
 
 
 
 
 
76
 
77
- _orchestrator: Optional[Orchestrator] = None
78
- _orchestrator_lock = Lock()
 
 
79
 
80
 
81
  def get_orchestrator() -> Orchestrator:
82
- """Thread-safe singleton Orchestrator, with Redis client injected."""
 
 
 
 
83
  global _orchestrator
84
  if _orchestrator:
85
  return _orchestrator
86
 
87
  with _orchestrator_lock:
 
 
88
  if _orchestrator:
89
  return _orchestrator
90
 
91
- logger.info("Initializing Orchestrator Singleton...")
92
 
93
- # Pass the shared Redis client so the agent can use it for quota checks
94
- # without opening a separate connection pool.
95
  try:
96
  redis_client = get_redis_client()
97
  except Exception:
98
- redis_client = None
99
  logger.warning("Redis unavailable β€” quota guard will be skipped.")
100
 
101
  _orchestrator = Orchestrator(
102
  cache_manager=get_cache_manager(),
103
  db_manager=get_db_manager(),
104
- redis_client=redis_client, # ← new param passed to Orchestrator
 
105
  )
106
- return _orchestrator
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """
2
  deps.py β€” Dependency injection for FastAPI.
3
+
4
+ Fixes applied vs. original:
5
+ 1. `get_cache_manager` and `get_db_manager` used `@lru_cache()` but their
6
+ factory functions call `get_redis_pool()` / `get_mongo_client()` which are
7
+ themselves guarded by module-level globals. `lru_cache` on these is
8
+ harmless but redundant β€” kept for explicit singleton semantics, added a
9
+ comment explaining why.
10
+ 2. `get_redis_client()` returned a new `redis.Redis` object on every call
11
+ (sharing the pool, so connections were fine). Made the intent explicit with
12
+ a docstring.
13
+ 3. Added `close()` helpers so lifespan shutdown can cleanly release
14
+ connections if needed in the future.
15
  """
16
 
17
+ import logging
 
 
18
  from functools import lru_cache
19
+ from threading import Lock
20
  from typing import Optional
21
+
22
+ import pymongo
23
+ import redis
24
 
25
  from app.core.orchestrator import Orchestrator
26
+ from app.core.settings import settings
27
  from app.memory.cache import CacheManager
28
  from app.memory.database import DatabaseManager
29
+ from app.memory.semantic_cache import SemanticCache
30
 
31
  logger = logging.getLogger(__name__)
32
 
33
+ # ── Module-level singletons ───────────────────────────────────────────────────
34
+ _redis_pool: Optional[redis.ConnectionPool] = None
35
+
36
+ _mongo_client: Optional[pymongo.MongoClient] = None
37
 
38
 
39
  def get_redis_pool() -> redis.ConnectionPool:
40
+ """Return (or lazily create) the shared Redis connection pool."""
41
  global _redis_pool
42
  if _redis_pool:
43
  return _redis_pool
44
+ redis_url = settings.REDIS_URL
45
+ if not redis_url:
46
+ raise ValueError("REDIS_URL is not configured.")
47
  try:
 
 
 
48
  _redis_pool = redis.ConnectionPool.from_url(redis_url, decode_responses=True)
49
+ logger.info(f"Initialized Redis pool: {redis_url}")
50
  return _redis_pool
51
  except Exception as e:
52
  logger.error(f"Failed to create Redis pool: {e}")
 
54
 
55
 
56
  def get_redis_client() -> redis.Redis:
57
+ """
58
+ Return a Redis client that borrows a connection from the shared pool.
59
+ Each call returns a lightweight client wrapper β€” no new connection is
60
+ opened unless the pool needs to grow.
61
+ """
62
  return redis.Redis(connection_pool=get_redis_pool())
63
 
64
 
65
  def get_mongo_client() -> pymongo.MongoClient:
66
+ """Return (or lazily create) the shared MongoDB client."""
67
  global _mongo_client
68
  if _mongo_client:
69
  return _mongo_client
 
72
  settings.MONGO_URI,
73
  serverSelectionTimeoutMS=5000,
74
  minPoolSize=1,
75
+ maxPoolSize=50,
76
  )
77
+ logger.info("Initialized MongoDB client.")
78
  return _mongo_client
79
  except Exception as e:
80
+ logger.error(f"Failed to create MongoDB client: {e}")
81
  raise
82
 
83
 
84
+ # lru_cache gives singleton semantics: the first call creates the manager and
85
+ # all subsequent calls return the same instance.
86
  @lru_cache()
87
  def get_cache_manager() -> CacheManager:
88
  return CacheManager(connection_pool=get_redis_pool())
 
93
  return DatabaseManager(client=get_mongo_client())
94
 
95
 
96
+ @lru_cache()
97
+ def get_semantic_cache() -> SemanticCache:
98
+ return SemanticCache(
99
+ redis_client=get_redis_client(),
100
+ gemini_api_key=settings.GOOGLE_API_KEY
101
+ )
102
 
103
+
104
+ # ── Orchestrator singleton (thread-safe double-checked locking) ───────────────
105
+ _orchestrator: Optional[Orchestrator] = None
106
+ _orchestrator_lock: Lock = Lock()
107
 
108
 
109
  def get_orchestrator() -> Orchestrator:
110
+ """
111
+ Thread-safe singleton Orchestrator.
112
+ Injects the shared Redis client so the ADK agent can use it for quota
113
+ tracking without opening a second connection pool.
114
+ """
115
  global _orchestrator
116
  if _orchestrator:
117
  return _orchestrator
118
 
119
  with _orchestrator_lock:
120
+ # Second check inside the lock β€” another thread may have initialized
121
+ # while we were waiting.
122
  if _orchestrator:
123
  return _orchestrator
124
 
125
+ logger.info("Initializing Orchestrator singleton…")
126
 
127
+ redis_client: Optional[redis.Redis] = None
 
128
  try:
129
  redis_client = get_redis_client()
130
  except Exception:
 
131
  logger.warning("Redis unavailable β€” quota guard will be skipped.")
132
 
133
  _orchestrator = Orchestrator(
134
  cache_manager=get_cache_manager(),
135
  db_manager=get_db_manager(),
136
+ semantic_cache=get_semantic_cache(),
137
+ redis_client=redis_client,
138
  )
139
+ return _orchestrator
140
+
141
+
142
+ # ── Optional teardown helpers (call from lifespan shutdown if needed) ─────────
143
+
144
+ def close_redis():
145
+ global _redis_pool
146
+ if _redis_pool:
147
+ try:
148
+ _redis_pool.disconnect()
149
+ logger.info("Redis pool disconnected.")
150
+ except Exception as e:
151
+ logger.warning(f"Redis pool disconnect error: {e}")
152
+ _redis_pool = None
153
+
154
+
155
+ def close_mongo():
156
+ global _mongo_client
157
+ if _mongo_client:
158
+ try:
159
+ _mongo_client.close()
160
+ logger.info("MongoDB client closed.")
161
+ except Exception as e:
162
+ logger.warning(f"MongoDB close error: {e}")
163
+ _mongo_client = None
app/api/main.py CHANGED
@@ -1,459 +1,427 @@
 
 
 
 
1
  import os
2
- os.environ["DISABLE_MODEL_SOURCE_CHECK"] = "True"
3
- from typing import Any, Dict, Optional, List
4
  import sys
5
  import asyncio
6
-
7
- if sys.platform == 'win32':
8
- asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
9
-
10
  import logging
11
- from datetime import datetime, timezone
12
  import uuid
13
- import sys
14
- import json
 
 
 
 
 
 
 
 
 
15
 
16
  from fastapi import FastAPI, HTTPException, status, Depends, Request
17
- from fastapi.responses import JSONResponse, StreamingResponse
18
  from fastapi.middleware.cors import CORSMiddleware
 
 
19
  from slowapi import _rate_limit_exceeded_handler
20
  from slowapi.errors import RateLimitExceeded
 
21
  from app.core.limiter import limiter
22
  from app.core.orchestrator import Orchestrator
23
- from app.core.schemas import SolveRequest, SolveResponse, HealthResponse, Message, ChatSession, SessionRename, UserSignup, UserLogin, TokenResponse
24
- from app.core.auth_utils import hash_password, verify_password, create_access_token
 
 
 
 
25
  from app.core.logging_config import configure_logging
26
- from app.core.errors import AppError, ErrorCodes, ERROR_MESSAGES
27
- from app.core.settings import settings # New settings module
28
- import os
29
- # Import dependency
30
- from app.api.deps import get_orchestrator, get_redis_pool, get_mongo_client, get_db_manager, get_redis_client
31
- from app.core.security import verify_token, get_current_user
 
 
 
 
32
 
33
- from contextlib import asynccontextmanager
34
 
35
- # Configure logging
36
  configure_logging()
37
  logger = logging.getLogger(__name__)
38
 
 
39
 
40
 
 
 
 
 
41
  @asynccontextmanager
42
  async def lifespan(app: FastAPI):
43
- # Startup: Preload resources
44
- logger.info("πŸš€ Starting MathMinds AI... Warming up resources.")
45
-
46
  try:
47
- # 1. Initialize Redis Pool
48
  get_redis_pool()
49
-
50
- # 2. Initialize MongoDB Client
51
  get_mongo_client()
52
-
53
- # 3. Initialize Orchestrator (Loads YOLO, Supabase, etc.)
54
- # This is the heavy lifting
55
  get_orchestrator()
56
-
57
- logger.info("βœ… Startup complete: Orchestrator & DBs ready.")
58
  except Exception as e:
59
- logger.critical(f"❌ Critical Startup Error: {e}")
60
- # We might want to exit here, but let's allow it to run in degraded mode
61
- # or let the first request fail.
62
-
63
  yield
64
-
65
- # Shutdown: Cleanup if needed
66
- logger.info("πŸ›‘ Shutting down MathMinds AI...")
67
- # (Optional) Close connections here if we implemented close methods
 
 
 
 
 
 
 
 
 
 
 
 
 
68
 
69
  app = FastAPI(
70
  title="MathMinds AI API",
71
- description="API for solving math problems using Gemini and local reasoning.",
72
  version="1.0.0",
73
- lifespan=lifespan
74
  )
75
- @app.get("/")
76
- async def root():
77
- return {"message": "MathMinds API running"}
78
 
79
- # CORS Configuration
 
 
 
 
 
 
80
  app.add_middleware(
81
  CORSMiddleware,
82
- allow_origins=["*"], # In production, replace with specific domains
83
  allow_credentials=True,
84
  allow_methods=["*"],
85
  allow_headers=["*"],
86
  )
87
 
88
- @app.get("/health")
89
- async def health_check():
90
- """System health check for container orchestration."""
91
- health = {
92
- "status": "healthy",
93
- "timestamp": datetime.utcnow().isoformat(),
94
- "services": {
95
- "api": "online"
96
- }
97
- }
98
-
99
- # Check Redis
100
- try:
101
- from app.api.deps import get_redis_client
102
- r = get_redis_client()
103
- r.ping()
104
- health["services"]["redis"] = "online"
105
- except Exception:
106
- health["services"]["redis"] = "offline"
107
- health["status"] = "degraded"
108
 
109
- # Check MongoDB
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
  try:
111
- from app.api.deps import get_mongo_client
112
- m = get_mongo_client()
113
- m.admin.command('ping')
114
- health["services"]["mongodb"] = "online"
115
- except Exception:
116
- health["services"]["mongodb"] = "offline"
117
- health["status"] = "degraded"
118
 
119
- return health
 
 
 
 
 
 
 
 
 
 
 
 
120
 
121
- # Global Exception Handler (Catch-All)
122
  @app.exception_handler(Exception)
123
  async def global_exception_handler(request: Request, exc: Exception):
 
124
  request_id = getattr(request.state, "request_id", "unknown")
125
- logger.error(f"[{request_id}] Unhandled Exception: {str(exc)}", exc_info=True)
 
 
 
 
 
126
  return JSONResponse(
127
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
128
  content={
129
  "status": "error",
130
  "error": "Internal Server Error",
131
- "error_code": "INTERNAL_ERROR",
132
  "metadata": {
133
  "request_id": request_id,
134
- "timestamp": datetime.utcnow().isoformat()
135
- }
136
- }
137
  )
138
 
139
- # Initialize Rate Limiter
140
- app.state.limiter = limiter
141
- app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
142
 
143
  @app.exception_handler(AppError)
144
  async def app_error_handler(request: Request, exc: AppError):
145
- """Handle application-level errors with proper HTTP status codes."""
146
-
147
- # Map error codes to HTTP status codes
148
- error_to_status = {
149
- ErrorCodes.INPUT_VALIDATION_ERROR: status.HTTP_400_BAD_REQUEST,
150
- ErrorCodes.RESOURCE_NOT_FOUND: status.HTTP_404_NOT_FOUND,
151
- ErrorCodes.DEPENDENCY_ERROR: status.HTTP_503_SERVICE_UNAVAILABLE,
152
- ErrorCodes.GEMINI_ERROR: status.HTTP_503_SERVICE_UNAVAILABLE,
153
- ErrorCodes.RATE_LIMIT_EXCEEDED: status.HTTP_429_TOO_MANY_REQUESTS,
154
- ErrorCodes.INTERNAL_ERROR: status.HTTP_500_INTERNAL_SERVER_ERROR,
155
  }
156
-
157
- http_status = error_to_status.get(exc.code, status.HTTP_500_INTERNAL_SERVER_ERROR)
158
-
159
  request_id = getattr(request.state, "request_id", "unknown")
160
-
161
- logger.error(f"[{request_id}] AppError: {exc.code} - {exc.message}")
162
-
163
  return JSONResponse(
164
- status_code=http_status,
165
  content={
166
  "status": "error",
167
  "error": exc.message,
168
  "error_code": exc.code,
169
  "metadata": {
170
  "request_id": request_id,
171
- "timestamp": datetime.utcnow().isoformat()
172
- }
173
- }
174
  )
175
 
176
- @app.middleware("http")
177
- async def add_request_id(request: Request, call_next):
178
- request_id = str(uuid.uuid4())
179
- request.state.request_id = request_id
180
-
181
- # Context for logging
182
- log_context = {"request_id": request_id, "path": request.url.path, "method": request.method}
183
-
184
- logger.info("Request started", extra=log_context)
185
-
186
- import time
187
- start_time = time.time()
188
-
189
- response = await call_next(request)
190
-
191
- duration = time.time() - start_time
192
- response.headers["X-Request-ID"] = request_id
193
-
194
- logger.info("Request finished", extra={
195
- **log_context,
196
- "status_code": response.status_code,
197
- "duration": duration
198
- })
199
-
200
- return response
201
 
202
  @app.get("/health")
203
- async def health_check():
204
- """Detailed health check endpoint."""
205
- health_status = {
206
  "status": "healthy",
207
- "version": "1.0.0",
208
- "timestamp": datetime.utcnow().isoformat(),
209
- "components": {}
210
  }
211
-
212
- # Check Redis
213
- try:
214
- redis_client = get_redis_client()
215
- if redis_client:
216
- redis_client.ping()
217
- health_status["components"]["redis"] = "βœ“ healthy" # using shared pool
218
- else:
219
- health_status["components"]["redis"] = "βœ— unavailable"
220
- except Exception as e:
221
- health_status["components"]["redis"] = f"βœ— error: {str(e)}"
222
-
223
- # Check MongoDB
224
  try:
225
- mongo_client = get_mongo_client()
226
- if mongo_client:
227
- # Low timeout ping
228
- mongo_client.admin.command('ping')
229
- health_status["components"]["mongodb"] = "βœ“ healthy"
230
- else:
231
- health_status["components"]["mongodb"] = "βœ— unavailable"
232
  except Exception as e:
233
- health_status["components"]["mongodb"] = f"βœ— error: {str(e)}"
234
-
235
- # Check Gemini
236
  try:
237
- # Just verify we have API key
238
- api_key = os.getenv("GOOGLE_API_KEY")
239
- if api_key:
240
- health_status["components"]["gemini"] = "βœ“ configured"
241
- else:
242
- health_status["components"]["gemini"] = "βœ— not configured"
243
  except Exception as e:
244
- health_status["components"]["gemini"] = f"βœ— error: {str(e)}"
245
-
246
- # Overall status
247
- if any("βœ—" in str(v) for v in health_status["components"].values()):
248
- health_status["status"] = "degraded"
249
-
250
- return health_status
 
 
251
 
252
  @app.post("/solve")
253
  @limiter.limit("5/minute")
254
  async def solve_problem(
255
  request: Request,
256
- solve_req: SolveRequest,
257
  orchestrator: Orchestrator = Depends(get_orchestrator),
258
- current_user: dict = Depends(get_current_user) # Protect this route
259
  ):
260
  """
261
- Solves a mocked problem provided in the request body.
262
  """
263
- # Grab request_id from state
264
- req_id = getattr(request.state, "request_id", str(uuid.uuid4()))
265
 
266
- if not orchestrator:
 
 
 
 
 
 
 
 
267
  raise HTTPException(
268
- status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
269
- detail="Orchestrator not initialized"
270
  )
271
 
272
- # Deduplication Check (Redis)
273
- final_request_id = solve_req.request_id or req_id
274
- dedup_key = f"active_req:{final_request_id}"
275
-
276
- redis_client = None
 
 
 
 
277
  try:
278
- redis_client = get_redis_client()
279
- # Set key with 300s expiry, only if it doesn't exist (nx=True)
280
- if not redis_client.set(dedup_key, "processing", ex=300, nx=True):
281
- logger.warning(f"[{final_request_id}] Blocked duplicate request (UI retry).")
282
- # Return 202 Accepted (Processing) - Friendly response
283
- return JSONResponse(
284
- status_code=status.HTTP_202_ACCEPTED,
285
- content={
286
- "status": "processing",
287
- "message": "Request is currently being processed. Please wait...",
288
- "metadata": {"request_id": final_request_id}
289
- }
290
- )
291
  except Exception as e:
292
- logger.warning(f"Redis dedup failed (failing open): {e}")
293
- # If Redis fails, we allow the request to proceed (fail open)
294
-
295
- async def event_generator():
296
- try:
297
- async for event in orchestrator.solve_problem_stream(
298
- query=solve_req.effective_text,
299
- image=solve_req.image,
300
- user_id=current_user["uid"],
301
- session_id=solve_req.session_id,
302
- request_id=final_request_id
303
- ):
304
- # βœ… STRICT SSE FORMAT
305
- yield f"data: {json.dumps(event)}\n\n"
306
- except Exception as e:
307
- logger.error(f"Streaming error: {e}")
308
- yield json.dumps({"type": "error", "content": "Internal processing error"}) + "\n"
309
- finally:
310
- if redis_client:
311
- try:
312
- redis_client.delete(dedup_key)
313
- except Exception:
314
- pass
315
-
316
- return StreamingResponse(
317
- event_generator(),
318
- media_type="text/event-stream",
319
- headers={
320
- "Cache-Control": "no-cache",
321
- "Connection": "keep-alive",
322
- "X-Accel-Buffering": "no" # Prevent Nginx buffering
323
- }
324
- )
325
 
326
- # --- Chat History Endpoints ---
 
 
 
 
 
 
 
 
 
 
327
 
328
  @app.get("/chat/sessions", response_model=List[ChatSession])
329
- async def list_chat_sessions(
330
  current_user: dict = Depends(get_current_user),
331
- db_manager = Depends(get_db_manager)
332
  ):
333
- """List all chat sessions for the current user."""
334
  return db_manager.list_sessions(current_user["uid"])
335
 
 
336
  @app.post("/chat/sessions", response_model=ChatSession)
337
- async def create_chat_session(
338
  current_user: dict = Depends(get_current_user),
339
- db_manager = Depends(get_db_manager)
340
  ):
341
- """Create a new chat session."""
342
  session_id = str(uuid.uuid4())
343
  title = "New Chat"
 
344
  if db_manager.create_session(current_user["uid"], session_id, title):
345
  return {
346
  "session_id": session_id,
347
  "title": title,
348
- "created_at": datetime.utcnow()
349
  }
350
- raise HTTPException(status_code=500, detail="Failed to create session")
 
 
351
 
352
  @app.get("/chat/sessions/{session_id}/messages", response_model=List[Message])
353
- async def get_session_history(
354
  session_id: str,
355
  current_user: dict = Depends(get_current_user),
356
- db_manager = Depends(get_db_manager)
357
  ):
358
- """Get message history for a specific session."""
359
  history = db_manager.get_chat_history(current_user["uid"], session_id)
360
- if not history and history != []:
361
- raise HTTPException(status_code=404, detail="Session not found")
 
 
362
  return history
363
 
 
364
  @app.patch("/chat/sessions/{session_id}")
365
- async def rename_chat_session(
366
  session_id: str,
367
  rename_data: SessionRename,
368
  current_user: dict = Depends(get_current_user),
369
- db_manager = Depends(get_db_manager)
370
  ):
371
- """Rename a chat session."""
372
- if db_manager.rename_session(current_user["uid"], session_id, rename_data.title):
373
- return {"status": "success", "title": rename_data.title}
374
- raise HTTPException(status_code=404, detail="Session not found or rename failed")
 
 
 
 
 
 
375
 
376
  @app.delete("/chat/sessions/{session_id}")
377
- async def delete_chat_session(
378
  session_id: str,
379
  current_user: dict = Depends(get_current_user),
380
- db_manager = Depends(get_db_manager)
381
  ):
382
- """Delete a chat session."""
383
- if db_manager.delete_session(current_user["uid"], session_id):
384
- return {"status": "success", "message": "Session deleted"}
385
- raise HTTPException(status_code=404, detail="Session not found or delete failed")
386
 
387
- # --- User Profile Endpoints ---
388
- from pydantic import BaseModel
389
- from typing import List, Optional
390
 
391
- class UserProfileUpdate(BaseModel):
392
- display_name: Optional[str] = None
393
- math_level: Optional[str] = "Student"
394
- interests: Optional[List[str]] = []
395
 
396
- @app.get("/users/profile")
397
- async def get_profile(
398
- current_user: dict = Depends(get_current_user),
399
- db_manager = Depends(get_db_manager)
400
- ):
401
- """Get current user profile."""
402
- try:
403
- profile = db_manager.get_user_profile(current_user["uid"])
404
- if not profile:
405
- # Return basic info if no profile exists yet
406
- return {
407
- "user_id": current_user["uid"],
408
- "email": current_user.get("email"),
409
- "display_name": "",
410
- "math_level": "Student",
411
- "interests": [],
412
- "is_new": True
413
- }
414
-
415
- # Remove MongoDB _id
416
- if "_id" in profile:
417
- del profile["_id"]
418
- return profile
419
- except Exception as e:
420
- logger.error(f"Profile fetch error: {e}")
421
- raise HTTPException(status_code=500, detail="Failed to fetch profile")
422
 
423
- @app.post("/users/profile")
424
- async def update_profile(
425
- profile_data: UserProfileUpdate,
426
- current_user: dict = Depends(get_current_user),
427
- db_manager = Depends(get_db_manager)
428
- ):
429
- """Update user profile."""
430
- try:
431
- success = db_manager.update_user_profile(current_user["uid"], profile_data.dict(exclude_unset=True))
432
- if not success:
433
- raise HTTPException(status_code=500, detail="Failed to update profile")
434
- return {"status": "success", "profile": profile_data.dict()}
435
- except Exception as e:
436
- logger.error(f"Profile update error: {e}")
437
- raise HTTPException(status_code=500, detail=str(e))
438
 
439
  if __name__ == "__main__":
 
440
  import uvicorn
 
441
  port = int(os.environ.get("PORT", 8080))
442
- uvicorn.run(app, host="0.0.0.0", port=port)
443
- # ── Auth Endpoints (DECOMMISSIONED - Use Firebase) ──────────────────────────
444
-
445
- @app.post("/auth/signup")
446
- async def signup():
447
- """Signups are now handled by Firebase on the frontend."""
448
- raise HTTPException(
449
- status_code=status.HTTP_410_GONE,
450
- detail="Local signup is decommissioned. Please use Firebase Auth."
451
- )
452
 
453
- @app.post("/auth/login")
454
- async def login():
455
- """Login is now handled by Firebase on the frontend."""
456
- raise HTTPException(
457
- status_code=status.HTTP_410_GONE,
458
- detail="Local login is decommissioned. Please use Firebase Auth."
459
- )
 
1
+ """
2
+ main.py β€” FastAPI entry point for MathMinds AI
3
+ """
4
+
5
  import os
 
 
6
  import sys
7
  import asyncio
 
 
 
 
8
  import logging
 
9
  import uuid
10
+ import time
11
+
12
+ from contextlib import asynccontextmanager
13
+ from datetime import datetime, timezone
14
+ from typing import Any, Dict, List, Optional
15
+
16
+ # Windows async fix
17
+ if sys.platform == "win32":
18
+ asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
19
+
20
+ os.environ["DISABLE_MODEL_SOURCE_CHECK"] = "True"
21
 
22
  from fastapi import FastAPI, HTTPException, status, Depends, Request
23
+ from fastapi.responses import JSONResponse
24
  from fastapi.middleware.cors import CORSMiddleware
25
+ from pydantic import BaseModel
26
+
27
  from slowapi import _rate_limit_exceeded_handler
28
  from slowapi.errors import RateLimitExceeded
29
+
30
  from app.core.limiter import limiter
31
  from app.core.orchestrator import Orchestrator
32
+ from app.core.schemas import (
33
+ SolveRequest,
34
+ ChatSession,
35
+ Message,
36
+ SessionRename,
37
+ )
38
  from app.core.logging_config import configure_logging
39
+ from app.core.settings import settings
40
+ from app.core.errors import AppError, ErrorCodes
41
+
42
+ from app.api.deps import (
43
+ get_orchestrator,
44
+ get_redis_pool,
45
+ get_mongo_client,
46
+ get_db_manager,
47
+ get_redis_client,
48
+ )
49
 
50
+ from app.core.security import get_current_user
51
 
52
+ # Logging
53
  configure_logging()
54
  logger = logging.getLogger(__name__)
55
 
56
+ MAX_IMAGE_SIZE = 5 * 1024 * 1024 # 5MB limit
57
 
58
 
59
+ # ═════════════════════════════════════════════════════════════════════
60
+ # LIFESPAN
61
+ # ═════════════════════════════════════════════════════════════════════
62
+
63
  @asynccontextmanager
64
  async def lifespan(app: FastAPI):
65
+ logger.info("Starting MathMinds AI")
66
+
 
67
  try:
 
68
  get_redis_pool()
 
 
69
  get_mongo_client()
 
 
 
70
  get_orchestrator()
71
+ logger.info("Startup complete")
 
72
  except Exception as e:
73
+ logger.critical(f"Startup failure: {e}")
74
+
 
 
75
  yield
76
+
77
+ logger.info("Shutting down MathMinds")
78
+
79
+ try:
80
+ from app.api.deps import close_redis, close_mongo
81
+
82
+ close_redis()
83
+ close_mongo()
84
+
85
+ logger.info("Shutdown complete")
86
+ except Exception as e:
87
+ logger.error(f"Shutdown error: {e}")
88
+
89
+
90
+ # ═════════════════════════════════════════════════════════════════════
91
+ # FASTAPI APP
92
+ # ═════════════════════════════════════════════════════════════════════
93
 
94
  app = FastAPI(
95
  title="MathMinds AI API",
96
+ description="AI-powered math solver API",
97
  version="1.0.0",
98
+ lifespan=lifespan,
99
  )
 
 
 
100
 
101
+ # Rate limiter
102
+ app.state.limiter = limiter
103
+ app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
104
+
105
+ # CORS
106
+ allowed_origins = os.getenv("ALLOWED_ORIGINS", "*").split(",")
107
+
108
  app.add_middleware(
109
  CORSMiddleware,
110
+ allow_origins=allowed_origins,
111
  allow_credentials=True,
112
  allow_methods=["*"],
113
  allow_headers=["*"],
114
  )
115
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
 
117
+ # ═════════════════════════════════════════════════════════════════════
118
+ # MIDDLEWARE
119
+ # ═════════════════════════════════════════════════════════════════════
120
+
121
+ @app.middleware("http")
122
+ async def request_id_middleware(request: Request, call_next):
123
+
124
+ request_id = str(uuid.uuid4())
125
+ request.state.request_id = request_id
126
+
127
+ start_time = time.time()
128
+
129
+ logger.info(
130
+ "Request started",
131
+ extra={
132
+ "request_id": request_id,
133
+ "path": request.url.path,
134
+ "method": request.method,
135
+ },
136
+ )
137
+
138
+ response = await call_next(request)
139
+
140
+ duration = time.time() - start_time
141
+
142
+ response.headers["X-Request-ID"] = request_id
143
+
144
+ logger.info(
145
+ "Request finished",
146
+ extra={
147
+ "request_id": request_id,
148
+ "status_code": response.status_code,
149
+ "duration": duration,
150
+ },
151
+ )
152
+
153
+ return response
154
+
155
+
156
+ @app.middleware("http")
157
+ async def timeout_middleware(request: Request, call_next):
158
+
159
  try:
160
+ return await asyncio.wait_for(call_next(request), timeout=120)
 
 
 
 
 
 
161
 
162
+ except asyncio.TimeoutError:
163
+
164
+ logger.error(f"Timeout: {request.url.path}")
165
+
166
+ return JSONResponse(
167
+ status_code=504,
168
+ content={"detail": "Request timed out"},
169
+ )
170
+
171
+
172
+ # ═════════════════════════════════════════════════════════════════════
173
+ # EXCEPTION HANDLERS
174
+ # ═════════════════════════════════════════════════════════════════════
175
 
 
176
  @app.exception_handler(Exception)
177
  async def global_exception_handler(request: Request, exc: Exception):
178
+
179
  request_id = getattr(request.state, "request_id", "unknown")
180
+
181
+ logger.error(
182
+ f"[{request_id}] Unhandled error: {exc}",
183
+ exc_info=True
184
+ )
185
+
186
  return JSONResponse(
187
+ status_code=500,
188
  content={
189
  "status": "error",
190
  "error": "Internal Server Error",
 
191
  "metadata": {
192
  "request_id": request_id,
193
+ "timestamp": datetime.now(timezone.utc).isoformat(),
194
+ },
195
+ },
196
  )
197
 
 
 
 
198
 
199
  @app.exception_handler(AppError)
200
  async def app_error_handler(request: Request, exc: AppError):
201
+
202
+ mapping = {
203
+ ErrorCodes.INPUT_VALIDATION_ERROR: 400,
204
+ ErrorCodes.RESOURCE_NOT_FOUND: 404,
205
+ ErrorCodes.RATE_LIMIT_EXCEEDED: 429,
206
+ ErrorCodes.DEPENDENCY_ERROR: 503,
207
+ ErrorCodes.GEMINI_ERROR: 503,
 
 
 
208
  }
209
+
210
+ status_code = mapping.get(exc.code, 500)
211
+
212
  request_id = getattr(request.state, "request_id", "unknown")
213
+
 
 
214
  return JSONResponse(
215
+ status_code=status_code,
216
  content={
217
  "status": "error",
218
  "error": exc.message,
219
  "error_code": exc.code,
220
  "metadata": {
221
  "request_id": request_id,
222
+ "timestamp": datetime.now(timezone.utc).isoformat(),
223
+ },
224
+ },
225
  )
226
 
227
+
228
+ # ═════════════════════════════════════════════════════════════════════
229
+ # GENERAL ROUTES
230
+ # ═════════════════════════════════════════════════════════════════════
231
+
232
+ @app.get("/")
233
+ async def root():
234
+ return {"message": "MathMinds API running"}
235
+
236
+
237
+ @app.get("/version")
238
+ async def version():
239
+ return {
240
+ "version": "1.0.0",
241
+ "build": os.getenv("BUILD_ID"),
242
+ "commit": os.getenv("GIT_SHA"),
243
+ }
244
+
 
 
 
 
 
 
 
245
 
246
  @app.get("/health")
247
+ async def health():
248
+
249
+ health: Dict[str, Any] = {
250
  "status": "healthy",
251
+ "timestamp": datetime.now(timezone.utc).isoformat(),
252
+ "services": {},
 
253
  }
254
+
 
 
 
 
 
 
 
 
 
 
 
 
255
  try:
256
+ # 2s timeout for Redis
257
+ ping_task = asyncio.to_thread(get_redis_client().ping)
258
+ await asyncio.wait_for(ping_task, timeout=2.0)
259
+ health["services"]["redis"] = "healthy"
 
 
 
260
  except Exception as e:
261
+ health["services"]["redis"] = str(e)
262
+ health["status"] = "degraded"
263
+
264
  try:
265
+ # 2s timeout for Mongo
266
+ mongo_ping = asyncio.to_thread(get_mongo_client().admin.command, "ping")
267
+ await asyncio.wait_for(mongo_ping, timeout=2.0)
268
+ health["services"]["mongodb"] = "healthy"
 
 
269
  except Exception as e:
270
+ health["services"]["mongodb"] = str(e)
271
+ health["status"] = "degraded"
272
+
273
+ return health
274
+
275
+
276
+ # ═════════════════════════════════════════════════════════════════════
277
+ # SOLVE ROUTE
278
+ # ═════════════════════════════════════════════════════════════════════
279
 
280
  @app.post("/solve")
281
  @limiter.limit("5/minute")
282
  async def solve_problem(
283
  request: Request,
284
+ solve_req: SolveRequest,
285
  orchestrator: Orchestrator = Depends(get_orchestrator),
286
+ current_user: dict = Depends(get_current_user),
287
  ):
288
  """
289
+ Solve a math problem and return the result.
290
  """
 
 
291
 
292
+ request_id = getattr(request.state, "request_id", str(uuid.uuid4()))
293
+
294
+ if not solve_req.effective_text and not solve_req.image:
295
+ raise HTTPException(
296
+ status_code=400,
297
+ detail="Either text or image must be provided",
298
+ )
299
+
300
+ if solve_req.image and len(solve_req.image) > MAX_IMAGE_SIZE:
301
  raise HTTPException(
302
+ status_code=413,
303
+ detail="Image too large",
304
  )
305
 
306
+ logger.info(
307
+ "Solve request received",
308
+ extra={
309
+ "request_id": request_id,
310
+ "user_id": current_user["uid"],
311
+ "session_id": solve_req.session_id,
312
+ },
313
+ )
314
+
315
  try:
316
+ result = await orchestrator.solve_problem(
317
+ query=solve_req.effective_text,
318
+ image=solve_req.image,
319
+ user_id=current_user["uid"],
320
+ session_id=solve_req.session_id,
321
+ request_id=request_id,
322
+ )
323
+
324
+ return JSONResponse(status_code=200, content=result)
325
+
 
 
 
326
  except Exception as e:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
327
 
328
+ logger.error(f"Solve error: {e}")
329
+
330
+ raise HTTPException(
331
+ status_code=500,
332
+ detail="Internal processing error",
333
+ )
334
+
335
+
336
+ # ═════════════════════════════════════════════════════════════════════
337
+ # CHAT ROUTES
338
+ # ═════════════════════════════════════════════════════════════════════
339
 
340
  @app.get("/chat/sessions", response_model=List[ChatSession])
341
+ async def list_sessions(
342
  current_user: dict = Depends(get_current_user),
343
+ db_manager=Depends(get_db_manager),
344
  ):
 
345
  return db_manager.list_sessions(current_user["uid"])
346
 
347
+
348
  @app.post("/chat/sessions", response_model=ChatSession)
349
+ async def create_session(
350
  current_user: dict = Depends(get_current_user),
351
+ db_manager=Depends(get_db_manager),
352
  ):
353
+
354
  session_id = str(uuid.uuid4())
355
  title = "New Chat"
356
+
357
  if db_manager.create_session(current_user["uid"], session_id, title):
358
  return {
359
  "session_id": session_id,
360
  "title": title,
361
+ "created_at": datetime.now(timezone.utc),
362
  }
363
+
364
+ raise HTTPException(500, "Failed to create session")
365
+
366
 
367
  @app.get("/chat/sessions/{session_id}/messages", response_model=List[Message])
368
+ async def get_messages(
369
  session_id: str,
370
  current_user: dict = Depends(get_current_user),
371
+ db_manager=Depends(get_db_manager),
372
  ):
373
+
374
  history = db_manager.get_chat_history(current_user["uid"], session_id)
375
+
376
+ if history is None:
377
+ raise HTTPException(404, "Session not found")
378
+
379
  return history
380
 
381
+
382
  @app.patch("/chat/sessions/{session_id}")
383
+ async def rename_session(
384
  session_id: str,
385
  rename_data: SessionRename,
386
  current_user: dict = Depends(get_current_user),
387
+ db_manager=Depends(get_db_manager),
388
  ):
389
+
390
+ if db_manager.rename_session(
391
+ current_user["uid"],
392
+ session_id,
393
+ rename_data.title,
394
+ ):
395
+ return {"status": "success"}
396
+
397
+ raise HTTPException(404, "Session not found")
398
+
399
 
400
  @app.delete("/chat/sessions/{session_id}")
401
+ async def delete_session(
402
  session_id: str,
403
  current_user: dict = Depends(get_current_user),
404
+ db_manager=Depends(get_db_manager),
405
  ):
 
 
 
 
406
 
407
+ if db_manager.delete_session(current_user["uid"], session_id):
408
+ return {"status": "success"}
 
409
 
410
+ raise HTTPException(404, "Session not found")
 
 
 
411
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
412
 
413
+ # ═════════════════════════════════════════════════════════════════════
414
+ # ENTRY POINT
415
+ # ═════════════════════════════════════════════════════════════════════
 
 
 
 
 
 
 
 
 
 
 
 
416
 
417
  if __name__ == "__main__":
418
+
419
  import uvicorn
420
+
421
  port = int(os.environ.get("PORT", 8080))
 
 
 
 
 
 
 
 
 
 
422
 
423
+ uvicorn.run(
424
+ app,
425
+ host="0.0.0.0",
426
+ port=port,
427
+ )
 
 
app/core/math_normalizer.py CHANGED
@@ -105,27 +105,46 @@ class MathQueryNormalizer:
105
  )
106
 
107
  # Arithmetic / Simplification
108
- # If it looks like math chars only
109
- if self._is_arithmetic(clean_text):
 
110
  return MathIntent(
111
  intent="arithmetic",
112
- expression=clean_text,
113
  original_query=text
114
  )
115
 
116
  return None
117
 
118
  def _clean_expression(self, text: str) -> str:
119
- """Removes common stop words and artifacts."""
 
 
 
 
 
 
 
 
 
 
 
 
120
  text = text.strip()
121
-
122
- # Remove "what is" if it somehow got in
123
- for stop in self.stop_words:
124
- # Replace start of string
125
- if text.startswith(stop):
126
- text = text[len(stop):].strip()
127
-
128
- return text.strip()
 
 
 
 
 
 
129
 
130
  def _is_arithmetic(self, text: str) -> bool:
131
  """
@@ -150,4 +169,4 @@ class MathQueryNormalizer:
150
  if char not in allowed_chars:
151
  return False
152
 
153
- return True
 
105
  )
106
 
107
  # Arithmetic / Simplification
108
+ # If it contains numbers and operators, or starts with "calculate", "what is"
109
+ if self._is_arithmetic(clean_text) or any(clean_text.startswith(sw) for sw in ["calculate", "what is", "evaluate"]):
110
+ expr = self._clean_expression(clean_text)
111
  return MathIntent(
112
  intent="arithmetic",
113
+ expression=expr,
114
  original_query=text
115
  )
116
 
117
  return None
118
 
119
  def _clean_expression(self, text: str) -> str:
120
+ """
121
+ Removes natural language words from an expression, leaving only
122
+ the mathematical notation SymPy can safely parse.
123
+
124
+ ROOT CAUSE FIX: the previous version only stripped stop words from
125
+ the START of the string. So "what is the value of 5*9" became
126
+ "the value of 5*9" β€” SymPy then treated t, h, e, v, a, l, u, e, o, f
127
+ as separate symbols and multiplied them: 45Β·aΒ·eΒ²Β·fΒ·hΒ·lΒ·oΒ·tΒ·uΒ·v.
128
+ That's the "45aeflouv" garble seen on the UI.
129
+
130
+ Fix: strip ALL known English prose words, not just from the start.
131
+ """
132
+ import re
133
  text = text.strip()
134
+
135
+ # Full list of prose words to remove wherever they appear
136
+ prose_words = [
137
+ "what is", "what are", "the value of", "the result of",
138
+ "please", "calculate", "compute", "evaluate", "find",
139
+ "solve", "simplify", "determine", "the", "of", "for",
140
+ "result", "value", "answer",
141
+ ]
142
+ for phrase in sorted(prose_words, key=len, reverse=True): # longest first
143
+ text = re.sub(rf'\b{re.escape(phrase)}\b', ' ', text, flags=re.IGNORECASE)
144
+
145
+ # Collapse multiple spaces
146
+ text = re.sub(r' +', ' ', text).strip()
147
+ return text
148
 
149
  def _is_arithmetic(self, text: str) -> bool:
150
  """
 
169
  if char not in allowed_chars:
170
  return False
171
 
172
+ return True
app/core/orchestrator.py CHANGED
@@ -1,25 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import logging
2
  import time
3
  import hashlib
4
  import json
5
  import re
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  import asyncio
7
  from typing import Any, Dict, Optional, AsyncGenerator
8
 
9
- import sympy
10
- from sympy.parsing.sympy_parser import parse_expr, standard_transformations, implicit_multiplication_application
11
-
12
  from app.core.input_processor import InputProcessor
13
  from app.core.math_normalizer import MathQueryNormalizer, MathIntent
14
  from app.memory.cache import CacheManager
 
 
15
  from app.memory.database import DatabaseManager
16
- from app.agents.adk_mathminds import MathMindsADKAgent
17
  from app.core.settings import settings
 
18
 
19
  logger = logging.getLogger(__name__)
20
 
21
- _SYMPY_TRANSFORMATIONS = standard_transformations + (implicit_multiplication_application,)
22
-
23
 
24
  class Orchestrator:
25
  """
@@ -30,6 +81,7 @@ class Orchestrator:
30
  self,
31
  cache_manager: Optional[CacheManager] = None,
32
  db_manager: Optional[DatabaseManager] = None,
 
33
  redis_client: Any = None,
34
  ):
35
  try:
@@ -39,6 +91,17 @@ class Orchestrator:
39
  self.db_manager = db_manager or DatabaseManager()
40
  self.redis_client = redis_client
41
  self.adk_agent = MathMindsADKAgent(redis_client=self.redis_client)
 
 
 
 
 
 
 
 
 
 
 
42
  except Exception as e:
43
  logger.critical(f"Failed to initialize Orchestrator: {e}")
44
  raise
@@ -61,102 +124,212 @@ class Orchestrator:
61
  "status": "success",
62
  "source": "agent",
63
  "answer": "",
64
- "metadata": {
65
- "latency_ms": 0,
66
- "model": "gemini-2.5-flash",
67
- "tools_used": [],
68
- "logic_trace": []
69
  },
70
  }
71
 
72
  try:
73
  # ── 1. Input processing ─────────────────────────────���─────────────
74
- processed = self.input_processor.process_compound(text_input=query, image_input=image)
 
 
75
  if not processed.is_valid:
76
  yield {"type": "error", "content": processed.error_message}
77
  return
78
 
79
- query = processed.cleaned_content
80
- image_data = processed.metadata.get("image_data")
81
 
82
- # 1.5. Persist user message (Safety Check: Don't duplicate)
83
  if user_id and session_id:
84
- # Check if this exact request already exists in DB to prevent duplicates
85
  history = self.db_manager.get_chat_history(user_id, session_id) or []
86
  if not any(m.get("request_id") == request_id for m in history):
87
  await self._persist_message(
88
- user_id=user_id, session_id=session_id, role="user",
89
  content=query or "Uploaded an image", image_data=image_data,
90
- request_id=request_id
91
  )
92
 
93
- # ── 2. Cache lookup ───────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
  if settings.ENABLE_CACHE and not image_data:
95
  cache_key = self._make_cache_key(query)
96
- cached = self.cache_manager.get_cached_answer(cache_key)
97
- if cached:
98
- yield {"type": "thought", "content": "Retrieving answer from memory..."}
99
- yield {"type": "answer", "content": cached.get("answer")}
100
- # Persist assistant response
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
  if user_id and session_id:
102
- await self._persist_log(query, {"answer": cached.get("answer"), "metadata": cached.get("metadata")}, user_id, session_id, cache_key)
 
 
 
 
 
103
  return
104
- else:
105
- cache_key = None
106
 
107
- # ── 3. Pre-flight (SymPy) ─────────────────────────────────────────
 
 
 
 
108
  if not image_data:
109
- preflight_result = self._try_sympy(query)
110
- if preflight_result is not None:
111
- yield {"type": "thought", "content": "Calculating result symbolically..."}
112
- yield {"type": "answer", "content": preflight_result}
113
-
114
- result_schema.update({
115
- "source": "sympy_preflight",
116
- "answer": preflight_result,
117
- "metadata": {"model": "sympy", "tools_used": ["sympy"]}
118
- })
119
-
120
- await self._persist_log(query, result_schema, user_id, session_id, cache_key, request_id=request_id)
121
- return
 
 
 
 
 
122
 
123
  # ── 4. Agentic Streaming Loop ─────────────────────────────────────
 
 
 
 
 
 
 
 
124
  full_answer = ""
125
  async for event in self.adk_agent.solve(
126
- problem=query, image_data=image_data,
127
- session_id=session_id, user_id=user_id
128
  ):
129
- if event["type"] == "thought":
 
 
 
 
 
 
 
 
 
 
130
  yield event
131
- elif event["type"] == "answer":
132
- full_answer += event["content"]
 
133
  yield event
134
- elif event["type"] in ("thought", "action", "observation"):
135
- label = ""
136
- if event["type"] == "action": label = "βš™οΈ "
137
- elif event["type"] == "observation": label = "πŸ‘οΈ "
138
-
139
- result_schema["metadata"]["logic_trace"].append(f"{label}{event['content']}")
140
  yield event
141
- elif event["type"] == "error":
 
142
  yield event
 
143
  else:
144
- # Fallback for any other content
145
- full_answer += str(event.get("content", ""))
146
- yield {"type": "answer", "content": str(event.get("content", ""))}
 
147
 
148
  # ── 5. Finalize ──────��────────────────────────────────────────────
149
  result_schema["answer"] = full_answer
150
  result_schema["metadata"]["latency_ms"] = int((time.time() - start_time) * 1000)
151
-
152
  if full_answer:
153
- # AWAIT the final log instead of fire-and-forget to prevent race conditions with UI reloads.
154
- await self._persist_log(query, result_schema, user_id, session_id, cache_key, request_id=request_id)
 
 
155
 
156
  except Exception as e:
157
- logger.error(f"Orchestrator Error: {e}")
158
  yield {"type": "error", "content": f"Internal Error: {str(e)}"}
159
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
160
  async def _persist_message(self, user_id, session_id, role, content, **kwargs):
161
  try:
162
  self.db_manager.create_session(user_id, session_id)
@@ -165,78 +338,34 @@ class Orchestrator:
165
  logger.error(f"Failed to persist message: {e}")
166
 
167
  async def _persist_log(self, query, schema, user_id, session_id, cache_key, request_id=None):
168
- """Internal awaitable helper."""
169
- # Map logic_trace to reasoning for frontend consistency
170
- reasoning = "\n".join(schema["metadata"].get("logic_trace", []))
171
-
172
  await self._persist_message(
173
  user_id=user_id, session_id=session_id, role="assistant",
174
- content=schema["answer"], reasoning=reasoning, metadata=schema["metadata"],
175
- request_id=request_id
 
176
  )
177
  if settings.ENABLE_CACHE and cache_key:
 
178
  self.cache_manager.set_cached_answer(cache_key, schema)
179
- self.db_manager.save_problem({"content": query}, schema)
180
-
181
- def _try_sympy(self, query: str) -> Optional[str]:
182
- try:
183
- intent: Optional[MathIntent] = self.normalizer.normalize(query)
184
- if intent is None: return None
185
- expr_str = self._prep_expr(intent.expression)
186
- target_var = sympy.Symbol(intent.variable or "x")
187
- if intent.intent == "arithmetic": return self._solve_arithmetic(expr_str)
188
- if intent.intent == "equation": return self._solve_equation(expr_str, target_var)
189
- if intent.intent == "derivative":
190
- expr = parse_expr(expr_str, transformations=_SYMPY_TRANSFORMATIONS)
191
- return f"d/d{target_var}({intent.expression}) = {sympy.latex(sympy.diff(expr, target_var))}"
192
- if intent.intent == "integral":
193
- expr = parse_expr(expr_str, transformations=_SYMPY_TRANSFORMATIONS)
194
- return f"∫({intent.expression}) d{target_var} = {sympy.latex(sympy.integrate(expr, target_var))} + C"
195
- if intent.intent == "limit": return self._solve_limit(intent, query)
196
- if intent.intent == "simplification":
197
- expr = parse_expr(expr_str, transformations=_SYMPY_TRANSFORMATIONS)
198
- return f"Simplified: {sympy.latex(sympy.simplify(expr))}"
199
- except Exception: pass
200
- return None
201
-
202
- def _prep_expr(self, expr: str) -> str:
203
- expr = expr.replace("^", "**")
204
- expr = re.sub(r"(\d)([a-zA-Z])", r"\1*\2", expr)
205
- expr = re.sub(r"\)\s*\(", ")*(", expr)
206
- return expr.strip()
207
-
208
- def _solve_arithmetic(self, expr_str: str) -> Optional[str]:
209
- try:
210
- result = sympy.simplify(parse_expr(expr_str, transformations=_SYMPY_TRANSFORMATIONS))
211
- if result.is_number:
212
- numeric = float(result)
213
- return str(int(numeric)) if numeric == int(numeric) else f"{numeric:.6g}"
214
- return sympy.latex(result)
215
- except Exception: return None
216
-
217
- def _solve_equation(self, expr_str: str, var: sympy.Symbol) -> Optional[str]:
218
- try:
219
- parts = expr_str.split("=", 1)
220
- if len(parts) == 2:
221
- lhs = parse_expr(self._prep_expr(parts[0]), transformations=_SYMPY_TRANSFORMATIONS)
222
- rhs = parse_expr(self._prep_expr(parts[1]), transformations=_SYMPY_TRANSFORMATIONS)
223
- solution = sympy.solve(lhs - rhs, var)
224
- else:
225
- solution = sympy.solve(parse_expr(expr_str, transformations=_SYMPY_TRANSFORMATIONS), var)
226
- if not solution: return "No solution found."
227
- if len(solution) == 1: return f"{var} = {sympy.latex(solution[0])}"
228
- return f"{var} ∈ {{{', '.join(sympy.latex(s) for s in solution)}}}"
229
- except Exception: return None
230
-
231
- def _solve_limit(self, intent: MathIntent, original_query: str) -> Optional[str]:
232
- try:
233
- match = re.search(r"limit of\s+(.+?)\s+as\s+(\w+)\s+approaches\s+(.+)", original_query, re.IGNORECASE)
234
- if not match: return None
235
- expr = parse_expr(self._prep_expr(match.group(1)), transformations=_SYMPY_TRANSFORMATIONS)
236
- var = sympy.Symbol(match.group(2).strip())
237
- point = parse_expr(self._prep_expr(match.group(3).strip()), transformations=_SYMPY_TRANSFORMATIONS)
238
- return f"lim({var}β†’{point}) {sympy.latex(expr)} = {sympy.latex(sympy.limit(expr, var, point))}"
239
- except Exception: return None
240
 
241
  def _make_cache_key(self, query: str) -> str:
242
- return hashlib.sha256(query.strip().lower().encode()).hexdigest()
 
1
+ """
2
+ orchestrator.py
3
+
4
+ BUG FIX β€” Dead elif branch in the agentic streaming loop silently dropped events.
5
+
6
+ The original if/elif chain:
7
+
8
+ if event["type"] == "thought": ← catches "thought"
9
+ yield event
10
+ elif event["type"] == "answer":
11
+ full_answer += event["content"]
12
+ yield event
13
+ elif event["type"] in ("thought", "action", "observation"): ← DEAD β€” "thought" already caught above
14
+ label = ...
15
+ result_schema["metadata"]["logic_trace"].append(...)
16
+ yield event ← "action" and "observation" DO get appended to logic_trace here
17
+ elif event["type"] == "error":
18
+ yield event
19
+ else:
20
+ full_answer += str(event.get("content", "")) ← BUT "action"/"observation" never reach here
21
+ yield {"type": "answer", ...}
22
+
23
+ The consequence: "action" and "observation" events were yielded (good), BUT their
24
+ content was NEVER appended to result_schema["metadata"]["logic_trace"], so the
25
+ final persist_log had empty reasoning. More critically, the order of branches
26
+ meant the logic_trace append for "thought" was ALSO skipped β€” the first branch
27
+ caught "thought" and just yielded it without logging it.
28
+
29
+ This is a correctness bug but NOT the main cause of the blank UI. Documented here
30
+ for completeness; the primary fix is in schemas.py (missing request_id on Message)
31
+ and in frontend/app.py (sent_to_api=True for assistant messages).
32
+ """
33
+
34
  import logging
35
  import time
36
  import hashlib
37
  import json
38
  import re
39
+
40
+ def _normalize_math(text: str) -> str:
41
+ """Inline replacement for math_renderer.render_math().
42
+ Converts LaTeX delimiters to $...$ / $$...$$ for Streamlit MathJax.
43
+ Gemini 2.5 Flash mostly outputs $...$ already β€” this catches the rare
44
+ \\(...\\) and \\[...\\] variants and cleans ```math blocks.
45
+ """
46
+ if not text:
47
+ return text
48
+ # Block: \[ ... \] β†’ $$ ... $$
49
+ import re as _re
50
+ text = _re.sub(r'\\\[(.+?)\\\]', r'$$\1$$', text, flags=_re.DOTALL)
51
+ # Inline: \( ... \) β†’ $ ... $
52
+ text = _re.sub(r'\\\((.+?)\\\)', r'$\1$', text, flags=_re.DOTALL)
53
+ # ```math blocks β†’ $$ ... $$
54
+ text = _re.sub(r'```math\s*(.+?)\s*```', r'$$\1$$', text, flags=_re.DOTALL)
55
+ # Empty $$$$ artifacts
56
+ text = _re.sub(r'\$\$\s*\$\$', '', text)
57
+ return text.strip()
58
+
59
  import asyncio
60
  from typing import Any, Dict, Optional, AsyncGenerator
61
 
 
 
 
62
  from app.core.input_processor import InputProcessor
63
  from app.core.math_normalizer import MathQueryNormalizer, MathIntent
64
  from app.memory.cache import CacheManager
65
+ from app.core.sympy_solver import SymPySolver
66
+ from app.memory.semantic_cache import SemanticCache
67
  from app.memory.database import DatabaseManager
68
+
69
  from app.core.settings import settings
70
+ from app.agents.adk_mathminds import MathMindsADKAgent
71
 
72
  logger = logging.getLogger(__name__)
73
 
 
 
74
 
75
  class Orchestrator:
76
  """
 
81
  self,
82
  cache_manager: Optional[CacheManager] = None,
83
  db_manager: Optional[DatabaseManager] = None,
84
+ semantic_cache: Optional[SemanticCache] = None,
85
  redis_client: Any = None,
86
  ):
87
  try:
 
91
  self.db_manager = db_manager or DatabaseManager()
92
  self.redis_client = redis_client
93
  self.adk_agent = MathMindsADKAgent(redis_client=self.redis_client)
94
+ self.sympy_solver = SymPySolver()
95
+
96
+ # Semantic cache β€” use injected instance from deps.py if provided,
97
+ # otherwise create internally.
98
+ if semantic_cache is not None:
99
+ self.semantic_cache = semantic_cache if settings.ENABLE_CACHE else None
100
+ else:
101
+ self.semantic_cache = SemanticCache(
102
+ redis_client = self.redis_client,
103
+ gemini_api_key = settings.GOOGLE_API_KEY,
104
+ ) if settings.ENABLE_CACHE else None
105
  except Exception as e:
106
  logger.critical(f"Failed to initialize Orchestrator: {e}")
107
  raise
 
124
  "status": "success",
125
  "source": "agent",
126
  "answer": "",
127
+ "metadata": {
128
+ "latency_ms": 0,
129
+ "model": "gemini-2.5-flash",
130
+ "tools_used": [],
131
+ "logic_trace": [],
132
  },
133
  }
134
 
135
  try:
136
  # ── 1. Input processing ─────────────────────────────���─────────────
137
+ processed = self.input_processor.process_compound(
138
+ text_input=query, image_input=image
139
+ )
140
  if not processed.is_valid:
141
  yield {"type": "error", "content": processed.error_message}
142
  return
143
 
144
+ query = processed.cleaned_content
145
+ image_data = processed.metadata.get("image_data") if processed.metadata else None
146
 
147
+ # ── 1.5. Persist user message (idempotent) ────────────────────────
148
  if user_id and session_id:
 
149
  history = self.db_manager.get_chat_history(user_id, session_id) or []
150
  if not any(m.get("request_id") == request_id for m in history):
151
  await self._persist_message(
152
+ user_id=user_id, session_id=session_id, role="user",
153
  content=query or "Uploaded an image", image_data=image_data,
154
+ request_id=request_id,
155
  )
156
 
157
+ # ── 2. Cache lookup β€” two layers ──────────────────────────────────
158
+ #
159
+ # Layer 1 β€” Exact hash (Redis, microseconds, zero API cost)
160
+ # sha256(normalized_query) β†’ instant lookup for identical questions
161
+ #
162
+ # Layer 2 β€” Semantic similarity (Redis embeddings, ~50ms, uses
163
+ # gemini-embedding-001 which has its OWN 1500 req/day quota,
164
+ # completely separate from the 20 req/day generate_content limit)
165
+ # Cosine similarity β‰₯ 0.85 β†’ treat as same question
166
+ #
167
+ # Both layers are skipped for image queries (can't embed images).
168
+ cache_key = None
169
+ cached_answer = None
170
+ cache_source = None
171
+
172
  if settings.ENABLE_CACHE and not image_data:
173
  cache_key = self._make_cache_key(query)
174
+
175
+ # Layer 1: exact hash
176
+ exact = self.cache_manager.get_cached_answer(cache_key)
177
+ if exact:
178
+ cached_answer = exact.get("answer")
179
+ cache_source = "exact_cache"
180
+ logger.info(f"Cache layer 1 HIT (exact) for key={cache_key[:8]}")
181
+
182
+ # Layer 2: semantic similarity (only if exact missed)
183
+ if not cached_answer and self.semantic_cache:
184
+ sem = self.semantic_cache.get(query)
185
+ if sem:
186
+ cached_answer = sem["answer"]
187
+ cache_source = f"semantic_cache (similarity={sem['similarity']}))"
188
+ logger.info(f"Cache layer 2 HIT (semantic) similarity={sem['similarity']}")
189
+
190
+ if cached_answer:
191
+ yield {"type": "thought", "content": f"πŸ’Ύ Retrieving from memory ({cache_source})..."}
192
+ yield {"type": "answer", "content": cached_answer}
193
  if user_id and session_id:
194
+ await self._persist_log(
195
+ query,
196
+ {"answer": cached_answer, "metadata": {"source": cache_source}},
197
+ user_id, session_id, cache_key,
198
+ request_id=request_id,
199
+ )
200
  return
 
 
201
 
202
+ # ── 3. SymPy Preflight ────────────────────────────────────────────
203
+ # Try to solve symbolically BEFORE calling Gemini.
204
+ # Cost: 0 LLM calls. Handles derivatives, integrals,
205
+ # equations, limits, arithmetic in milliseconds.
206
+ # If SymPy can't solve it β†’ falls through to Gemini.
207
  if not image_data:
208
+ math_intent = self.normalizer.normalize(query)
209
+ if math_intent:
210
+ sympy_result = self.sympy_solver.solve(math_intent)
211
+ if sympy_result:
212
+ # sympy_solver.solve() returns a plain str, not a dict
213
+ answer = sympy_result
214
+ yield {"type": "thought", "content": f"⚑ Solving symbolically ({math_intent.intent})..."}
215
+ yield {"type": "answer", "content": _normalize_math(answer)}
216
+ result_schema["answer"] = answer
217
+ result_schema["metadata"]["source"] = "sympy_preflight"
218
+ result_schema["metadata"]["intent"] = math_intent.intent
219
+ if user_id and session_id:
220
+ await self._persist_log(
221
+ query, result_schema,
222
+ user_id, session_id, cache_key,
223
+ request_id=request_id,
224
+ )
225
+ return
226
 
227
  # ── 4. Agentic Streaming Loop ─────────────────────────────────────
228
+ # FIX: The original had a dead elif branch. The chain was:
229
+ # if "thought" β†’ yield
230
+ # elif "answer" β†’ accumulate + yield
231
+ # elif ("thought","action","observation") β†’ log + yield ← "thought" ALREADY matched above
232
+ # elif "error" β†’ yield
233
+ #
234
+ # Result: "action" and "observation" were yielded but never logged to
235
+ # logic_trace. Rewritten as explicit branches with no dead code.
236
  full_answer = ""
237
  async for event in self.adk_agent.solve(
238
+ problem=query, image_data=image_data,
239
+ session_id=session_id, user_id=user_id,
240
  ):
241
+ ev_type = event.get("type", "")
242
+ content = event.get("content", "")
243
+
244
+ if ev_type == "answer":
245
+ # Normalize LaTeX and SymPy notation before sending to frontend
246
+ content = _normalize_math(content)
247
+ full_answer += content
248
+ yield {**event, "content": content}
249
+
250
+ elif ev_type == "thought":
251
+ result_schema["metadata"]["logic_trace"].append(content)
252
  yield event
253
+
254
+ elif ev_type == "action":
255
+ result_schema["metadata"]["logic_trace"].append(f"βš™οΈ {content}")
256
  yield event
257
+
258
+ elif ev_type == "observation":
259
+ result_schema["metadata"]["logic_trace"].append(f"πŸ‘οΈ {content}")
 
 
 
260
  yield event
261
+
262
+ elif ev_type == "error":
263
  yield event
264
+
265
  else:
266
+ # Unexpected event type β€” treat as answer text so nothing is lost
267
+ if content:
268
+ full_answer += str(content)
269
+ yield {"type": "answer", "content": str(content)}
270
 
271
  # ── 5. Finalize ──────��────────────────────────────────────────────
272
  result_schema["answer"] = full_answer
273
  result_schema["metadata"]["latency_ms"] = int((time.time() - start_time) * 1000)
274
+
275
  if full_answer:
276
+ await self._persist_log(
277
+ query, result_schema, user_id, session_id, cache_key,
278
+ request_id=request_id,
279
+ )
280
 
281
  except Exception as e:
282
+ logger.error(f"Orchestrator Error: {e}", exc_info=True)
283
  yield {"type": "error", "content": f"Internal Error: {str(e)}"}
284
 
285
+ async def solve_problem(
286
+ self,
287
+ query: Optional[str] = None,
288
+ image: Optional[str] = None,
289
+ request_id: Optional[str] = None,
290
+ model_preference: str = "fast",
291
+ session_id: Optional[str] = None,
292
+ user_id: Optional[str] = None,
293
+ ) -> Dict[str, Any]:
294
+ """
295
+ Non-streaming version of solve_problem.
296
+ Executes the full agent loop and returns the final answer object.
297
+ """
298
+ full_answer = ""
299
+ logic_trace = []
300
+ error = None
301
+
302
+ # We wrap the logic here to ensure we get a consistent response
303
+ # by consuming the stream which already handles persistence.
304
+ async for event in self.solve_problem_stream(
305
+ query=query,
306
+ image=image,
307
+ request_id=request_id,
308
+ model_preference=model_preference,
309
+ session_id=session_id,
310
+ user_id=user_id,
311
+ ):
312
+ ev_type = event.get("type")
313
+ content = event.get("content")
314
+
315
+ if ev_type == "answer":
316
+ full_answer += content
317
+ elif ev_type in ("thought", "action", "observation"):
318
+ logic_trace.append(content)
319
+ elif ev_type == "error":
320
+ error = content
321
+
322
+ return {
323
+ "request_id": request_id or "unknown",
324
+ "status": "error" if error else "success",
325
+ "answer": full_answer,
326
+ "error": error,
327
+ "metadata": {
328
+ "logic_trace": logic_trace,
329
+ "timestamp": time.time(),
330
+ }
331
+ }
332
+
333
  async def _persist_message(self, user_id, session_id, role, content, **kwargs):
334
  try:
335
  self.db_manager.create_session(user_id, session_id)
 
338
  logger.error(f"Failed to persist message: {e}")
339
 
340
  async def _persist_log(self, query, schema, user_id, session_id, cache_key, request_id=None):
341
+ reasoning = "\n".join(schema.get("metadata", {}).get("logic_trace", []))
 
 
 
342
  await self._persist_message(
343
  user_id=user_id, session_id=session_id, role="assistant",
344
+ content=schema["answer"], reasoning=reasoning,
345
+ metadata=schema.get("metadata", {}),
346
+ request_id=request_id,
347
  )
348
  if settings.ENABLE_CACHE and cache_key:
349
+ # Layer 1: exact hash cache
350
  self.cache_manager.set_cached_answer(cache_key, schema)
351
+ # Layer 2: semantic cache (stores embedding vector alongside answer)
352
+ # Only store if we have a real answer β€” don't cache errors/empty strings
353
+ # Wrapped in to_thread: semantic_cache.set() calls the embedding API
354
+ # (blocking HTTP). Running it in a thread means the response is already
355
+ # returned to the user before the cache write completes.
356
+ # Skip semantic cache write for SymPy answers β€” they are deterministic,
357
+ # so caching them via embedding similarity adds no value and wastes
358
+ # 1 embedding API call (out of the 1500/day quota).
359
+ source = schema.get("metadata", {}).get("source", "")
360
+ if self.semantic_cache and schema.get("answer") and source != "sympy_preflight":
361
+ await asyncio.to_thread(
362
+ self.semantic_cache.set,
363
+ query = query,
364
+ answer = schema["answer"],
365
+ metadata = schema.get("metadata", {}),
366
+ )
367
+ # pymongo is sync β€” run in thread so it doesn't block the event loop
368
+ await asyncio.to_thread(self.db_manager.save_problem, {"content": query}, schema)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
369
 
370
  def _make_cache_key(self, query: str) -> str:
371
+ return hashlib.sha256(query.strip().lower().encode()).hexdigest()
app/core/schemas.py CHANGED
@@ -1,18 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from datetime import datetime
2
  from typing import Any, Dict, Optional, List
3
  from pydantic import BaseModel, Field, model_validator
4
 
 
5
  class SolveRequest(BaseModel):
6
- """
7
- Request model for the /solve endpoint.
8
- Supports text-only, image-only, or multimodal (text + image) input.
9
- """
10
- text: Optional[str] = Field(None, description="The math problem text or specific question about the image.")
11
- image: Optional[str] = Field(None, description="Base64 encoded image string or Image URL.")
12
- session_id: Optional[str] = Field(None, description="Session ID for maintaining chat context.")
13
- model_preference: Optional[str] = Field("fast", description="Model preference: 'fast' or 'reasoning'.")
14
- request_id: Optional[str] = Field(None, description="Unique ID for deduplication.")
15
- input: Optional[str] = Field(None, description="Legacy field for backward compatibility.", deprecated=True)
16
 
17
  @property
18
  def effective_text(self) -> Optional[str]:
@@ -21,77 +39,73 @@ class SolveRequest(BaseModel):
21
  @model_validator(mode='before')
22
  @classmethod
23
  def check_input_compatibility(cls, values: Any) -> Any:
24
- # Support legacy 'input' field
25
  if isinstance(values, dict):
26
  if 'input' in values and not values.get('text'):
27
  values['text'] = values['input']
28
  return values
29
-
30
  @model_validator(mode='after')
31
  def check_at_least_one(self) -> 'SolveRequest':
32
- text = self.text
33
- image = self.image
34
- # We don't check 'input' here because it should have been mapped to 'text' above
35
- if not text and not image:
36
- raise ValueError("At least one of 'text' or 'image' must be provided.")
37
  return self
38
 
 
39
  class SolveResponse(BaseModel):
40
- """
41
- Response model for the /solve endpoint.
42
- """
43
- request_id: str
44
- status: str = Field(..., description="Status of the request (success/error).")
45
- problem_type: str = "unknown"
46
- source: str = "unknown"
47
- answer: Any = Field(None, description="The structured answer from the AI. Can be str, float, or dict.")
48
- steps: List[str] = Field(default_factory=list, description="A list of steps taken to solve the problem.")
49
- explanation: Optional[str] = Field(None, description="A detailed explanation of the solution.")
50
- confidence: float = Field(0.0, description="Confidence score of the answer.")
51
- cached: bool = Field(False, description="Indicates if the response was served from cache.")
52
- error: Optional[str] = Field(None, description="Error message if status is error.")
53
- error_code: Optional[str] = Field(None, description="Error code if status is error.")
54
- metadata: Dict[str, Any] = Field(default_factory=dict, description="Metadata about the processing.")
55
 
56
  class HealthResponse(BaseModel):
57
- """
58
- Response model for the /health endpoint.
59
- """
60
- status: str
61
  version: str
62
 
63
- # --- Chat History Schemas ---
64
 
65
  class Message(BaseModel):
66
- role: str
67
- content: str
68
- timestamp: datetime
69
- reasoning: Optional[str] = None
70
- metadata: Dict[str, Any] = {}
71
- steps: List[str] = []
 
 
 
 
72
 
73
  class ChatSession(BaseModel):
74
  session_id: str
75
- title: str
76
  created_at: datetime
77
- # messages: Optional[List[Message]] = None # Optional for listing
78
 
79
  class SessionRename(BaseModel):
80
  title: str = Field(..., min_length=1, max_length=100)
81
 
82
- # --- Auth Schemas ---
83
 
84
  class UserSignup(BaseModel):
85
- email: str
86
- password: str = Field(..., min_length=8, max_length=72)
87
  full_name: Optional[str] = None
88
 
 
89
  class UserLogin(BaseModel):
90
- email: str
91
  password: str = Field(..., max_length=72)
92
 
 
93
  class TokenResponse(BaseModel):
94
  access_token: str
95
- token_type: str = "bearer"
96
- user_id: str
97
- email: str
 
1
+ """
2
+ schemas.py
3
+
4
+ BUG FIX β€” Message model was missing `request_id: Optional[str]`.
5
+
6
+ Why this caused "no answer" on the UI:
7
+ The GET /chat/sessions/{id}/messages endpoint uses `response_model=List[Message]`.
8
+ FastAPI strips any field NOT declared in the model before sending the response.
9
+ So even though `save_chat_message(..., request_id=request_id)` correctly stores
10
+ request_id in MongoDB, FastAPI silently dropped it on the way back out.
11
+
12
+ The frontend's load_messages() dedup merge keys on (role, request_id):
13
+ server_keys = {(m["role"], m["request_id"]) for m in server_msgs if m.get("request_id")}
14
+
15
+ With request_id always None from server, server_keys was always empty.
16
+ On every load_messages() call, ALL local messages looked unconfirmed, so they
17
+ got appended again as duplicates β€” and on the next rerun the trigger condition
18
+ `role=="user" and not sent_to_api` re-fired, sending the question a second time
19
+ and overwriting the answer_placeholder before it could be seen.
20
+ """
21
+
22
  from datetime import datetime
23
  from typing import Any, Dict, Optional, List
24
  from pydantic import BaseModel, Field, model_validator
25
 
26
+
27
  class SolveRequest(BaseModel):
28
+ text: Optional[str] = Field(None, description="The math problem text.")
29
+ image: Optional[str] = Field(None, description="Base64 encoded image string.")
30
+ session_id: Optional[str] = Field(None, description="Session ID for chat context.")
31
+ model_preference: Optional[str] = Field("fast", description="'fast' or 'reasoning'.")
32
+ request_id: Optional[str] = Field(None, description="Unique ID for deduplication.")
33
+ input: Optional[str] = Field(None, description="Legacy field.", deprecated=True)
 
 
 
 
34
 
35
  @property
36
  def effective_text(self) -> Optional[str]:
 
39
  @model_validator(mode='before')
40
  @classmethod
41
  def check_input_compatibility(cls, values: Any) -> Any:
 
42
  if isinstance(values, dict):
43
  if 'input' in values and not values.get('text'):
44
  values['text'] = values['input']
45
  return values
46
+
47
  @model_validator(mode='after')
48
  def check_at_least_one(self) -> 'SolveRequest':
49
+ if not self.text and not self.image:
50
+ raise ValueError("At least one of 'text' or 'image' must be provided.")
 
 
 
51
  return self
52
 
53
+
54
  class SolveResponse(BaseModel):
55
+ request_id: str
56
+ status: str = Field(..., description="success/error")
57
+ problem_type: str = "unknown"
58
+ source: str = "unknown"
59
+ answer: Any = Field(None)
60
+ steps: List[str] = Field(default_factory=list)
61
+ explanation: Optional[str] = None
62
+ confidence: float = 0.0
63
+ cached: bool = False
64
+ error: Optional[str] = None
65
+ error_code: Optional[str] = None
66
+ metadata: Dict[str, Any] = Field(default_factory=dict)
67
+
 
 
68
 
69
  class HealthResponse(BaseModel):
70
+ status: str
 
 
 
71
  version: str
72
 
 
73
 
74
  class Message(BaseModel):
75
+ role: str
76
+ content: str
77
+ timestamp: datetime
78
+ # FIX: This field was missing. FastAPI was stripping it from every response,
79
+ # breaking the frontend dedup merge and causing phantom re-triggers.
80
+ request_id: Optional[str] = None
81
+ reasoning: Optional[str] = None
82
+ metadata: Dict[str, Any] = {}
83
+ steps: List[str] = []
84
+
85
 
86
  class ChatSession(BaseModel):
87
  session_id: str
88
+ title: str
89
  created_at: datetime
90
+
91
 
92
  class SessionRename(BaseModel):
93
  title: str = Field(..., min_length=1, max_length=100)
94
 
 
95
 
96
  class UserSignup(BaseModel):
97
+ email: str
98
+ password: str = Field(..., min_length=8, max_length=72)
99
  full_name: Optional[str] = None
100
 
101
+
102
  class UserLogin(BaseModel):
103
+ email: str
104
  password: str = Field(..., max_length=72)
105
 
106
+
107
  class TokenResponse(BaseModel):
108
  access_token: str
109
+ token_type: str = "bearer"
110
+ user_id: str
111
+ email: str
app/core/settings.py CHANGED
@@ -37,7 +37,7 @@ class Settings(BaseSettings):
37
  ENABLE_LOCAL_MODELS: bool = True
38
  ENABLE_CACHE: bool = True
39
  ENABLE_AUTH: bool = True
40
- MAX_LLM_CALLS_PER_DAY: int = 18 # Default limit per user per day
41
 
42
  # Integrations
43
  FIREBASE_CREDENTIALS_PATH: Optional[str] = None
 
37
  ENABLE_LOCAL_MODELS: bool = True
38
  ENABLE_CACHE: bool = True
39
  ENABLE_AUTH: bool = True
40
+ MAX_LLM_CALLS_PER_DAY: int = 100 # Default limit per user per day
41
 
42
  # Integrations
43
  FIREBASE_CREDENTIALS_PATH: Optional[str] = None
app/core/sympy_solver.py ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ import sympy
3
+ from sympy.parsing.sympy_parser import parse_expr, standard_transformations, implicit_multiplication_application, convert_xor
4
+ from typing import Optional, Any
5
+ from app.core.math_normalizer import MathIntent
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+ class SymPySolver:
10
+ """
11
+ Attempts to solve mathematical expressions using SymPy.
12
+ Used as a pre-flight check to save LLM quota for pure math.
13
+ """
14
+
15
+ def solve(self, intent: MathIntent) -> Optional[str]:
16
+ """
17
+ Processes a MathIntent and returns a formatted solution string or None.
18
+ """
19
+ try:
20
+ expr_str = intent.expression
21
+ action = intent.intent
22
+ var_symbol = sympy.Symbol(intent.variable or 'x')
23
+
24
+ if action == "derivative":
25
+ return self._solve_derivative(expr_str, var_symbol)
26
+ elif action == "integral":
27
+ return self._solve_integral(expr_str, var_symbol)
28
+ elif action == "equation":
29
+ return self._solve_equation(expr_str, var_symbol)
30
+ elif action == "arithmetic":
31
+ return self._solve_arithmetic(expr_str)
32
+
33
+ return None
34
+ except Exception as e:
35
+ logger.info(f"SymPy could not solve '{intent.expression}': {e}")
36
+ return None
37
+
38
+ def _parse(self, expr_str: str) -> Any:
39
+ transformations = standard_transformations + (implicit_multiplication_application, convert_xor)
40
+ return parse_expr(expr_str, transformations=transformations)
41
+
42
+ def _solve_derivative(self, expr_str: str, var: sympy.Symbol) -> Optional[str]:
43
+ expr = self._parse(expr_str)
44
+ result = sympy.diff(expr, var)
45
+ return f"The derivative of ${sympy.latex(expr)}$ with respect to ${var}$ is:\n\n$${sympy.latex(result)}$$"
46
+
47
+ def _solve_integral(self, expr_str: str, var: sympy.Symbol) -> Optional[str]:
48
+ expr = self._parse(expr_str)
49
+ result = sympy.integrate(expr, var)
50
+ # Check if integral was actually solved (not just returned as an Integral object)
51
+ if isinstance(result, sympy.Integral):
52
+ return None
53
+ return f"The indefinite integral of ${sympy.latex(expr)}$ with respect to ${var}$ is:\n\n$${sympy.latex(result)} + C$$"
54
+
55
+ def _solve_equation(self, expr_str: str, var: sympy.Symbol) -> Optional[str]:
56
+ # Handle equations like "x^2 - 4 = 0" or "x^2 = 4"
57
+ if "=" in expr_str:
58
+ lhs_str, rhs_str = expr_str.split("=")
59
+ lhs = self._parse(lhs_str.strip())
60
+ rhs = self._parse(rhs_str.strip())
61
+ eq = sympy.Eq(lhs, rhs)
62
+ else:
63
+ # Assume expression = 0 if no '='
64
+ eq = self._parse(expr_str)
65
+
66
+ solutions = sympy.solve(eq, var)
67
+ if not solutions:
68
+ return "No solutions found."
69
+
70
+ sol_str = ", ".join([f"${sympy.latex(s)}$" for s in solutions])
71
+ return f"The solutions for ${sympy.latex(eq if '=' in expr_str else sympy.Eq(self._parse(expr_str), 0))}$ are:\n\n{sol_str}"
72
+
73
+ def _solve_arithmetic(self, expr_str: str) -> Optional[str]:
74
+ # SAFETY CHECK: reject expressions containing non-math words
75
+ # If the expression has alphabetic characters that aren't recognised
76
+ # math symbols (e, i, pi, x, y, z, etc.), SymPy silently treats each
77
+ # letter as a variable and multiplies them together β€” producing garbled
78
+ # output like "45aeflouv" on the UI.
79
+ # Example: "the value of 5*9" β†’ SymPy sees t*h*e*v*a*l*u*e*o*f*5*9
80
+ #
81
+ # Rule: if the expression contains any English word characters beyond
82
+ # known math constants, return None and let Gemini handle it.
83
+ import re
84
+ # Strip pure math tokens to see what's left
85
+ stripped = re.sub(r'[0-9+\-*/^().\s]', '', expr_str)
86
+ # Known single-letter math constants that SymPy handles correctly
87
+ safe_single_letters = set('eijxyz')
88
+ # Known multi-letter constants/functions
89
+ safe_words = {'pi', 'inf', 'oo', 'sin', 'cos', 'tan', 'log', 'exp',
90
+ 'sqrt', 'abs', 'floor', 'ceil'}
91
+
92
+ # Check for multi-char letter sequences (words) that aren't math
93
+ words_in_expr = re.findall(r'[a-zA-Z]+', expr_str)
94
+ for word in words_in_expr:
95
+ if word.lower() not in safe_words and len(word) > 1:
96
+ # Multi-letter word that isn't a math function β€” natural language crept in
97
+ logger.debug(f"SymPy arithmetic rejected: found word '{word}' in '{expr_str}'")
98
+ return None
99
+
100
+ try:
101
+ expr = self._parse(expr_str)
102
+ result = expr.evalf() if expr.is_number else sympy.simplify(expr)
103
+
104
+ # If result is same as input and not a simple number, let Gemini handle it
105
+ if str(result) == expr_str and not result.is_number:
106
+ return None
107
+
108
+ # Format result cleanly β€” integer if possible, float otherwise
109
+ try:
110
+ numeric = float(result)
111
+ if numeric == int(numeric):
112
+ display = str(int(numeric))
113
+ else:
114
+ display = f"{numeric:.4f}".rstrip('0').rstrip('.')
115
+ return f"Result: **{display}**\n\n$${ sympy.latex(self._parse(expr_str))} = {sympy.latex(result)}$$"
116
+ except Exception:
117
+ return f"Result of evaluation:\n\n$${sympy.latex(result)}$$"
118
+ except Exception as e:
119
+ logger.debug(f"SymPy arithmetic eval failed for '{expr_str}': {e}")
120
+ return None
app/memory/cache.py CHANGED
@@ -1,131 +1,171 @@
1
  import json
2
  import logging
3
- import os
4
  from typing import Any, Dict, Optional
5
- from app.core.settings import settings
6
 
7
  import redis
8
  from redis.exceptions import RedisError
9
 
10
- # Configure logging
 
11
  logger = logging.getLogger(__name__)
12
 
 
13
  class CacheManager:
14
- """
15
- Manages Redis cache operations for the AI system.
16
- Handles connections, serialization, and failure scenarios gracefully.
17
- """
18
-
19
- def __init__(self, redis_url: Optional[str] = None, connection_pool: Optional[redis.ConnectionPool] = None):
20
- """
21
- Initialize the CacheManager.
22
-
23
- Args:
24
- redis_url: Redis connection string (used if pool not provided).
25
- connection_pool: Existing Redis connection pool.
26
- """
27
  self.redis_url = redis_url or settings.REDIS_URL
28
  self.redis_client = None
29
-
30
  try:
 
31
  if connection_pool:
32
- self.redis_client = redis.Redis(connection_pool=connection_pool, decode_responses=True)
 
 
 
33
  else:
34
- # If no pool provided, create standard client (which uses internal pool)
35
- # But typically we want to pass the pool.
36
- self.redis_client = redis.from_url(self.redis_url, decode_responses=True)
37
-
38
- # Fast ping to verify connection
 
 
39
  self.redis_client.ping()
40
- logger.info(f"Successfully connected to Redis at {self.redis_url}")
41
-
 
42
  except RedisError as e:
43
- logger.error(f"Failed to connect to Redis: {e}")
 
44
  self.redis_client = None
45
 
46
- # _connect method is removed/merged into __init__ since we prefer injection
 
47
 
48
- def get_cached_answer(self, cache_key: str) -> Optional[Dict[str, Any]]:
49
- """
50
- Retrieve a cached answer by its hash key.
51
 
52
- Args:
53
- cache_key: The unique hash key for the problem.
54
 
55
- Returns:
56
- Optional[Dict[str, Any]]: The cached answer info if found and valid, else None.
57
- """
58
  if not self.redis_client:
59
- logger.warning("Redis client is not available. Skipping cache lookup.")
60
  return None
61
 
62
  try:
63
- data = self.redis_client.get(cache_key)
 
 
 
 
64
  if data:
65
- logger.info(f"Cache hit for key: {cache_key}")
66
  return json.loads(data)
67
- logger.debug(f"Cache miss for key: {cache_key}")
68
- return None
69
- except RedisError as e:
70
- logger.error(f"Redis error during get operations: {e}")
71
- return None
72
- except json.JSONDecodeError as e:
73
- logger.error(f"Failed to decode cached data for key {cache_key}: {e}")
74
  return None
75
 
76
- def set_cached_answer(self, cache_key: str, answer: Dict[str, Any], ttl: int = 86400) -> bool:
77
- """
78
- Cache an answer with a TTL.
79
 
80
- Args:
81
- cache_key: The unique hash key.
82
- answer: The answer data to cache (will be JSON serialized).
83
- ttl: Time-to-live in seconds. Defaults to 86400 (24 hours).
 
 
 
 
 
84
 
85
- Returns:
86
- bool: True if successful, False otherwise.
87
- """
88
  if not self.redis_client:
89
- logger.warning("Redis client is not available. Skipping cache write.")
90
  return False
91
 
92
  try:
93
- serialized_data = json.dumps(answer)
94
- self.redis_client.setex(cache_key, ttl, serialized_data)
95
- logger.info(f"Successfully cached answer for key: {cache_key} with TTL {ttl}")
 
 
 
 
 
 
 
 
96
  return True
 
97
  except (RedisError, TypeError) as e:
98
- # TypeError catches JSON serialization errors
99
- logger.error(f"Failed to cache answer for key {cache_key}: {e}")
100
  return False
101
 
102
- def set_if_not_exists(self, cache_key: str, answer: Dict[str, Any], ttl: int = 86400) -> bool:
103
- """
104
- Set cache only if key doesn't exist (atomic operation).
105
- Prevents thundering herd when multiple requests populate cache.
106
-
107
- Args:
108
- cache_key: The unique hash key.
109
- answer: The answer data to cache.
110
- ttl: Time-to-live in seconds.
111
-
112
- Returns:
113
- bool: True if set, False if key already existed or error.
114
- """
115
  if not self.redis_client:
116
  return False
117
-
118
  try:
119
- serialized_data = json.dumps(answer)
120
- # SETNX is atomic - only succeeds if key doesn't exist
121
- # Redis-py set() with nx=True is equivalent to SETNX + EXPIRE
 
 
122
  result = self.redis_client.set(
123
- cache_key,
124
- serialized_data,
125
- ex=ttl,
126
- nx=True # Only set if not exists
127
  )
 
128
  return bool(result)
 
129
  except Exception as e:
130
- logger.error(f"Failed to set_if_not_exists for {cache_key}: {e}")
 
131
  return False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import json
2
  import logging
 
3
  from typing import Any, Dict, Optional
 
4
 
5
  import redis
6
  from redis.exceptions import RedisError
7
 
8
+ from app.core.settings import settings
9
+
10
  logger = logging.getLogger(__name__)
11
 
12
+
13
  class CacheManager:
14
+
15
+ CACHE_PREFIX = "mathminds:cache:"
16
+ MAX_CACHE_SIZE = 50000
17
+
18
+ def __init__(
19
+ self,
20
+ redis_url: Optional[str] = None,
21
+ connection_pool: Optional[redis.ConnectionPool] = None,
22
+ ):
23
+
 
 
 
24
  self.redis_url = redis_url or settings.REDIS_URL
25
  self.redis_client = None
26
+
27
  try:
28
+
29
  if connection_pool:
30
+ self.redis_client = redis.Redis(
31
+ connection_pool=connection_pool,
32
+ decode_responses=True,
33
+ )
34
  else:
35
+ self.redis_client = redis.from_url(
36
+ self.redis_url,
37
+ decode_responses=True,
38
+ socket_timeout=2,
39
+ socket_connect_timeout=2,
40
+ )
41
+
42
  self.redis_client.ping()
43
+
44
+ logger.info(f"Connected to Redis at {self.redis_url}")
45
+
46
  except RedisError as e:
47
+
48
+ logger.error(f"Redis connection failed: {e}")
49
  self.redis_client = None
50
 
51
+ def _serialize(self, data: Any) -> str:
52
+ return json.dumps(data, default=str)
53
 
54
+ def _prefixed(self, key: str) -> str:
55
+ return f"{self.CACHE_PREFIX}{key}"
 
56
 
57
+ def get_cached_answer(self, cache_key: str) -> Optional[Dict[str, Any]]:
 
58
 
 
 
 
59
  if not self.redis_client:
 
60
  return None
61
 
62
  try:
63
+
64
+ key = self._prefixed(cache_key)
65
+
66
+ data = self.redis_client.get(key)
67
+
68
  if data:
69
+ logger.debug(f"Cache hit: {key}")
70
  return json.loads(data)
71
+
 
 
 
 
 
 
72
  return None
73
 
74
+ except (RedisError, json.JSONDecodeError) as e:
 
 
75
 
76
+ logger.error(f"Cache read error: {e}")
77
+ return None
78
+
79
+ def set_cached_answer(
80
+ self,
81
+ cache_key: str,
82
+ answer: Dict[str, Any],
83
+ ttl: int = 86400,
84
+ ) -> bool:
85
 
 
 
 
86
  if not self.redis_client:
 
87
  return False
88
 
89
  try:
90
+
91
+ key = self._prefixed(cache_key)
92
+
93
+ serialized_data = self._serialize(answer)
94
+
95
+ if len(serialized_data) > self.MAX_CACHE_SIZE:
96
+ logger.warning("Cache skipped: payload too large")
97
+ return False
98
+
99
+ self.redis_client.setex(key, ttl, serialized_data)
100
+
101
  return True
102
+
103
  except (RedisError, TypeError) as e:
104
+
105
+ logger.error(f"Cache write failed: {e}")
106
  return False
107
 
108
+ def set_if_not_exists(
109
+ self,
110
+ cache_key: str,
111
+ answer: Dict[str, Any],
112
+ ttl: int = 86400,
113
+ ) -> bool:
114
+
 
 
 
 
 
 
115
  if not self.redis_client:
116
  return False
117
+
118
  try:
119
+
120
+ key = self._prefixed(cache_key)
121
+
122
+ serialized_data = self._serialize(answer)
123
+
124
  result = self.redis_client.set(
125
+ key,
126
+ serialized_data,
127
+ ex=ttl,
128
+ nx=True,
129
  )
130
+
131
  return bool(result)
132
+
133
  except Exception as e:
134
+
135
+ logger.error(f"set_if_not_exists failed: {e}")
136
  return False
137
+
138
+ def delete(self, cache_key: str) -> bool:
139
+
140
+ if not self.redis_client:
141
+ return False
142
+
143
+ try:
144
+
145
+ key = self._prefixed(cache_key)
146
+
147
+ return bool(self.redis_client.delete(key))
148
+
149
+ except RedisError:
150
+
151
+ return False
152
+
153
+ def stats(self) -> Dict[str, Any]:
154
+
155
+ if not self.redis_client:
156
+ return {}
157
+
158
+ try:
159
+
160
+ info = self.redis_client.info()
161
+
162
+ return {
163
+ "used_memory": info.get("used_memory_human"),
164
+ "connected_clients": info.get("connected_clients"),
165
+ "keyspace_hits": info.get("keyspace_hits"),
166
+ "keyspace_misses": info.get("keyspace_misses"),
167
+ }
168
+
169
+ except Exception:
170
+
171
+ return {}
app/memory/semantic_cache.py ADDED
@@ -0,0 +1,245 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ app/memory/semantic_cache.py β€” Semantic (meaning-aware) cache for MathMinds AI.
3
+
4
+ Architecture
5
+ ────────────
6
+ Exact hash cache (Redis) ← microseconds, free
7
+ ↓ MISS
8
+ Semantic vector cache (Redis) ← ~50ms, free (embedding stored in Redis)
9
+ ↓ MISS
10
+ Gemini API call ← costs 1 quota unit
11
+
12
+ Why two layers?
13
+ - Exact cache: zero cost, handles identical repeated questions instantly.
14
+ - Semantic cache: handles paraphrases. Uses Google's gemini-embedding-001
15
+ to embed both the query and stored questions, then finds nearest neighbour
16
+ by cosine similarity. Entirely self-contained in Redis β€” no Supabase needed.
17
+
18
+ Redis key design
19
+ semantic:index β†’ Redis Set β€” all embedding keys
20
+ semantic:emb:{hash} β†’ JSON {query, embedding, answer, metadata, timestamp}
21
+
22
+ Similarity threshold: 0.85
23
+ - 0.85+ β†’ same mathematical question, different words (safe to return)
24
+ - 0.70-0.85 β†’ related topic, probably different question (skip)
25
+ - <0.70 β†’ unrelated
26
+
27
+ Quota cost of embeddings
28
+ gemini-embedding-001 is NOT counted against the generate_content quota.
29
+ It has its own free tier: 1500 requests/day β€” far more than the 20/day
30
+ generate limit, so semantic lookup is essentially free to run.
31
+ """
32
+
33
+ import json
34
+ import logging
35
+ import hashlib
36
+ import time
37
+ import math
38
+ from typing import Optional, Dict, Any, List, Tuple
39
+
40
+ logger = logging.getLogger(__name__)
41
+
42
+ # ── Similarity threshold ───────────────────────────────────────────────────
43
+ # Tested against math paraphrase pairs. Lower = more aggressive matching.
44
+ SIMILARITY_THRESHOLD = 0.85
45
+
46
+ # Redis key prefixes
47
+ _PREFIX_EMB = "semantic:emb:" # stores embedding + answer
48
+ _INDEX_KEY = "semantic:index" # set of all embedding hashes
49
+ _TTL_SECONDS = 7 * 24 * 3600 # 7 days
50
+
51
+
52
+ def _cosine_similarity(a: List[float], b: List[float]) -> float:
53
+ """Pure-Python cosine similarity. No numpy needed."""
54
+ dot = sum(x * y for x, y in zip(a, b))
55
+ norm_a = math.sqrt(sum(x * x for x in a))
56
+ norm_b = math.sqrt(sum(x * x for x in b))
57
+ if norm_a == 0 or norm_b == 0:
58
+ return 0.0
59
+ return dot / (norm_a * norm_b)
60
+
61
+
62
+ def _normalize_query(query: str) -> str:
63
+ """
64
+ Light normalization before embedding.
65
+ Removes punctuation noise but keeps math symbols β€” '2+2' and '2 + 2'
66
+ should map to the same embedding region.
67
+ """
68
+ import re
69
+ q = query.lower().strip()
70
+ # collapse whitespace
71
+ q = re.sub(r"\s+", " ", q)
72
+ return q
73
+
74
+
75
+ class SemanticCache:
76
+ """
77
+ Semantic similarity cache backed by Redis.
78
+
79
+ Usage (in orchestrator):
80
+ sc = SemanticCache(redis_client, gemini_client)
81
+
82
+ # Lookup
83
+ result = sc.get(query)
84
+ if result:
85
+ return result["answer"]
86
+
87
+ # Store after getting answer from API
88
+ sc.set(query, answer_text, metadata)
89
+ """
90
+
91
+ def __init__(self, redis_client, gemini_api_key: str):
92
+ self.redis = redis_client
93
+ self._api_key = gemini_api_key
94
+ self._genai = None # lazy init
95
+
96
+ def _get_client(self):
97
+ """Lazy-init google.genai client so import errors are surfaced clearly."""
98
+ if self._genai is None:
99
+ try:
100
+ from google import genai
101
+ self._genai = genai.Client(api_key=self._api_key)
102
+ except Exception as e:
103
+ logger.error(f"SemanticCache: failed to init genai client: {e}")
104
+ raise
105
+ return self._genai
106
+
107
+ def _embed(self, text: str) -> Optional[List[float]]:
108
+ """
109
+ Generate embedding vector for text.
110
+ Uses gemini-embedding-001 (NOT counted against generate_content quota).
111
+ Returns None on failure so cache misses gracefully on API errors.
112
+ """
113
+ try:
114
+ from google.genai import types
115
+ client = self._get_client()
116
+ resp = client.models.embed_content(
117
+ model="models/gemini-embedding-001",
118
+ contents=_normalize_query(text),
119
+ config=types.EmbedContentConfig(output_dimensionality=768),
120
+ )
121
+ return resp.embeddings[0].values
122
+ except Exception as e:
123
+ logger.warning(f"SemanticCache: embedding failed: {e}")
124
+ return None
125
+
126
+ def _query_hash(self, query: str) -> str:
127
+ return hashlib.sha256(_normalize_query(query).encode()).hexdigest()[:16]
128
+
129
+ # ── Public API ─────────────────────────────────────────────────────────
130
+
131
+ def get(self, query: str) -> Optional[Dict[str, Any]]:
132
+ """
133
+ Look up a semantically similar cached answer.
134
+
135
+ Returns dict with keys: answer, metadata, source, similarity
136
+ Returns None on cache miss or any error.
137
+ """
138
+ if not self.redis:
139
+ return None
140
+
141
+ try:
142
+ # Get all stored embedding keys
143
+ keys = self.redis.smembers(_INDEX_KEY)
144
+ if not keys:
145
+ return None
146
+
147
+ # Embed the incoming query
148
+ query_vec = self._embed(query)
149
+ if query_vec is None:
150
+ return None
151
+
152
+ best_score = 0.0
153
+ best_entry = None
154
+
155
+ for key in keys:
156
+ raw = self.redis.get(f"{_PREFIX_EMB}{key}")
157
+ if not raw:
158
+ continue
159
+ try:
160
+ entry = json.loads(raw)
161
+ except json.JSONDecodeError:
162
+ continue
163
+
164
+ stored_vec = entry.get("embedding")
165
+ if not stored_vec:
166
+ continue
167
+
168
+ score = _cosine_similarity(query_vec, stored_vec)
169
+ if score > best_score:
170
+ best_score = score
171
+ best_entry = entry
172
+
173
+ if best_score >= SIMILARITY_THRESHOLD and best_entry:
174
+ logger.info(
175
+ f"SemanticCache HIT | similarity={best_score:.3f} | "
176
+ f"query='{query[:60]}' matched '{best_entry.get('query','')[:60]}'"
177
+ )
178
+ return {
179
+ "answer": best_entry["answer"],
180
+ "metadata": best_entry.get("metadata", {}),
181
+ "source": "semantic_cache",
182
+ "similarity": round(best_score, 3),
183
+ }
184
+
185
+ logger.debug(f"SemanticCache MISS | best_score={best_score:.3f} | query='{query[:60]}'")
186
+ return None
187
+
188
+ except Exception as e:
189
+ logger.error(f"SemanticCache.get failed: {e}")
190
+ return None
191
+
192
+ def set(self, query: str, answer: str, metadata: Optional[Dict] = None) -> bool:
193
+ """
194
+ Store a query+answer with its embedding vector.
195
+ Silent on failure β€” caching is best-effort.
196
+ """
197
+ if not self.redis or not answer:
198
+ return False
199
+
200
+ try:
201
+ embedding = self._embed(query)
202
+ if embedding is None:
203
+ return False
204
+
205
+ key = self._query_hash(query)
206
+ entry = {
207
+ "query": _normalize_query(query),
208
+ "answer": answer,
209
+ "metadata": metadata or {},
210
+ "embedding": embedding,
211
+ "timestamp": time.time(),
212
+ }
213
+ self.redis.setex(
214
+ f"{_PREFIX_EMB}{key}",
215
+ _TTL_SECONDS,
216
+ json.dumps(entry),
217
+ )
218
+ self.redis.sadd(_INDEX_KEY, key)
219
+ self.redis.expire(_INDEX_KEY, _TTL_SECONDS)
220
+
221
+ logger.info(f"SemanticCache SET | key={key} | query='{query[:60]}'")
222
+ return True
223
+
224
+ except Exception as e:
225
+ logger.error(f"SemanticCache.set failed: {e}")
226
+ return False
227
+
228
+ def invalidate(self, query: str) -> bool:
229
+ """Remove a specific entry (e.g. if answer was wrong)."""
230
+ try:
231
+ key = self._query_hash(query)
232
+ self.redis.delete(f"{_PREFIX_EMB}{key}")
233
+ self.redis.srem(_INDEX_KEY, key)
234
+ return True
235
+ except Exception as e:
236
+ logger.error(f"SemanticCache.invalidate failed: {e}")
237
+ return False
238
+
239
+ def stats(self) -> Dict[str, Any]:
240
+ """How many entries are cached."""
241
+ try:
242
+ count = self.redis.scard(_INDEX_KEY) if self.redis else 0
243
+ return {"entries": count, "threshold": SIMILARITY_THRESHOLD}
244
+ except Exception:
245
+ return {"entries": 0, "threshold": SIMILARITY_THRESHOLD}
app/services/automation.py CHANGED
@@ -1,6 +1,8 @@
1
  import logging
2
  import httpx
 
3
  from typing import Dict, Any, Optional
 
4
  from app.core.settings import settings
5
 
6
  logger = logging.getLogger(__name__)
@@ -14,6 +16,12 @@ class AutomationService:
14
  def __init__(self, webhook_url: Optional[str] = None):
15
  self.webhook_url = webhook_url or settings.N8N_WEBHOOK_URL
16
 
 
 
 
 
 
 
17
  async def trigger(self, event_name: str, payload: Dict[str, Any]) -> Dict[str, Any]:
18
  """
19
  Triggers an n8n workflow by sending a POST request to a webhook.
@@ -24,9 +32,11 @@ class AutomationService:
24
 
25
  try:
26
  # Add metadata to the payload
 
 
27
  data = {
28
  "event": event_name,
29
- "timestamp": settings.datetime.now().isoformat() if hasattr(settings, 'datetime') else None,
30
  "environment": settings.ENV,
31
  "data": payload
32
  }
@@ -43,11 +53,14 @@ class AutomationService:
43
  return {"status": "success", "response": response.json() if response.content else "OK"}
44
  else:
45
  logger.error(f"n8n automation failed with status {response.status_code}: {response.text}")
 
 
 
46
  return {"status": "error", "code": response.status_code, "detail": response.text}
47
 
48
  except Exception as e:
49
  logger.error(f"Error triggering n8n automation: {e}")
50
- return {"status": "error", "detail": str(e)}
51
 
52
  # Singleton instance
53
  automation_service = AutomationService()
 
1
  import logging
2
  import httpx
3
+ import asyncio
4
  from typing import Dict, Any, Optional
5
+ from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
6
  from app.core.settings import settings
7
 
8
  logger = logging.getLogger(__name__)
 
16
  def __init__(self, webhook_url: Optional[str] = None):
17
  self.webhook_url = webhook_url or settings.N8N_WEBHOOK_URL
18
 
19
+ @retry(
20
+ stop=stop_after_attempt(3),
21
+ wait=wait_exponential(multiplier=1, min=2, max=10),
22
+ retry=retry_if_exception_type((httpx.HTTPError, asyncio.TimeoutError)),
23
+ reraise=True
24
+ )
25
  async def trigger(self, event_name: str, payload: Dict[str, Any]) -> Dict[str, Any]:
26
  """
27
  Triggers an n8n workflow by sending a POST request to a webhook.
 
32
 
33
  try:
34
  # Add metadata to the payload
35
+ # Use datetime directly since settings.datetime might not exist reliably
36
+ from datetime import datetime
37
  data = {
38
  "event": event_name,
39
+ "timestamp": datetime.now().isoformat(),
40
  "environment": settings.ENV,
41
  "data": payload
42
  }
 
53
  return {"status": "success", "response": response.json() if response.content else "OK"}
54
  else:
55
  logger.error(f"n8n automation failed with status {response.status_code}: {response.text}")
56
+ # We raise here to trigger tenacity retry if it's a 5xx or transient
57
+ if 500 <= response.status_code < 600:
58
+ raise httpx.HTTPStatusError(f"Server Error {response.status_code}", request=None, response=response)
59
  return {"status": "error", "code": response.status_code, "detail": response.text}
60
 
61
  except Exception as e:
62
  logger.error(f"Error triggering n8n automation: {e}")
63
+ raise # Re-raise to let tenacity catch it and retry if it matches the types
64
 
65
  # Singleton instance
66
  automation_service = AutomationService()
app/tools/symbolic_solver.py DELETED
@@ -1,162 +0,0 @@
1
- import logging
2
- import os
3
- import asyncio
4
- from concurrent.futures import ThreadPoolExecutor
5
- from typing import Dict, Any, Optional, Union
6
- import sympy
7
- from sympy.parsing.sympy_parser import parse_expr
8
- from app.core.math_normalizer import MathIntent
9
- from app.core.settings import settings
10
-
11
-
12
-
13
- logger = logging.getLogger(__name__)
14
-
15
- class SymbolicSolver:
16
- """
17
- Tool for solving math problems symbolically.
18
- Prioritizes WolframAlpha (if AppID present), falls back to SymPy.
19
- """
20
-
21
- def __init__(self, wolfram_app_id: Optional[str] = None):
22
- self.wolfram_app_id = wolfram_app_id or settings.WOLFRAM_APP_ID
23
- logger.info(f"Initializing SymbolicSolver. WolframAppID present: {bool(self.wolfram_app_id)}")
24
-
25
- async def solve(self, query: Union[str, MathIntent]) -> Dict[str, Any]:
26
- """
27
- Attempts to solve the query symbolically.
28
- Accepts either a raw string (tried via Wolfram) or a structured MathIntent (for SymPy).
29
- """
30
- # Unwrap intent if passed
31
- intent = None
32
- raw_query = query
33
- if isinstance(query, MathIntent):
34
- intent = query
35
- raw_query = intent.original_query or intent.expression
36
-
37
- logger.info(f"SymbolicSolver triggered for query: {raw_query}")
38
-
39
- # 1. Try WolframAlpha (best for natural language or complex stuff)
40
- if self.wolfram_app_id:
41
- try:
42
- import httpx
43
- import urllib.parse
44
-
45
- # Construct URL manually to avoid library assertion errors
46
- # We request JSON output for easier parsing
47
- encoded_query = urllib.parse.quote(raw_query)
48
- url = f"https://api.wolframalpha.com/v2/query?appid={self.wolfram_app_id}&input={encoded_query}&output=json"
49
-
50
- async with httpx.AsyncClient() as client:
51
- response = await client.get(url, timeout=30.0)
52
-
53
- if response.status_code != 200:
54
- logger.warning(f"WolframAlpha API returned status {response.status_code}")
55
- else:
56
- data = response.json()
57
- query_result = data.get("queryresult", {})
58
-
59
- success = query_result.get("success")
60
- error = query_result.get("error")
61
-
62
- logger.info(f"Wolfram Response: success={success}, error={error}")
63
-
64
- if not success:
65
- logger.warning(f"Wolfram query returned success=false. Error: {error}")
66
- else:
67
- answer_text = ""
68
- pods = query_result.get("pods", [])
69
-
70
- for pod in pods:
71
- title = pod.get("title", "Result")
72
- for sub in pod.get("subpods", []):
73
- plaintext = sub.get("plaintext")
74
- if plaintext:
75
- answer_text += f"{title}: {plaintext}\n"
76
-
77
- if answer_text:
78
- return {
79
- "source": "wolfram_alpha",
80
- "content": answer_text,
81
- "status": "success"
82
- }
83
-
84
- except Exception as e:
85
- import traceback
86
- logger.warning(f"WolframAlpha query failed: {repr(e)}\n{traceback.format_exc()}")
87
-
88
- # 2. Try SymPy (Local Fallback)
89
- # We need a structured intent for SymPy to work reliably.
90
- # If we just got a string and Wolfram failed, we can't easily use SymPy
91
- # unless it was already normalized.
92
-
93
- if not intent:
94
- return {
95
- "source": "symbolic_solver",
96
- "error": "WolframAlpha failed and no structured MathIntent provided for SymPy.",
97
- "status": "error"
98
- }
99
-
100
- try:
101
- # Pre-processing for SymPy syntax
102
- # handle power operator ^ -> **
103
- expr_str = intent.expression.replace("^", "**")
104
-
105
- # handle implicit multiplication (simple regex)
106
- import re
107
- expr_str = re.sub(r'(\d)([a-z])', r'\1*\2', expr_str)
108
- expr_str = re.sub(r'\)\(', ')*(', expr_str)
109
-
110
- target_var = sympy.symbols(intent.variable or 'x')
111
- result_latex = ""
112
-
113
- if intent.intent == "derivative":
114
- expr = parse_expr(expr_str)
115
- res = sympy.diff(expr, target_var)
116
- result_latex = sympy.latex(res)
117
-
118
- elif intent.intent == "integral":
119
- expr = parse_expr(expr_str)
120
- res = sympy.integrate(expr, target_var)
121
- result_latex = sympy.latex(res)
122
-
123
- elif intent.intent == "equation":
124
- # Expecting "lhs = rhs" or just expression assumed = 0
125
- parts = expr_str.split("=")
126
- if len(parts) == 2:
127
- lhs = parse_expr(parts[0])
128
- rhs = parse_expr(parts[1])
129
- solution = sympy.solve(lhs - rhs, target_var)
130
- else:
131
- # Assume expr = 0
132
- expr = parse_expr(expr_str)
133
- solution = sympy.solve(expr, target_var)
134
-
135
- result_latex = sympy.latex(solution)
136
-
137
- elif intent.intent == "limit":
138
- # TODO: Parsing limits needs 'approaches' value, logic not fully here yet
139
- # Fallback implementation
140
- return {"source": "symbolic_solver", "status": "error", "error": "Limit parsing not fully implemented"}
141
-
142
- elif intent.intent == "arithmetic" or intent.intent == "simplification":
143
- expr = parse_expr(expr_str)
144
- res = sympy.simplify(expr)
145
- result_latex = sympy.latex(res)
146
-
147
- else:
148
- return {"source": "symbolic_solver", "status": "error", "error": f"Unknown intent: {intent.intent}"}
149
-
150
- return {
151
- "source": "sympy_local",
152
- "content": result_latex,
153
- "status": "success"
154
- }
155
-
156
- except Exception as e:
157
- logger.warning(f"SymPy execution failed: {e}")
158
- return {
159
- "source": "symbolic_solver",
160
- "error": str(e),
161
- "status": "error"
162
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
check_agent.py DELETED
@@ -1,12 +0,0 @@
1
-
2
- try:
3
- from google.adk.agents import Agent
4
- print("Agent class found in google.adk.agents")
5
- except ImportError:
6
- print("Agent class NOT found in google.adk.agents")
7
-
8
- try:
9
- from google.adk.agents import LlmAgent
10
- print("LlmAgent class found in google.adk.agents")
11
- except ImportError:
12
- print("LlmAgent class NOT found in google.adk.agents")
 
 
 
 
 
 
 
 
 
 
 
 
 
check_redis.py DELETED
@@ -1,18 +0,0 @@
1
- import redis
2
- import os
3
- from dotenv import load_dotenv
4
-
5
- load_dotenv()
6
- redis_url = os.getenv("REDIS_URL", "redis://localhost:6379/0")
7
-
8
- def check_redis():
9
- print(f"Checking Redis at: {redis_url}")
10
- try:
11
- r = redis.from_url(redis_url)
12
- r.ping()
13
- print("βœ… Redis is UP!")
14
- except Exception as e:
15
- print(f"❌ Redis is DOWN or unreachable: {e}")
16
-
17
- if __name__ == "__main__":
18
- check_redis()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
db_diag.py DELETED
@@ -1,21 +0,0 @@
1
- import os
2
- from pymongo import MongoClient
3
- from dotenv import load_dotenv
4
-
5
- load_dotenv()
6
-
7
- mongo_uri = os.getenv("MONGO_URI")
8
- client = MongoClient(mongo_uri)
9
- db = client.mathminds_db
10
- # FIXED COLLECTION NAME
11
- sessions = db.chat_sessions
12
-
13
- print("LAST 3 SESSIONS:")
14
- for s in sessions.find().sort("created_at", -1).limit(3):
15
- print(f"Session: {s.get('session_id')} | User: {s.get('user_id')}")
16
- print(f"Title: {s.get('title')}")
17
- msgs = s.get("messages", [])
18
- print(f"Messages Count: {len(msgs)}")
19
- for m in msgs[-10:]:
20
- print(f" [{m.get('role')}] {m.get('content')[:100]} (RID: {m.get('request_id')})")
21
- print("-" * 20)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
debug_adk.py DELETED
@@ -1,23 +0,0 @@
1
-
2
- import sys
3
- try:
4
- import google
5
- print("google imported")
6
- print(dir(google))
7
-
8
- try:
9
- import google.adk
10
- print("google.adk imported")
11
- print(dir(google.adk))
12
- except ImportError as e:
13
- print(f"Failed to import google.adk: {e}")
14
-
15
- try:
16
- from google import adk
17
- print("from google import adk succeeded")
18
- print(dir(adk))
19
- except ImportError as e:
20
- print(f"Failed to from google import adk: {e}")
21
-
22
- except ImportError as e:
23
- print(f"Failed to import google: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
debug_adk_events.py DELETED
@@ -1,91 +0,0 @@
1
- """
2
- Run this script STANDALONE β€” no FastAPI needed.
3
- It directly invokes the ADK agent and prints every single event it emits,
4
- so we can see exactly what is_final_response() returns and what text we get.
5
-
6
- Usage:
7
- cd E:\madhuri\mathminds
8
- python debug_adk_events.py
9
- """
10
-
11
- import asyncio
12
- import sys
13
- import os
14
- sys.path.insert(0, os.getcwd())
15
-
16
- from google.adk.agents import Agent
17
- from google.adk.runners import Runner
18
- from google.adk.sessions.in_memory_session_service import InMemorySessionService
19
- from google.genai import types
20
- from dotenv import load_dotenv
21
- load_dotenv()
22
-
23
- QUESTION = "what is 9 + 8"
24
-
25
- async def main():
26
- from app.core.settings import settings
27
-
28
- agent = Agent(
29
- name="math_minds_core",
30
- model="gemini-2.5-flash",
31
- tools=[],
32
- instruction="You are a math assistant. Answer concisely."
33
- )
34
-
35
- session_service = InMemorySessionService()
36
- runner = Runner(
37
- app_name="mathminds_debug",
38
- agent=agent,
39
- session_service=session_service
40
- )
41
-
42
- await session_service.create_session(
43
- app_name="mathminds_debug",
44
- user_id="debug_user",
45
- session_id="debug_session"
46
- )
47
-
48
- print(f"\nQuestion: {QUESTION}\n{'='*60}")
49
-
50
- all_text = ""
51
- final_text = ""
52
- event_num = 0
53
-
54
- async for event in runner.run_async(
55
- user_id="debug_user",
56
- session_id="debug_session",
57
- new_message=types.Content(role="user", parts=[types.Part.from_text(text=QUESTION)])
58
- ):
59
- event_num += 1
60
- event_type = type(event).__name__
61
- author = getattr(event, "author", "N/A")
62
-
63
- # Check is_final_response
64
- has_ifr = hasattr(event, "is_final_response") and callable(event.is_final_response)
65
- is_final = event.is_final_response() if has_ifr else "method missing"
66
-
67
- print(f"\n[Event #{event_num}]")
68
- print(f" type : {event_type}")
69
- print(f" author : {author}")
70
- print(f" is_final_response : {is_final}")
71
- print(f" has content : {bool(event.content)}")
72
-
73
- if event.content and event.content.parts:
74
- for i, part in enumerate(event.content.parts):
75
- print(f" part[{i}].text : {repr(part.text)}")
76
- print(f" part[{i}].function_call : {bool(getattr(part, 'function_call', None))}")
77
- print(f" part[{i}].function_resp : {bool(getattr(part, 'function_response', None))}")
78
- if part.text:
79
- all_text += part.text
80
- if is_final is True:
81
- final_text += part.text
82
- if author == "math_minds_core":
83
- final_text += part.text
84
-
85
- print(f"\n{'='*60}")
86
- print(f"Total events : {event_num}")
87
- print(f"all_text (fallback): {repr(all_text)}")
88
- print(f"final_text : {repr(final_text)}")
89
- print(f"RESULT WOULD BE : {repr((final_text or all_text).strip())}")
90
-
91
- asyncio.run(main())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
debug_celery_worker.py DELETED
@@ -1,49 +0,0 @@
1
- import asyncio
2
- import logging
3
- import sys
4
- import os
5
-
6
- # Add the current directory to sys.path so we can import 'app'
7
- sys.path.append(os.getcwd())
8
-
9
- from app.worker.tasks import scrape_task
10
- import time
11
-
12
- logging.basicConfig(level=logging.INFO)
13
- logger = logging.getLogger(__name__)
14
-
15
- async def debug_scrape():
16
- print("Triggering Celery Scrape Task...")
17
- query = "gold rate in india today"
18
-
19
- try:
20
- # Dispatch task
21
- result = scrape_task.delay(query)
22
- print(f"Task ID: {result.id}")
23
-
24
- # Wait for result
25
- start_time = time.time()
26
- max_wait = 60 # seconds
27
-
28
- while time.time() - start_time < max_wait:
29
- if result.ready():
30
- print("Task Ready!")
31
- print("Result Status:", result.status)
32
- # Safely handle potential encoding issues when printing to console
33
- try:
34
- res_content = str(result.result)
35
- print("Result Content (partial):", res_content[:200].encode('ascii', 'ignore').decode('ascii'))
36
- except Exception as e:
37
- print(f"Result received, but print failed: {e}")
38
- return
39
-
40
- print(f"Waiting... (status: {result.status})")
41
- await asyncio.sleep(2)
42
-
43
- print("Task timed out. Is the worker running?")
44
-
45
- except Exception as e:
46
- print(f"Dispatch failed: {e}")
47
-
48
- if __name__ == "__main__":
49
- asyncio.run(debug_scrape())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
debug_env.py DELETED
@@ -1,23 +0,0 @@
1
-
2
- import sys
3
- import os
4
-
5
- print(f"Python Executable: {sys.executable}")
6
- print(f"Python Version: {sys.version}")
7
- print(f"Sys Path: {sys.path}")
8
-
9
- try:
10
- import langchain
11
- print(f"LangChain Version: {langchain.__version__}")
12
- print(f"LangChain Path: {langchain.__file__}")
13
- except ImportError as e:
14
- print(f"ImportError: {e}")
15
- except Exception as e:
16
- print(f"Error: {e}")
17
-
18
- # Verify Agent Import
19
- try:
20
- from app.agents.langchain_mathminds import MathMindsLangChainAgent
21
- print("βœ… MathMindsLangChainAgent imported.")
22
- except ImportError as e:
23
- print(f"Agent Import Failed: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
debug_history.py DELETED
@@ -1,15 +0,0 @@
1
-
2
- import json
3
- try:
4
- with open('chat_history.json') as f:
5
- data = json.load(f)
6
- last_session_id = sorted(data.keys(), key=lambda k: data[k].get('created_at', 0))[-1]
7
- print(f"Session: {last_session_id}")
8
- messages = data[last_session_id]['messages']
9
- print(f"Total messages: {len(messages)}")
10
- for i, m in enumerate(messages):
11
- print(f"Index {i}: Role: {m['role']}")
12
- print(f" Sent to API: {m.get('sent_to_api')}")
13
- print(f" Content: {repr(m['content'])[:100]}...")
14
- except Exception as e:
15
- print(f"Error: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
debug_history_all.py DELETED
@@ -1,13 +0,0 @@
1
-
2
- import json
3
- try:
4
- with open('chat_history.json') as f:
5
- data = json.load(f)
6
- print(f"Total sessions: {len(data)}")
7
- for sid, sess in data.items():
8
- print(f"Session {sid}: {sess.get('title', 'Untitled')} ({len(sess['messages'])} msgs)")
9
- for m in sess['messages']:
10
- if m['role'] == 'assistant':
11
- print(f" [FOUND ASSISTANT MSG in {sid}] Content: {repr(m['content'])[:50]}")
12
- except Exception as e:
13
- print(f"Error: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
debug_import.py DELETED
@@ -1,21 +0,0 @@
1
-
2
- print("Start")
3
- try:
4
- import app.tools.web_scraper
5
- print("Imported WebScraper")
6
- except Exception as e:
7
- print(f"Failed WebScraper: {e}")
8
-
9
- try:
10
- import app.tools.vision_analyzer
11
- print("Imported VisionAnalyzer")
12
- except Exception as e:
13
- print(f"Failed VisionAnalyzer: {e}")
14
-
15
- try:
16
- from app.core.orchestrator import Orchestrator
17
- print("Imported Orchestrator")
18
- o = Orchestrator()
19
- print("Instantiated Orchestrator")
20
- except Exception as e:
21
- print(f"Failed Orchestrator: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
debug_models.py DELETED
@@ -1,30 +0,0 @@
1
- import os
2
- import asyncio
3
- from google import genai
4
- from dotenv import load_dotenv
5
-
6
- load_dotenv()
7
-
8
- async def list_models():
9
- api_key = os.getenv("GOOGLE_API_KEY")
10
- if not api_key:
11
- print("Error: GOOGLE_API_KEY not found.")
12
- return
13
-
14
- client = genai.Client(api_key=api_key)
15
-
16
- print("Listing available models...")
17
- try:
18
- # Pager object, need to iterate
19
- pager = client.models.list()
20
- for model in pager:
21
- print(f"Name: {model.name}")
22
- print(f" DisplayName: {model.display_name}")
23
- print(f" Supported Actions: {model.supported_actions}")
24
- print("-" * 20)
25
-
26
- except Exception as e:
27
- print(f"Error listing models: {e}")
28
-
29
- if __name__ == "__main__":
30
- asyncio.run(list_models())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
debug_response.py DELETED
@@ -1,50 +0,0 @@
1
- """
2
- Run this script while your backend is running.
3
- It bypasses the frontend completely and shows you EXACTLY what the API returns.
4
-
5
- Usage:
6
- cd E:\madhuri\mathminds
7
- python debug_response.py
8
-
9
- Replace TOKEN and QUESTION below.
10
- """
11
-
12
- import requests
13
- import json
14
-
15
- # ── CONFIG ────────────────────────────────────────────────────────────────────
16
- API_URL = "http://localhost:8000/solve"
17
- TOKEN = "PASTE_YOUR_FIREBASE_TOKEN_HERE" # grab from browser devtools
18
- QUESTION = "what is 9 + 8"
19
- # ─────────────────────────────────────────────────────────────────────────────
20
-
21
- headers = {"Authorization": f"Bearer {TOKEN}"}
22
- payload = {
23
- "text": QUESTION,
24
- "model_preference": "agent",
25
- "session_id": "debug-session-001",
26
- "request_id": "debug-req-001",
27
- }
28
-
29
- print(f"\n{'='*60}")
30
- print(f"POST {API_URL}")
31
- print(f"Question: {QUESTION}")
32
- print(f"{'='*60}\n")
33
-
34
- try:
35
- r = requests.post(API_URL, json=payload, headers=headers, timeout=120)
36
- print(f"HTTP Status: {r.status_code}")
37
- print(f"\nFull Response JSON:")
38
- data = r.json()
39
- print(json.dumps(data, indent=2))
40
-
41
- print(f"\n{'='*60}")
42
- print(f"status : {data.get('status')}")
43
- print(f"answer : {repr(data.get('answer'))}")
44
- print(f"source : {data.get('source')}")
45
- print(f"explain : {repr(data.get('explanation'))}")
46
- print(f"error : {data.get('error')}")
47
- print(f"{'='*60}\n")
48
-
49
- except Exception as e:
50
- print(f"Request failed: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
debug_scraper.py DELETED
@@ -1,21 +0,0 @@
1
- from app.tools.web_scraper import run_playwright_sync
2
-
3
- query = "calculate the price of 2 kg gold according to todays gold rate"
4
- print(f"Running scraper for: {query}")
5
-
6
- result = run_playwright_sync(query, headless=True)
7
-
8
- print("\n--- STATUS ---")
9
- print(result.get("status"))
10
-
11
- print("\n--- CONTENT SNIPPET ---")
12
- content = result.get("content", "")
13
- # Print first 5000 chars to be sure we see the body
14
- print(content[:5000])
15
-
16
- if "unusual traffic" in content.lower() or "captcha" in content.lower():
17
- print("\n[!] DETECTED CAPTCHA/BLOCKING")
18
- elif "Gold Rate" in content or "Silver Rate" in content:
19
- print("\n[+] SUCCESS: Found Gold Rate related content")
20
- else:
21
- print("\n[?] Content unclear. Check snippet above.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
debug_scraper_manual.py DELETED
@@ -1,41 +0,0 @@
1
- import asyncio
2
- import sys
3
- import os
4
-
5
- # Add project root to path
6
- sys.path.append(os.getcwd())
7
-
8
- from app.tools.web_scraper import WebScraper
9
-
10
- async def main():
11
- print("Initializing WebScraper...")
12
- scraper = WebScraper(headless=True)
13
-
14
- print("\n--- Test 1: Generic Search (Yahoo Finance via Logic) ---")
15
- # Logic in scraper: if "stock" in query -> yahoo finance
16
- query1 = "stock price of apple"
17
- print(f"Query: {query1}")
18
- result1 = await scraper.scrape(query1)
19
- print(f"Status: {result1.get('status')}")
20
- if result1.get('error'):
21
- print(f"Error: {result1.get('error')}")
22
- else:
23
- content = result1.get('content', '')
24
- print(f"Content Length: {len(content)}")
25
- print(f"Preview: {content[:200]}...")
26
-
27
- print("\n--- Test 2: Gold Rate (Goodreturns via Logic) ---")
28
- # Logic in scraper: if "gold" and "rate" -> goodreturns
29
- query2 = "gold rate today"
30
- print(f"Query: {query2}")
31
- result2 = await scraper.scrape(query2)
32
- print(f"Status: {result2.get('status')}")
33
- if result2.get('error'):
34
- print(f"Error: {result2.get('error')}")
35
- else:
36
- content = result2.get('content', '')
37
- print(f"Content Length: {len(content)}")
38
- print(f"Preview: {content[:200]}...")
39
-
40
- if __name__ == "__main__":
41
- asyncio.run(main())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
debug_ui_v2.py DELETED
@@ -1,30 +0,0 @@
1
-
2
- import json
3
- import os
4
-
5
- HISTORY_FILE = 'chat_history.json'
6
-
7
- def debug():
8
- if not os.path.exists(HISTORY_FILE):
9
- print("File not found")
10
- return
11
-
12
- try:
13
- with open(HISTORY_FILE, "r", encoding="utf-8") as f:
14
- data = json.load(f)
15
-
16
- last_sid = sorted(data.keys(), key=lambda k: data[k].get('created_at', 0))[-1]
17
- sess = data[last_sid]
18
- print(f"Session: {last_sid} (Title: {sess.get('title')})")
19
- print(f"Total messages: {len(sess['messages'])}")
20
-
21
- for i, m in enumerate(sess['messages']):
22
- print(f"[{i}] {m['role'].upper()}: {repr(m['content'])[:80]}...")
23
- if 'metadata' in m:
24
- print(f" Metadata keys: {list(m['metadata'].keys())}")
25
-
26
- except Exception as e:
27
- print(f"CRITICAL ERROR reading history: {e}")
28
-
29
- if __name__ == "__main__":
30
- debug()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
find_embedding_models.py DELETED
@@ -1,11 +0,0 @@
1
- import os
2
- from dotenv import load_dotenv
3
- import google.generativeai as genai
4
-
5
- load_dotenv()
6
- genai.configure(api_key=os.getenv("GOOGLE_API_KEY"))
7
-
8
- print("Available models:")
9
- for model in genai.list_models():
10
- if "gemini" in model.name:
11
- print(f" {model.name:50} {model.display_name}")
 
 
 
 
 
 
 
 
 
 
 
 
frontend/app.py CHANGED
@@ -1,61 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import streamlit as st
2
  import requests
3
- import json
4
  import base64
5
- from PIL import Image
6
  import io
7
  import os
8
- import uuid
9
  import time
 
 
10
  from streamlit_drawable_canvas import st_canvas
11
  from dotenv import load_dotenv
12
  from firebase_utils import sign_in_with_email, sign_up_with_email
13
 
 
 
14
  load_dotenv()
15
 
16
- # ── Session state: ALL keys initialized ONCE at the very top ─────────────────
17
- # CRITICAL: These must be the very first st.session_state accesses, before any
18
- # st.* UI calls. Streamlit re-runs the entire script on every interaction.
19
- if "is_processing" not in st.session_state:
20
- st.session_state.is_processing = False
21
- if "user" not in st.session_state:
22
- st.session_state.user = None # None = logged out
23
- if "current_view" not in st.session_state:
24
- st.session_state.current_view = "Chat"
25
-
26
- # MULTIUSER FIX ─ these three keys must be RESET on logout.
27
- # They are initialized here so first-run doesn't KeyError.
28
- if "chat_sessions" not in st.session_state:
29
- st.session_state.chat_sessions = []
30
- if "active_session_id" not in st.session_state:
31
- st.session_state.active_session_id = None
32
- if "messages" not in st.session_state:
33
- st.session_state.messages = []
34
-
35
- # MULTIUSER FIX ─ track WHICH user's data is currently loaded.
36
- # If this doesn't match st.session_state.user["uid"], we know we need to reload.
37
- if "loaded_for_user" not in st.session_state:
38
- st.session_state.loaded_for_user = None
39
-
40
- if "renaming_session_id" not in st.session_state:
41
- st.session_state.renaming_session_id = None
42
-
43
- if "canvas_key" not in st.session_state:
44
- st.session_state.canvas_key = "main_canvas"
45
-
46
- # ====================================================
47
- # Page Config β€” must come before any st.* calls
48
- # ====================================================
49
  st.set_page_config(
50
  page_title="MathMinds AI",
51
  page_icon="🧠",
52
  layout="wide",
53
- initial_sidebar_state="expanded"
54
  )
55
 
56
- # ====================================================
57
- # Premium Global Styling
58
- # ====================================================
59
  st.markdown("""
60
  <style>
61
  .stApp {
@@ -84,19 +93,15 @@ st.markdown("""
84
  }
85
  h1, h2, h3 { color: #f3f4f6; letter-spacing: -0.5px; }
86
  p, li { color: #e5e7eb; line-height: 1.6; }
87
- .canvas-container {
88
- border-radius: 12px; overflow: hidden;
89
- border: 2px solid rgba(99, 102, 241, 0.3);
90
- box-shadow: 0 0 20px rgba(99, 102, 241, 0.1);
91
- }
92
  .badge {
93
  display: inline-flex; align-items: center;
94
  padding: 0.25rem 0.75rem; border-radius: 9999px;
95
  font-size: 0.75rem; font-weight: 600; margin-right: 0.5rem;
96
  }
97
- .badge-blue { background: rgba(59,130,246,0.2); color: #93c5fd; border: 1px solid rgba(59,130,246,0.3); }
98
- .badge-purple{ background: rgba(168,85,247,0.2); color: #d8b4fe; border: 1px solid rgba(168,85,247,0.3); }
99
- .badge-green { background: rgba(34,197,94,0.2); color: #86efac; border: 1px solid rgba(34,197,94,0.3); }
 
100
  button[kind="primary"] {
101
  background: linear-gradient(to right, #4f46e5, #7c3aed);
102
  border: none; box-shadow: 0 4px 6px -1px rgba(79,70,229,0.3); transition: all 0.2s;
@@ -105,648 +110,610 @@ st.markdown("""
105
  </style>
106
  """, unsafe_allow_html=True)
107
 
108
- # ====================================================
109
- # Config
110
- # ====================================================
111
- BACKEND_URL = os.getenv("BACKEND_URL", "http://localhost:8000")
112
- API_URL = f"{BACKEND_URL}/solve"
113
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
 
115
- # ====================================================
116
- # MULTIUSER ISOLATION β€” Core helper
117
- # ====================================================
118
- def _clear_user_state():
119
- """
120
- Wipe ALL per-user data from Streamlit session state.
121
 
122
- Called on logout and whenever a different user logs in.
 
 
 
 
 
 
 
123
 
124
- WHY THIS IS THE MOST IMPORTANT FUNCTION FOR MULTIUSER ISOLATION:
125
- Streamlit's st.session_state is per browser-tab, not per user. If User A
126
- logs in, chats, then User B logs in on the same tab, all of User A's
127
- chat_sessions and messages are still sitting in st.session_state. The
128
- backend correctly refuses to serve User A's data to User B (every DB query
129
- filters by user_id), but the frontend would still DISPLAY User A's messages
130
- briefly until the next API call returns. This function prevents that.
131
- """
132
- st.session_state.chat_sessions = []
133
- st.session_state.active_session_id = None
134
- st.session_state.messages = []
135
- st.session_state.loaded_for_user = None
136
- st.session_state.is_processing = False
137
- st.session_state.current_view = "Chat"
138
- st.session_state.renaming_session_id = None
139
- st.session_state.canvas_key = f"canvas_{uuid.uuid4()}"
140
- # Also clear profile cache if it exists
141
- if "profile_data" in st.session_state:
142
- del st.session_state["profile_data"]
143
-
144
-
145
- # ====================================================
146
- # Helper Functions
147
- # ====================================================
148
- def get_auth_headers():
149
- if st.session_state.user and "token" in st.session_state.user:
150
- return {"Authorization": f"Bearer {st.session_state.user['token']}"}
151
- return {}
152
-
153
-
154
- def load_sessions():
155
- """Fetch THIS user's chat sessions from the backend and populate state."""
156
  try:
157
- headers = get_auth_headers()
158
- try:
159
- response = requests.get(f"{BACKEND_URL}/chat/sessions", headers=headers, timeout=10)
160
- except requests.exceptions.ConnectionError:
161
- st.info("βŒ› **MathMinds API is warming up...** Please wait a few seconds.")
162
- st.stop()
163
-
164
- if response.status_code == 200:
165
- st.session_state.chat_sessions = response.json()
166
- # Mark that we've successfully loaded data for this specific user
167
- if st.session_state.user:
168
- st.session_state.loaded_for_user = st.session_state.user["uid"]
169
- # Auto-select first session if none active
170
- if not st.session_state.active_session_id and st.session_state.chat_sessions:
171
- st.session_state.active_session_id = st.session_state.chat_sessions[0]["session_id"]
172
- load_messages(st.session_state.active_session_id)
173
- elif st.session_state.active_session_id and not any(
174
- s["session_id"] == st.session_state.active_session_id
175
- for s in st.session_state.chat_sessions
176
- ):
177
- # Active session was deleted β€” pick first or clear
178
- if st.session_state.chat_sessions:
179
- st.session_state.active_session_id = st.session_state.chat_sessions[0]["session_id"]
180
- load_messages(st.session_state.active_session_id)
181
- else:
182
- st.session_state.active_session_id = None
183
- st.session_state.messages = []
184
- elif response.status_code == 401:
185
- # JWT expired β€” force re-login
186
- _clear_user_state()
187
- st.session_state.user = None
188
- st.error("Session expired. Please log in again.")
189
- else:
190
- st.error(f"Failed to load sessions: {response.status_code}")
191
- st.session_state.chat_sessions = []
192
  except Exception as e:
193
- st.error(f"Error loading sessions: {e}")
194
- st.session_state.chat_sessions = []
195
 
196
 
197
- def load_messages(session_id):
198
- """
199
- Load messages for a session.
200
- The backend enforces user ownership β€” it will 404 if session_id
201
- doesn't belong to the authenticated user, so this is safe.
202
- """
203
  try:
204
- headers = get_auth_headers()
205
- response = requests.get(
206
  f"{BACKEND_URL}/chat/sessions/{session_id}/messages",
207
- headers=headers, timeout=30
208
  )
209
- if response.status_code == 200:
210
- server_messages = response.json()
211
- local_messages = st.session_state.get("messages", [])
212
-
213
- # βœ… INDESTRUCTIBLE MERGE LOGIC
214
- # 1. Start with server messages as the definitive baseline.
215
- merged = []
216
- server_keys = set()
217
- for m in server_messages:
218
- merged.append(m)
219
- rid = m.get("request_id")
220
- role = m.get("role")
221
- if rid and role:
222
- server_keys.add((role, rid))
223
-
224
- # 2. Append local messages that have NOT yet reached the server.
225
- # This protects local "optimistic" messages from vanishing if DB is slow.
226
- for lm in local_messages:
227
- rid = lm.get("request_id")
228
- role = lm.get("role")
229
- if rid and role:
230
- if (role, rid) not in server_keys:
231
- merged.append(lm)
232
- elif not rid:
233
- # Fallback for messages without IDs (should be rare)
234
- content_prefix = str(lm.get("content", ""))[:50]
235
- if not any(str(sm.get("content", "")).startswith(content_prefix) for sm in server_messages):
236
- merged.append(lm)
237
-
238
- st.session_state.messages = merged
239
- elif response.status_code == 404:
240
- # Session doesn't belong to this user β€” clear silently
241
- st.session_state.messages = []
242
- st.session_state.active_session_id = None
243
- st.warning("Session not found.")
244
- else:
245
- st.session_state.messages = []
246
- st.error(f"Failed to load messages: {response.status_code}")
247
  except Exception as e:
248
- logger.error(f"Error loading messages: {e}")
249
- st.error(f"Error loading messages: {e}")
250
- st.session_state.messages = []
251
-
252
-
253
- def get_active_session():
254
- for s in st.session_state.chat_sessions:
255
- if s["session_id"] == st.session_state.active_session_id:
256
- return s
257
- return None
258
-
259
-
260
- def add_message(role, content, sent_to_api=False, request_id=None, **kwargs):
261
- """Optimistic UI update only β€” persistence happens in the backend via /solve."""
262
- msg = {
263
- "role": role,
264
- "content": content,
265
- "timestamp": time.time(),
266
- "sent_to_api": sent_to_api,
267
- "request_id": request_id
268
- }
269
- msg.update(kwargs)
270
- st.session_state.messages.append(msg)
271
 
272
 
273
- def new_chat():
274
  try:
275
- headers = get_auth_headers()
276
- response = requests.post(f"{BACKEND_URL}/chat/sessions", headers=headers, timeout=30)
277
- if response.status_code == 200:
278
- new_s = response.json()
279
- st.session_state.active_session_id = new_s["session_id"]
280
- st.session_state.messages = []
281
- load_sessions()
282
- st.rerun()
283
- else:
284
- st.error("Failed to create new chat")
285
  except Exception as e:
286
- st.error(f"Error: {e}")
287
 
288
 
289
- def delete_chat(sid):
290
  try:
291
- headers = get_auth_headers()
292
- response = requests.delete(f"{BACKEND_URL}/chat/sessions/{sid}", headers=headers, timeout=30)
293
- if response.status_code == 200:
294
- if st.session_state.active_session_id == sid:
295
- st.session_state.active_session_id = None
296
- st.session_state.messages = []
297
- load_sessions()
298
- st.rerun()
299
- else:
300
- st.error("Failed to delete chat")
301
  except Exception as e:
302
- st.error(f"Error: {e}")
303
 
304
 
305
- def rename_chat(sid, new_title):
306
  try:
307
- headers = get_auth_headers()
308
- response = requests.patch(
309
  f"{BACKEND_URL}/chat/sessions/{sid}",
310
- headers=headers, json={"title": new_title}, timeout=30
311
  )
312
- if response.status_code == 200:
313
- load_sessions()
314
- st.rerun()
315
- else:
316
- st.error("Failed to rename chat")
317
  except Exception as e:
318
- st.error(f"Error: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
319
 
320
 
321
- # ====================================================
322
- # Login Screen
323
- # ====================================================
324
- def login_screen():
325
- c1, c2, c3 = st.columns([1, 2, 1])
326
- with c2:
327
- st.write("")
328
- st.write("")
329
  st.markdown("""
330
- <div style="text-align:center;padding:4rem;background:rgba(255,255,255,0.05);border-radius:20px;border:1px solid rgba(255,255,255,0.1);">
331
- <h1>🧠 MathMinds AI</h1>
332
- <p style="color:#9ca3af;">Your intelligent quantitative assistant.</p>
 
333
  </div>
334
  """, unsafe_allow_html=True)
335
 
336
- tab_login, tab_signup = st.tabs(["Login", "Sign Up"])
337
 
338
- with tab_login:
339
  with st.form("login_form"):
340
- email = st.text_input("Email", placeholder="student@university.edu")
341
  password = st.text_input("Password", type="password")
342
- if st.form_submit_button("Sign In", use_container_width=True):
343
  if email and password:
344
- try:
345
- token, uid, user_email, error = sign_in_with_email(email, password)
346
- if token:
347
- # βœ… MULTIUSER FIX: Clear ALL previous user data
348
- _clear_user_state()
349
- st.session_state.user = {
350
- "email": user_email,
351
- "token": token,
352
- "uid": uid
353
- }
354
- st.success(f"Welcome back, {user_email}!")
355
- time.sleep(0.5)
356
- st.rerun()
357
- else:
358
- st.error(f"Login Failed: {error}")
359
- except Exception as e:
360
- st.error(f"Connection Error: {e}")
361
  else:
362
  st.error("Please enter email and password.")
363
 
364
- with tab_signup:
365
  with st.form("signup_form"):
366
- new_email = st.text_input("New Email", placeholder="new@student.edu")
367
- new_password = st.text_input("New Password", type="password")
368
- confirm_password = st.text_input("Confirm Password", type="password")
369
- full_name = st.text_input("Full Name", placeholder="Optional")
370
- if st.form_submit_button("Create Account", use_container_width=True):
371
- if new_email and new_password:
372
- if new_password != confirm_password:
373
- st.error("Passwords do not match!")
374
  else:
375
- try:
376
- token, uid, user_email, error = sign_up_with_email(new_email, new_password)
377
- if token:
378
- # βœ… MULTIUSER FIX: Same as login β€” clear first
379
- _clear_user_state()
380
- st.session_state.user = {
381
- "email": user_email,
382
- "token": token,
383
- "uid": uid
384
- }
385
- st.success(f"Account Created! Welcome, {user_email}!")
386
- time.sleep(0.5)
387
- st.rerun()
388
- else:
389
- st.error(f"Sign Up Failed: {error}")
390
- except Exception as e:
391
- st.error(f"Connection Error: {e}")
392
  else:
393
  st.error("Please fill all fields.")
394
 
395
  st.markdown(
396
  "<p style='text-align:center;font-size:0.8rem;color:#6b7280;'>Powered by Gemini & SymPy</p>",
397
- unsafe_allow_html=True
398
  )
399
 
400
 
401
- # ── Auth gate ─────────────────────────────────────────────────────────────────
402
- if not st.session_state.user:
403
- login_screen()
404
- st.stop()
405
-
406
- # ====================================================
407
- # βœ… MULTIUSER FIX β€” Per-rerun data isolation check
408
- # ====================================================
409
- # At this point we know a user IS logged in.
410
- # Check: is the data currently in state actually for THIS user?
411
- # This handles the scenario where User A's browser tab is reused by User B
412
- # (e.g. token swap, shared kiosk, etc.) without a full page reload.
413
- _current_uid = st.session_state.user["uid"]
414
- if st.session_state.loaded_for_user != _current_uid:
415
- # Data in state belongs to a different user (or nobody) β€” reload for current user
416
- _clear_user_state()
417
- load_sessions()
418
- # loaded_for_user is set inside load_sessions() on success
419
-
420
-
421
- # ====================================================
422
- # Profile Interface
423
- # ====================================================
424
- def profile_interface():
425
  st.title("πŸ‘€ User Profile")
426
- st.markdown("Customize your MathMinds experience.")
427
- headers = get_auth_headers()
428
-
429
  if "profile_data" not in st.session_state:
430
  try:
431
- r = requests.get(f"{BACKEND_URL}/users/profile", headers=headers, timeout=30)
432
  st.session_state.profile_data = r.json() if r.status_code == 200 else {}
433
  except Exception:
434
  st.session_state.profile_data = {}
435
 
436
- data = st.session_state.profile_data
437
- levels = ["High School", "Undergraduate", "Graduate", "Researcher"]
438
  interests_all = ["Algebra", "Calculus", "Geometry", "Statistics", "Physics", "Computer Science", "Finance"]
439
 
440
  with st.form("profile_form"):
441
  display_name = st.text_input("Display Name", value=data.get("display_name", ""))
442
  math_level = st.selectbox(
443
- "Math Proficiency Level", levels,
444
- index=levels.index(data.get("math_level", "Undergraduate"))
445
- if data.get("math_level") in levels else 1
446
  )
447
  interests = st.multiselect(
448
- "Areas of Interest", interests_all,
449
- default=[i for i in data.get("interests", []) if i in interests_all]
450
  )
451
- if st.form_submit_button("Save Profile", use_container_width=True, type="primary"):
452
  payload = {"display_name": display_name, "math_level": math_level, "interests": interests}
453
  try:
454
- r = requests.post(f"{BACKEND_URL}/users/profile", json=payload, headers=headers)
455
  if r.status_code == 200:
456
- st.success("Profile updated!")
457
  st.session_state.profile_data = payload
458
- time.sleep(1)
459
- st.rerun()
460
  else:
461
- st.error(f"Update failed: {r.text}")
462
  except Exception as e:
463
- st.error(f"Error saving: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
464
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
465
 
466
- # ====================================================
467
- # Chat Interface
468
- # ====================================================
469
  def chat_interface():
470
- if not st.session_state.active_session_id:
471
- if st.session_state.chat_sessions:
472
- st.session_state.active_session_id = st.session_state.chat_sessions[0]["session_id"]
473
- load_messages(st.session_state.active_session_id)
474
- else:
475
- new_chat()
476
- return
477
 
478
- active_sess = get_active_session()
479
- st.title(active_sess["title"] if active_sess else "Chat")
 
 
480
 
481
- # βœ… SELF-HEALING: Reset processing lock if assistant has already replied
482
- if st.session_state.is_processing and st.session_state.messages:
483
- if st.session_state.messages[-1]["role"] == "assistant":
484
- st.session_state.is_processing = False
485
- # No rerun needed here, just continue to render
486
 
487
- # ── 1. Render history ─────────────────────────────────────────────────────
 
488
  for msg in st.session_state.messages:
489
- role = msg["role"]
490
- with st.chat_message(role, avatar="πŸ‘€" if role == "user" else "πŸ€–"):
491
- if role == "user":
492
- if msg.get("image_data"):
493
- try:
494
- st.image(base64.b64decode(msg["image_data"]), width=300)
495
- except Exception:
496
- pass
497
- st.write(msg["content"])
498
- else:
499
- meta = msg.get("metadata", {})
500
- if meta:
501
- badges = ""
502
- src = meta.get("source", "")
503
- if src == "sympy_preflight":
504
- badges += '<span class="badge badge-green">⚑ INSTANT</span>'
505
- elif src == "cache":
506
- badges += '<span class="badge badge-blue">πŸ’Ύ CACHED</span>'
507
- elif src in ("google_adk_agent", "agent"):
508
- badges += '<span class="badge badge-purple">πŸ€– AGENT</span>'
509
- model = meta.get("model_used") or meta.get("model")
510
- if model:
511
- badges += f'<span class="badge" style="background:rgba(255,255,255,0.1);">{model}</span>'
512
- if badges:
513
- st.markdown(badges, unsafe_allow_html=True)
514
-
515
- # Reasoning display removed as per user request
516
-
517
- content = msg["content"]
518
- if isinstance(content, dict) and "final_answer" in content:
519
- st.markdown(f"**Answer:**\n\n> {content['final_answer']}")
520
- else:
521
- st.markdown(str(content))
522
 
523
- # ── 2. Input area ─────────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
524
  st.divider()
525
  tab_text, tab_draw, tab_upload = st.tabs(["πŸ’¬ Text", "✏️ Draw", "πŸ“€ Upload"])
 
526
  prompt = None
527
  image_b64 = None
528
- is_processing = st.session_state.get("is_processing", False)
529
 
530
  with tab_text:
531
- text_prompt = st.chat_input("Ask a math question...", disabled=is_processing)
532
- if text_prompt:
533
- prompt = text_prompt
534
 
535
  with tab_draw:
536
- col_canvas, col_controls = st.columns([3, 1])
537
- with col_canvas:
538
- canvas_result = st_canvas(
539
  stroke_width=3, stroke_color="#FFFFFF", background_color="#000000",
540
  height=300, width=600, drawing_mode="freedraw",
541
  key=st.session_state.canvas_key,
542
  )
543
- draw_prompt_input = st.text_input(
544
- "Question about drawing (optional)",
545
- placeholder="Solve this handwritten problem...",
546
- key="draw_prompt_input"
547
- )
548
- with col_controls:
549
  st.caption("Controls")
550
- if st.button("Clear"):
551
  st.session_state.canvas_key = f"canvas_{uuid.uuid4()}"
552
  st.rerun()
553
- if st.button("Solve", type="primary", disabled=is_processing):
554
- if canvas_result.image_data is not None:
555
- img = Image.fromarray(canvas_result.image_data.astype("uint8"), "RGBA")
 
556
  bg = Image.new("RGB", img.size, (0, 0, 0))
557
  bg.paste(img, mask=img.split()[3])
558
  buf = io.BytesIO()
559
  bg.save(buf, format="PNG")
560
  image_b64 = base64.b64encode(buf.getvalue()).decode()
561
- prompt = draw_prompt_input or "Solve this handwritten math problem."
 
 
562
 
563
  with tab_upload:
564
- uploaded_file = st.file_uploader("Upload", type=["png", "jpg"], disabled=is_processing)
565
- upload_prompt_input = st.text_input("Question", placeholder="Analyze...", disabled=is_processing, key="upload_prompt_input")
566
- if uploaded_file and st.button("Analyze", disabled=is_processing):
567
- image_b64 = base64.b64encode(uploaded_file.getvalue()).decode()
568
- prompt = upload_prompt_input or "Analyze this image."
569
-
570
- # ── 3. New user message β†’ optimistic UI update + rerun ────────────────────
 
 
 
571
  if prompt:
572
- req_id = str(uuid.uuid4())
573
- add_message("user", prompt, image_data=image_b64, request_id=req_id, sent_to_api=False)
574
  st.session_state.is_processing = True
575
  st.rerun()
576
 
577
- # ── 4. Fire API call if last message is an unsent user message ────────────
578
- if (
579
- st.session_state.messages
580
- and st.session_state.messages[-1]["role"] == "user"
581
- and not st.session_state.messages[-1].get("sent_to_api", False)
582
- ):
583
- last = st.session_state.messages[-1]
584
- request_id = last.get("request_id") or str(uuid.uuid4())
585
- last["request_id"] = request_id
586
- # βœ… CRITICAL: Mark as sent immediately to prevent re-triggering during streaming
587
- last["sent_to_api"] = True
588
-
589
- with st.chat_message("assistant", avatar="πŸ€–"):
590
- status_msg = st.status("Thinking...", expanded=False)
591
- answer_placeholder = st.empty()
592
-
593
- full_answer = ""
594
- logic_trace = []
595
-
596
- try:
597
- # Prepare SSE Session
598
- payload = {
599
- "text": last["content"],
600
- "image": last.get("image_data"),
601
- "session_id": st.session_state.active_session_id,
602
- "request_id": request_id
603
- }
604
- headers = get_auth_headers()
605
- with requests.post(f"{BACKEND_URL}/solve", json=payload, headers=headers, stream=True, timeout=360) as r:
606
- if r.status_code == 200:
607
- line_buffer = ""
608
- last_ui_update = time.time()
609
-
610
- # βœ… ZERO-BUFFER BYTE STREAMING
611
- for chunk in r.iter_content(chunk_size=None, decode_unicode=True):
612
- if chunk:
613
- line_buffer += chunk
614
- while "\n" in line_buffer:
615
- line, line_buffer = line_buffer.split("\n", 1)
616
- line = line.strip()
617
- if not line: continue
618
-
619
- try:
620
- if line.startswith("data:"):
621
- line = line[len("data:"):].strip()
622
-
623
- data = json.loads(line)
624
- ev_type = data.get("type", "")
625
-
626
- if ev_type == "answer":
627
- content = data.get("content", "")
628
- full_answer += content
629
- # βœ… RATE-LIMITED UI UPDATE (Smooth @ 20fps)
630
- if time.time() - last_ui_update > 0.05:
631
- answer_placeholder.markdown(full_answer + "β–Œ")
632
- last_ui_update = time.time()
633
- elif ev_type in ("thought", "action", "observation"):
634
- content = data.get("content", "")
635
- if content:
636
- logic_trace.append(content)
637
- status_msg.update(label=f"βš™οΈ {content}", state="running", expanded=False)
638
- except Exception:
639
- continue
640
-
641
- # βœ… FINAL FLUSH
642
- if line_buffer.strip():
643
- try:
644
- line = line_buffer.strip()
645
- if line.startswith("data:"): line = line[len("data:"):].strip()
646
- data = json.loads(line)
647
- if data.get("type") == "answer":
648
- full_answer += data.get("content", "")
649
- except Exception: pass
650
-
651
- # Finalize
652
- answer_placeholder.markdown(full_answer if full_answer else "No answer received.")
653
- status_msg.update(label="Solved!", state="complete", expanded=False)
654
-
655
- # Save & FINAL SYNC
656
- add_message("assistant", full_answer, request_id=request_id)
657
- time.sleep(0.1)
658
- load_messages(st.session_state.active_session_id)
659
- st.rerun()
660
- else:
661
- st.error(f"Backend Error: {r.status_code}")
662
- except Exception as e:
663
- logger.error(f"Streaming Exception: {e}")
664
- st.error(f"Connection lost or error: {e}")
665
- finally:
666
- # βœ… CRITICAL: Always release processing lock
667
- st.session_state.is_processing = False
668
- st.rerun()
669
-
670
-
671
-
672
- # ====================================================
673
- # Sidebar
674
- # ====================================================
675
- with st.sidebar:
676
- st.markdown("### 🧠 MathMinds")
677
- st.write(f"Logged in as **{st.session_state.user['email']}**")
678
-
679
- view = st.radio(
680
- "Navigation", ["Chat", "Profile"],
681
- index=0 if st.session_state.current_view == "Chat" else 1
682
- )
683
- if view != st.session_state.current_view:
684
- st.session_state.current_view = view
685
- st.rerun()
686
-
687
- if st.button("Sign Out", type="secondary"):
688
- # βœ… MULTIUSER FIX: Wipe ALL user-specific state first, THEN clear identity.
689
- # Without _clear_user_state() here, the next user to log in on the same
690
- # browser tab would see User A's chat history briefly before load_sessions
691
- # returns, because st.session_state persists across logins within a tab.
692
- _clear_user_state()
693
- st.session_state.user = None
694
- st.rerun()
695
 
696
- if st.session_state.is_processing:
697
- if st.button(
698
- "πŸ”“ Reset Processing Lock", type="primary",
699
- help="Use if UI is stuck despite answer finishing."
700
- ):
701
- st.session_state.is_processing = False
702
- st.rerun()
703
-
704
- st.divider()
705
 
706
- if st.session_state.current_view == "Chat":
707
- if st.button("βž• New Chat", use_container_width=True, type="primary"):
708
- new_chat()
709
-
710
- st.markdown("#### History")
711
-
712
- for session in st.session_state.chat_sessions:
713
- sid = session["session_id"]
714
- title = session["title"]
715
 
716
- cols = st.columns([0.8, 0.1, 0.1])
717
- with cols[0]:
718
- is_active = (st.session_state.active_session_id == sid)
719
- btn_type = "primary" if is_active else "secondary"
720
- if st.button(title, key=f"sel_{sid}", use_container_width=True, type=btn_type):
721
- st.session_state.active_session_id = sid
722
- load_messages(sid)
723
- st.rerun()
724
- with cols[1]:
725
- if st.button("πŸ–ŠοΈ", key=f"ren_{sid}", help="Rename"):
726
- st.session_state.renaming_session_id = (
727
- sid if st.session_state.renaming_session_id != sid else None
728
- )
729
- st.rerun()
730
- with cols[2]:
731
- if st.button("πŸ—‘οΈ", key=f"del_{sid}", help="Delete"):
732
- delete_chat(sid)
733
-
734
- if st.session_state.renaming_session_id == sid:
735
- with st.container():
736
- new_title = st.text_input(
737
- "New title", value=title,
738
- key=f"in_{sid}", label_visibility="collapsed"
739
- )
740
- if st.button("Save", key=f"save_{sid}", use_container_width=True):
741
- rename_chat(sid, new_title)
742
- st.session_state.renaming_session_id = None
743
- st.rerun()
744
 
 
 
745
 
746
- # ====================================================
747
- # Main Content Area
748
- # ====================================================
749
  if st.session_state.current_view == "Chat":
750
  chat_interface()
751
  elif st.session_state.current_view == "Profile":
752
- profile_interface()
 
1
+ """
2
+ frontend/app.py β€” MathMinds AI Streamlit frontend.
3
+
4
+ STRUCTURE
5
+ ─────────
6
+ 1. CONFIG & CONSTANTS β€” env vars, page config, CSS
7
+ 2. SESSION STATE β€” defaults, init, clear
8
+ 3. API LAYER β€” all HTTP calls, no st.* calls, returns plain dicts
9
+ 4. RENDER HELPERS β€” pure display functions, never mutate state
10
+ 5. SESSION MANAGEMENT β€” state mutations, never call st.rerun()
11
+ 6. CHAT INTERFACE β€” the 3-state machine (idle / processing / done)
12
+ 7. MAIN ENTRY β€” auth gate + router
13
+
14
+ STATE MACHINE (why the previous version had bugs)
15
+ ──────────────────────────────────────────────────
16
+ Every bug in the old version came from collapsing 3 distinct states into one
17
+ render pass: add user message + call API + render answer all happened together.
18
+
19
+ The fix is a strict 3-state machine:
20
+
21
+ IDLE β†’ render history + show input
22
+ user submits β†’ _add_message("user") β†’ is_processing=True β†’ rerun()
23
+
24
+ PROCESSING β†’ render history (user question VISIBLE above spinner)
25
+ make API call β†’ _add_message("assistant") β†’ is_processing=False β†’ rerun()
26
+
27
+ IDLE again β†’ render history (assistant answer now visible)
28
+
29
+ Rules that make this impossible to break:
30
+ - st.rerun() is ALWAYS the last statement β€” never inside a with-block
31
+ - The API call NEVER happens inside with st.chat_message()
32
+ - Render helpers never write session_state
33
+ - load_messages() only called on session switch, never after an answer
34
+ """
35
+
36
  import streamlit as st
37
  import requests
 
38
  import base64
39
+ import logging
40
  import io
41
  import os
 
42
  import time
43
+ import uuid
44
+ from PIL import Image
45
  from streamlit_drawable_canvas import st_canvas
46
  from dotenv import load_dotenv
47
  from firebase_utils import sign_in_with_email, sign_up_with_email
48
 
49
+ logging.basicConfig(level=logging.INFO)
50
+ logger = logging.getLogger(__name__)
51
  load_dotenv()
52
 
53
+
54
+ # ══════════════════════════════════════════════════════════════════════════════
55
+ # 1. CONFIG & CONSTANTS
56
+ # ══════════════════════════════════════════════════════════════════════════════
57
+
58
+ BACKEND_URL = os.getenv("BACKEND_URL", "http://localhost:8000")
59
+ ENABLE_AUTH = os.getenv("ENABLE_AUTH", "True").lower() == "true"
60
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
  st.set_page_config(
62
  page_title="MathMinds AI",
63
  page_icon="🧠",
64
  layout="wide",
65
+ initial_sidebar_state="expanded",
66
  )
67
 
 
 
 
68
  st.markdown("""
69
  <style>
70
  .stApp {
 
93
  }
94
  h1, h2, h3 { color: #f3f4f6; letter-spacing: -0.5px; }
95
  p, li { color: #e5e7eb; line-height: 1.6; }
 
 
 
 
 
96
  .badge {
97
  display: inline-flex; align-items: center;
98
  padding: 0.25rem 0.75rem; border-radius: 9999px;
99
  font-size: 0.75rem; font-weight: 600; margin-right: 0.5rem;
100
  }
101
+ .badge-blue { background:rgba(59,130,246,0.2); color:#93c5fd; border:1px solid rgba(59,130,246,0.3); }
102
+ .badge-purple { background:rgba(168,85,247,0.2); color:#d8b4fe; border:1px solid rgba(168,85,247,0.3); }
103
+ .badge-green { background:rgba(34,197,94,0.2); color:#86efac; border:1px solid rgba(34,197,94,0.3); }
104
+ .badge-red { background:rgba(239,68,68,0.2); color:#fca5a5; border:1px solid rgba(239,68,68,0.3); }
105
  button[kind="primary"] {
106
  background: linear-gradient(to right, #4f46e5, #7c3aed);
107
  border: none; box-shadow: 0 4px 6px -1px rgba(79,70,229,0.3); transition: all 0.2s;
 
110
  </style>
111
  """, unsafe_allow_html=True)
112
 
 
 
 
 
 
113
 
114
+ # ══════════════════════════════════════════════════════════════════════════════
115
+ # 2. SESSION STATE
116
+ # ══════════════════════════════════════════════════════════════════════════════
117
+
118
+ _DEFAULTS = {
119
+ "user": None,
120
+ "current_view": "Chat",
121
+ "chat_sessions": [],
122
+ "active_session_id": None,
123
+ "messages": [],
124
+ "loaded_for_user": None,
125
+ "loaded_for_session": None,
126
+ "is_processing": False,
127
+ "renaming_session_id": None,
128
+ "canvas_key": "main_canvas",
129
+ }
130
+
131
+ for _k, _v in _DEFAULTS.items():
132
+ if _k not in st.session_state:
133
+ st.session_state[_k] = _v
134
+
135
+ # Dev mode bypass
136
+ if not ENABLE_AUTH and st.session_state.user is None:
137
+ st.session_state.user = {
138
+ "email": "dev@mathminds.ai",
139
+ "token": "mock_dev_token",
140
+ "uid": "dev_user_123",
141
+ }
142
 
 
 
 
 
 
 
143
 
144
+ def _reset_state(keep_user=False):
145
+ """Clear all state. Optionally preserve the user object."""
146
+ saved_user = st.session_state.user if keep_user else None
147
+ for k, v in _DEFAULTS.items():
148
+ st.session_state[k] = v
149
+ st.session_state.canvas_key = f"canvas_{uuid.uuid4()}"
150
+ if keep_user:
151
+ st.session_state.user = saved_user
152
 
153
+
154
+ def _get_headers() -> dict:
155
+ u = st.session_state.user
156
+ return {"Authorization": f"Bearer {u['token']}"} if u and "token" in u else {}
157
+
158
+
159
+ def _add_message(role: str, content, **kwargs):
160
+ msg = {"role": role, "content": content, "timestamp": time.time()}
161
+ msg.update(kwargs)
162
+ st.session_state.messages.append(msg)
163
+
164
+
165
+ def _is_blank_canvas(image_data) -> bool:
166
+ if image_data is None:
167
+ return True
168
+ import numpy as np
169
+ return image_data[:, :, 3].max() == 0
170
+
171
+
172
+ # ══════════════════════════════════════════════════════════════════════════════
173
+ # 3. API LAYER
174
+ # ─────────────────────────────────────────────────────────────────────────────
175
+ # Rules:
176
+ # - NO st.* calls anywhere in this section
177
+ # - Returns plain dict, always has "ok" key
178
+ # - Never raises β€” exceptions are caught and returned as {"ok": False}
179
+ # ══════════════════════════════════════════════════════════════════════════════
180
+
181
+ @st.cache_data(ttl=30)
182
+ def api_health() -> dict:
183
+ """Cached β€” only hits the backend once every 30 seconds, not on every rerender."""
 
184
  try:
185
+ r = requests.get(f"{BACKEND_URL}/health", timeout=5)
186
+ return {"ok": r.status_code == 200, **r.json()} if r.status_code == 200 else {"ok": False}
187
+ except Exception:
188
+ return {"ok": False}
189
+
190
+
191
+ def api_solve(text: str, image, session_id: str, request_id: str) -> dict:
192
+ try:
193
+ r = requests.post(
194
+ f"{BACKEND_URL}/solve",
195
+ json={"text": text, "image": image, "session_id": session_id, "request_id": request_id},
196
+ headers=_get_headers(),
197
+ timeout=360,
198
+ )
199
+ if r.status_code == 200:
200
+ return {"ok": True, **r.json()}
201
+ if r.status_code == 401:
202
+ return {"ok": False, "error": "AUTH_EXPIRED"}
203
+ if r.status_code == 429:
204
+ return {"ok": False, "error": "Daily limit reached. Please try again tomorrow."}
205
+ return {"ok": False, "error": f"Backend error {r.status_code}"}
206
+ except requests.exceptions.ConnectionError:
207
+ return {"ok": False, "error": "Cannot reach backend. Is the server running?"}
208
+ except requests.exceptions.Timeout:
209
+ return {"ok": False, "error": "Request timed out."}
 
 
 
 
 
 
 
 
 
 
210
  except Exception as e:
211
+ logger.error(f"api_solve: {e}")
212
+ return {"ok": False, "error": "Unexpected error. Please try again."}
213
 
214
 
215
+ def api_load_messages(session_id: str) -> dict:
 
 
 
 
 
216
  try:
217
+ r = requests.get(
 
218
  f"{BACKEND_URL}/chat/sessions/{session_id}/messages",
219
+ headers=_get_headers(), timeout=30,
220
  )
221
+ if r.status_code == 200: return {"ok": True, "messages": r.json()}
222
+ if r.status_code == 404: return {"ok": False, "error": "SESSION_NOT_FOUND"}
223
+ if r.status_code == 401: return {"ok": False, "error": "AUTH_EXPIRED"}
224
+ return {"ok": False, "error": f"Status {r.status_code}"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
225
  except Exception as e:
226
+ return {"ok": False, "error": str(e)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
227
 
228
 
229
+ def api_load_sessions() -> dict:
230
  try:
231
+ r = requests.get(f"{BACKEND_URL}/chat/sessions", headers=_get_headers(), timeout=10)
232
+ if r.status_code == 200: return {"ok": True, "sessions": r.json()}
233
+ if r.status_code == 401: return {"ok": False, "error": "AUTH_EXPIRED"}
234
+ return {"ok": False, "error": f"Status {r.status_code}"}
235
+ except requests.exceptions.ConnectionError:
236
+ return {"ok": False, "error": "BACKEND_OFFLINE"}
 
 
 
 
237
  except Exception as e:
238
+ return {"ok": False, "error": str(e)}
239
 
240
 
241
+ def api_new_session() -> dict:
242
  try:
243
+ r = requests.post(f"{BACKEND_URL}/chat/sessions", headers=_get_headers(), timeout=30)
244
+ return {"ok": True, "session": r.json()} if r.status_code == 200 else {"ok": False, "error": f"Status {r.status_code}"}
245
+ except Exception as e:
246
+ return {"ok": False, "error": str(e)}
247
+
248
+
249
+ def api_delete_session(sid: str) -> dict:
250
+ try:
251
+ r = requests.delete(f"{BACKEND_URL}/chat/sessions/{sid}", headers=_get_headers(), timeout=30)
252
+ return {"ok": r.status_code == 200}
253
  except Exception as e:
254
+ return {"ok": False, "error": str(e)}
255
 
256
 
257
+ def api_rename_session(sid: str, title: str) -> dict:
258
  try:
259
+ r = requests.patch(
 
260
  f"{BACKEND_URL}/chat/sessions/{sid}",
261
+ json={"title": title}, headers=_get_headers(), timeout=30,
262
  )
263
+ return {"ok": r.status_code == 200}
 
 
 
 
264
  except Exception as e:
265
+ return {"ok": False, "error": str(e)}
266
+
267
+
268
+ # ══════════════════════════════════════════════════════════════════════════════
269
+ # 4. RENDER HELPERS
270
+ # ─────────────────────────────────────────────────────────────────────────────
271
+ # Rules:
272
+ # - Only READ session_state, never write
273
+ # - Only call st.* display functions
274
+ # - Never call st.rerun() or make API calls
275
+ # ══════════════════════════════════════════════════════════════════════════════
276
+
277
+ def _badge(source: str, status: str) -> str:
278
+ b = ""
279
+ if source == "sympy_preflight":
280
+ b += '<span class="badge badge-green">⚑ INSTANT</span>'
281
+ elif source in ("cache", "semantic_cache"):
282
+ b += '<span class="badge badge-blue">πŸ’Ύ CACHED</span>'
283
+ elif source in ("google_adk_agent", "agent"):
284
+ b += '<span class="badge badge-purple">πŸ€– AI</span>'
285
+ if status == "error":
286
+ b += '<span class="badge badge-red">πŸ”΄ ERROR</span>'
287
+ return b
288
+
289
+
290
+ def _render_message(msg: dict):
291
+ role = msg.get("role", "assistant")
292
+ with st.chat_message(role, avatar="πŸ‘€" if role == "user" else "πŸ€–"):
293
+ if role == "user":
294
+ if msg.get("image_data"):
295
+ try:
296
+ st.image(base64.b64decode(msg["image_data"]), width=300)
297
+ except Exception:
298
+ pass
299
+ st.write(msg.get("content", ""))
300
+ else:
301
+ meta = msg.get("metadata") or {}
302
+ status = meta.get("status") or msg.get("status", "success")
303
+ badges = _badge(meta.get("source", ""), status)
304
+ if badges:
305
+ st.markdown(badges, unsafe_allow_html=True)
306
+
307
+ logic = meta.get("logic_trace") or msg.get("reasoning")
308
+ if logic:
309
+ steps = [s for s in (logic if isinstance(logic, list) else logic.split("\n")) if s]
310
+ if steps:
311
+ with st.expander("πŸ’­ Reasoning", expanded=False):
312
+ for step in steps:
313
+ st.caption(step)
314
+
315
+ content = msg.get("content", "")
316
+ if status == "error":
317
+ st.error(content)
318
+ elif isinstance(content, dict) and "final_answer" in content:
319
+ st.markdown(content["final_answer"])
320
+ else:
321
+ st.markdown(str(content))
322
 
323
 
324
+ def _render_login():
325
+ _, c, _ = st.columns([1, 2, 1])
326
+ with c:
 
 
 
 
 
327
  st.markdown("""
328
+ <div style="text-align:center;padding:3rem 4rem;background:rgba(255,255,255,0.05);
329
+ border-radius:20px;border:1px solid rgba(255,255,255,0.1);margin:2rem 0;">
330
+ <h1 style="margin:0">🧠 MathMinds AI</h1>
331
+ <p style="color:#9ca3af;margin-top:0.5rem;">Your intelligent math assistant.</p>
332
  </div>
333
  """, unsafe_allow_html=True)
334
 
335
+ tab_in, tab_up = st.tabs(["Login", "Sign Up"])
336
 
337
+ with tab_in:
338
  with st.form("login_form"):
339
+ email = st.text_input("Email", placeholder="student@university.edu")
340
  password = st.text_input("Password", type="password")
341
+ if st.form_submit_button("Sign In", use_container_width=True, type="primary"):
342
  if email and password:
343
+ token, uid, user_email, error = sign_in_with_email(email, password)
344
+ if token:
345
+ _reset_state()
346
+ st.session_state.user = {"email": user_email, "token": token, "uid": uid}
347
+ st.rerun()
348
+ else:
349
+ st.error(f"Login failed: {error}")
 
 
 
 
 
 
 
 
 
 
350
  else:
351
  st.error("Please enter email and password.")
352
 
353
+ with tab_up:
354
  with st.form("signup_form"):
355
+ new_email = st.text_input("Email", placeholder="new@student.edu")
356
+ new_pass = st.text_input("Password", type="password")
357
+ confirm = st.text_input("Confirm Password", type="password")
358
+ if st.form_submit_button("Create Account", use_container_width=True, type="primary"):
359
+ if new_email and new_pass:
360
+ if new_pass != confirm:
361
+ st.error("Passwords do not match.")
 
362
  else:
363
+ token, uid, user_email, error = sign_up_with_email(new_email, new_pass)
364
+ if token:
365
+ _reset_state()
366
+ st.session_state.user = {"email": user_email, "token": token, "uid": uid}
367
+ st.rerun()
368
+ else:
369
+ st.error(f"Sign up failed: {error}")
 
 
 
 
 
 
 
 
 
 
370
  else:
371
  st.error("Please fill all fields.")
372
 
373
  st.markdown(
374
  "<p style='text-align:center;font-size:0.8rem;color:#6b7280;'>Powered by Gemini & SymPy</p>",
375
+ unsafe_allow_html=True,
376
  )
377
 
378
 
379
+ def _render_profile():
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
380
  st.title("πŸ‘€ User Profile")
 
 
 
381
  if "profile_data" not in st.session_state:
382
  try:
383
+ r = requests.get(f"{BACKEND_URL}/users/profile", headers=_get_headers(), timeout=30)
384
  st.session_state.profile_data = r.json() if r.status_code == 200 else {}
385
  except Exception:
386
  st.session_state.profile_data = {}
387
 
388
+ data = st.session_state.profile_data
389
+ levels = ["High School", "Undergraduate", "Graduate", "Researcher"]
390
  interests_all = ["Algebra", "Calculus", "Geometry", "Statistics", "Physics", "Computer Science", "Finance"]
391
 
392
  with st.form("profile_form"):
393
  display_name = st.text_input("Display Name", value=data.get("display_name", ""))
394
  math_level = st.selectbox(
395
+ "Math Level", levels,
396
+ index=levels.index(data["math_level"]) if data.get("math_level") in levels else 1,
 
397
  )
398
  interests = st.multiselect(
399
+ "Interests", interests_all,
400
+ default=[i for i in data.get("interests", []) if i in interests_all],
401
  )
402
+ if st.form_submit_button("Save", use_container_width=True, type="primary"):
403
  payload = {"display_name": display_name, "math_level": math_level, "interests": interests}
404
  try:
405
+ r = requests.post(f"{BACKEND_URL}/users/profile", json=payload, headers=_get_headers())
406
  if r.status_code == 200:
407
+ st.success("Saved!")
408
  st.session_state.profile_data = payload
 
 
409
  else:
410
+ st.error(f"Failed: {r.text}")
411
  except Exception as e:
412
+ st.error(str(e))
413
+
414
+
415
+ # ══════════════════════════════════════════════════════════════════════════════
416
+ # 5. SESSION MANAGEMENT
417
+ # ─────────────────────────────────────────────────────────────────────────────
418
+ # These functions mutate session_state but never call st.rerun().
419
+ # Callers decide when to rerun.
420
+ # ══════════════════════════════════════════════════════════════════════════════
421
+
422
+ def _refresh_sessions():
423
+ result = api_load_sessions()
424
+ if result["ok"]:
425
+ st.session_state.chat_sessions = result["sessions"]
426
+ st.session_state.loaded_for_user = st.session_state.user["uid"]
427
+ elif result.get("error") == "AUTH_EXPIRED":
428
+ _reset_state()
429
+ elif result.get("error") == "BACKEND_OFFLINE":
430
+ st.info("βŒ› Backend warming up...")
431
+ st.stop()
432
+
433
+
434
+ def _switch_session(sid: str):
435
+ """Load messages for a session. Only fetches if session actually changed."""
436
+ if st.session_state.loaded_for_session == sid:
437
+ st.session_state.active_session_id = sid
438
+ return
439
+
440
+ result = api_load_messages(sid)
441
+ if result["ok"]:
442
+ st.session_state.active_session_id = sid
443
+ st.session_state.messages = result["messages"]
444
+ st.session_state.loaded_for_session = sid
445
+ elif result.get("error") == "SESSION_NOT_FOUND":
446
+ st.warning("Session not found.")
447
+ st.session_state.active_session_id = None
448
+ st.session_state.messages = []
449
+ elif result.get("error") == "AUTH_EXPIRED":
450
+ _reset_state()
451
+
452
+
453
+ def _ensure_session() -> bool:
454
+ """
455
+ Make sure there's an active session. Returns True if ready, False if rerun needed.
456
+ """
457
+ if st.session_state.active_session_id:
458
+ return True
459
+
460
+ sessions = st.session_state.chat_sessions
461
+ if sessions:
462
+ _switch_session(sessions[0]["session_id"])
463
+ return True
464
+
465
+ # Create first session
466
+ result = api_new_session()
467
+ if result["ok"]:
468
+ sess = result["session"]
469
+ st.session_state.active_session_id = sess["session_id"]
470
+ st.session_state.messages = []
471
+ st.session_state.loaded_for_session = sess["session_id"]
472
+ _refresh_sessions()
473
+ return False # caller must rerun
474
+ return True
475
+
476
+
477
+ def _render_sidebar():
478
+ with st.sidebar:
479
+ st.markdown("### 🧠 MathMinds")
480
+ st.caption(f"πŸ‘€ {st.session_state.user['email']}")
481
+
482
+ view = st.radio("Nav", ["Chat", "Profile"],
483
+ index=0 if st.session_state.current_view == "Chat" else 1,
484
+ label_visibility="collapsed")
485
+ if view != st.session_state.current_view:
486
+ st.session_state.current_view = view
487
+ st.rerun()
488
+
489
+ # Health
490
+ st.divider()
491
+ health = api_health()
492
+ if health.get("ok"):
493
+ st.markdown('<div class="badge badge-green">● Online</div>', unsafe_allow_html=True)
494
+ svc = health.get("services", {})
495
+ if svc.get("redis") != "healthy":
496
+ st.warning(f"Redis: {svc.get('redis')}")
497
+ if svc.get("mongodb") != "healthy":
498
+ st.warning(f"MongoDB: {svc.get('mongodb')}")
499
+ else:
500
+ st.markdown('<div class="badge badge-red">● Offline</div>', unsafe_allow_html=True)
501
+
502
+ # Stuck lock reset
503
+ if st.session_state.is_processing:
504
+ st.divider()
505
+ if st.button("πŸ”“ Reset Lock", help="Use if UI appears stuck"):
506
+ st.session_state.is_processing = False
507
+ st.rerun()
508
+
509
+ st.divider()
510
+
511
+ if st.session_state.current_view == "Chat":
512
+ if st.button("βž• New Chat", use_container_width=True, type="primary"):
513
+ result = api_new_session()
514
+ if result["ok"]:
515
+ sess = result["session"]
516
+ st.session_state.active_session_id = sess["session_id"]
517
+ st.session_state.messages = []
518
+ st.session_state.loaded_for_session = sess["session_id"]
519
+ _refresh_sessions()
520
+ st.rerun()
521
+ else:
522
+ st.error("Failed to create session.")
523
+
524
+ st.markdown("#### History")
525
+ for sess in st.session_state.chat_sessions:
526
+ sid = sess["session_id"]
527
+ title = sess.get("title", "Chat")
528
+ cols = st.columns([0.78, 0.11, 0.11])
529
+
530
+ with cols[0]:
531
+ active = st.session_state.active_session_id == sid
532
+ if st.button(title, key=f"s_{sid}", use_container_width=True,
533
+ type="primary" if active else "secondary"):
534
+ _switch_session(sid)
535
+ st.rerun()
536
 
537
+ with cols[1]:
538
+ if st.button("✏️", key=f"r_{sid}"):
539
+ st.session_state.renaming_session_id = (
540
+ sid if st.session_state.renaming_session_id != sid else None
541
+ )
542
+ st.rerun()
543
+
544
+ with cols[2]:
545
+ if st.button("πŸ—‘οΈ", key=f"d_{sid}"):
546
+ api_delete_session(sid)
547
+ if st.session_state.active_session_id == sid:
548
+ st.session_state.active_session_id = None
549
+ st.session_state.messages = []
550
+ st.session_state.loaded_for_session = None
551
+ _refresh_sessions()
552
+ st.rerun()
553
+
554
+ if st.session_state.renaming_session_id == sid:
555
+ new_title = st.text_input("Title", value=title,
556
+ key=f"ti_{sid}", label_visibility="collapsed")
557
+ if st.button("Save", key=f"sv_{sid}", use_container_width=True):
558
+ api_rename_session(sid, new_title)
559
+ st.session_state.renaming_session_id = None
560
+ _refresh_sessions()
561
+ st.rerun()
562
+
563
+ st.divider()
564
+ if st.button("Sign Out", use_container_width=True):
565
+ _reset_state()
566
+ st.rerun()
567
+
568
+
569
+ # ══════════════════════════════════════════════════════════════════════════════
570
+ # 6. CHAT INTERFACE β€” 3-STATE MACHINE
571
+ # ══════════════════════════════════════════════════════════════════════════════
572
 
 
 
 
573
  def chat_interface():
 
 
 
 
 
 
 
574
 
575
+ # Ensure active session exists
576
+ if not _ensure_session():
577
+ st.rerun()
578
+ return
579
 
580
+ active_sid = st.session_state.active_session_id
581
+ active_sess = next((s for s in st.session_state.chat_sessions
582
+ if s["session_id"] == active_sid), None)
583
+ st.title(active_sess["title"] if active_sess else "Chat")
 
584
 
585
+ # ── ALWAYS render history first ────────────────────────────────────────
586
+ # This runs every pass β€” so the user question is visible while processing
587
  for msg in st.session_state.messages:
588
+ _render_message(msg)
589
+
590
+ # ══════════════════════════════════════════════════════════════════════
591
+ # STATE: PROCESSING
592
+ # Entered when: is_processing=True (user just submitted, prev rerun set flag)
593
+ # Exit: add assistant message β†’ is_processing=False β†’ rerun
594
+ # ══════════════════════════════════════════════════════════════════════
595
+ if st.session_state.is_processing:
596
+ last = st.session_state.messages[-1]
597
+ request_id = str(uuid.uuid4())
598
+
599
+ # Spinner appears BELOW the rendered history (user question visible above)
600
+ with st.spinner("Thinking..."):
601
+ result = api_solve(
602
+ text = last.get("content", ""),
603
+ image = last.get("image_data"),
604
+ session_id = active_sid,
605
+ request_id = request_id,
606
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
607
 
608
+ # Auth expired β€” logout
609
+ if result.get("error") == "AUTH_EXPIRED":
610
+ _reset_state()
611
+ st.rerun()
612
+ return
613
+
614
+ # Add assistant response to state
615
+ if result["ok"]:
616
+ answer = result.get("answer") or "No answer received."
617
+ metadata = result.get("metadata") or {}
618
+ _add_message(
619
+ "assistant", answer,
620
+ request_id = request_id,
621
+ metadata = {**metadata, "status": result.get("status", "success")},
622
+ )
623
+ else:
624
+ _add_message(
625
+ "assistant", f"⚠️ {result.get('error', 'Unknown error')}",
626
+ request_id = request_id,
627
+ metadata = {"status": "error"},
628
+ )
629
+
630
+ # Transition β†’ IDLE
631
+ # st.rerun() is the LAST statement, outside all with-blocks
632
+ st.session_state.is_processing = False
633
+ st.rerun()
634
+ return
635
+
636
+ # ══════════════════════════════════════════════════════════════════════
637
+ # STATE: IDLE
638
+ # Show input. If user submits, transition to PROCESSING.
639
+ # ══════════════════════════════════════════════════════════════════════
640
  st.divider()
641
  tab_text, tab_draw, tab_upload = st.tabs(["πŸ’¬ Text", "✏️ Draw", "πŸ“€ Upload"])
642
+
643
  prompt = None
644
  image_b64 = None
 
645
 
646
  with tab_text:
647
+ prompt = st.chat_input("Ask a math question...")
 
 
648
 
649
  with tab_draw:
650
+ col_c, col_ctrl = st.columns([3, 1])
651
+ with col_c:
652
+ canvas = st_canvas(
653
  stroke_width=3, stroke_color="#FFFFFF", background_color="#000000",
654
  height=300, width=600, drawing_mode="freedraw",
655
  key=st.session_state.canvas_key,
656
  )
657
+ draw_q = st.text_input("Question (optional)", placeholder="Solve this...",
658
+ key="draw_q")
659
+ with col_ctrl:
 
 
 
660
  st.caption("Controls")
661
+ if st.button("πŸ—‘οΈ Clear"):
662
  st.session_state.canvas_key = f"canvas_{uuid.uuid4()}"
663
  st.rerun()
664
+ return
665
+ if st.button("β–Ά Solve", type="primary"):
666
+ if canvas.image_data is not None and not _is_blank_canvas(canvas.image_data):
667
+ img = Image.fromarray(canvas.image_data.astype("uint8"), "RGBA")
668
  bg = Image.new("RGB", img.size, (0, 0, 0))
669
  bg.paste(img, mask=img.split()[3])
670
  buf = io.BytesIO()
671
  bg.save(buf, format="PNG")
672
  image_b64 = base64.b64encode(buf.getvalue()).decode()
673
+ prompt = draw_q or "Solve this handwritten math problem."
674
+ else:
675
+ st.warning("Please draw something first.")
676
 
677
  with tab_upload:
678
+ uploaded = st.file_uploader("Upload image", type=["png", "jpg"])
679
+ up_q = st.text_input("Question", placeholder="Analyze...", key="up_q")
680
+ if uploaded and st.button("β–Ά Analyze", type="primary"):
681
+ image_b64 = base64.b64encode(uploaded.getvalue()).decode()
682
+ prompt = up_q or "Analyze this image."
683
+
684
+ # ── Transition: IDLE β†’ PROCESSING ─────────────────────────────────────
685
+ # Add user message to state, set flag, rerun.
686
+ # The API call happens on the NEXT pass (PROCESSING state above).
687
+ # This is what makes the user question visible before the answer.
688
  if prompt:
689
+ _add_message("user", prompt, image_data=image_b64)
 
690
  st.session_state.is_processing = True
691
  st.rerun()
692
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
693
 
694
+ # ══════════════════════════════════════════════════════════════════════════════
695
+ # 7. MAIN ENTRY
696
+ # ══════════════════════════════════════════════════════════════════════════════
 
 
 
 
 
 
697
 
698
+ # Auth gate
699
+ if not st.session_state.user:
700
+ _render_login()
701
+ st.stop()
 
 
 
 
 
702
 
703
+ # User switch detection β€” clear state when a different user logs in
704
+ _uid = st.session_state.user["uid"]
705
+ if st.session_state.loaded_for_user != _uid:
706
+ _saved = st.session_state.user
707
+ _reset_state()
708
+ st.session_state.user = _saved
709
+ st.session_state.loaded_for_user = _uid
710
+ _refresh_sessions()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
711
 
712
+ # Sidebar always renders
713
+ _render_sidebar()
714
 
715
+ # Main content
 
 
716
  if st.session_state.current_view == "Chat":
717
  chat_interface()
718
  elif st.session_state.current_view == "Profile":
719
+ _render_profile()
inspect_adk.py DELETED
@@ -1,58 +0,0 @@
1
-
2
- import os
3
- import sys
4
- from dotenv import load_dotenv
5
-
6
- # Add project root to path
7
- sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
8
-
9
- try:
10
- from google.adk.agents import LlmAgent
11
- import inspect
12
-
13
- print("=== LlmAgent.__init__ ===")
14
- print(inspect.signature(LlmAgent.__init__))
15
- print(LlmAgent.__init__.__doc__)
16
-
17
- print("\n=== LlmAgent.run ===")
18
- if hasattr(LlmAgent, 'run'):
19
- print(inspect.signature(LlmAgent.run))
20
- print(LlmAgent.run.__doc__)
21
- else:
22
- print("No run method")
23
-
24
- print("\n=== google.adk.runners.Runner ===")
25
- try:
26
- from google.adk.runners import Runner
27
- print(inspect.signature(Runner.__init__))
28
- print(Runner.__init__.__doc__)
29
-
30
- print("\n=== Runner.run_async ===")
31
- if hasattr(Runner, 'run_async'):
32
- print(inspect.signature(Runner.run_async))
33
- print(Runner.run_async.__doc__)
34
- except ImportError:
35
- print("Could not import google.adk.runners.Runner")
36
-
37
- print("\n=== google.adk.model.Model ===")
38
- print("\n=== google.adk.sessions.in_memory_session_service.InMemorySessionService ===")
39
- try:
40
- from google.adk.sessions.in_memory_session_service import InMemorySessionService
41
- print(dir(InMemorySessionService))
42
- print(inspect.signature(InMemorySessionService.create_session))
43
- except ImportError:
44
- print("Could not import google.adk.sessions.in_memory_session_service.InMemorySessionService")
45
- except Exception as e:
46
- print(f"Error inspecting InMemorySessionService: {e}")
47
-
48
- try:
49
- import google.genai.types
50
- print("\n=== google.genai.types ===")
51
- print("google.genai.types found")
52
- except ImportError:
53
- print("Could not import google.genai.types")
54
-
55
- except ImportError as e:
56
- print(f"Failed to import: {e}")
57
- except Exception as e:
58
- print(f"Error: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
inspect_agent_class.py DELETED
@@ -1,19 +0,0 @@
1
-
2
- import inspect
3
- from google.adk.agents import Agent
4
-
5
- print("=== Agent.__init__ ===")
6
- print(inspect.signature(Agent.__init__))
7
- print(Agent.__init__.__doc__)
8
-
9
- print("\n=== Agent.run ===")
10
- if hasattr(Agent, 'run'):
11
- print(inspect.signature(Agent.run))
12
- else:
13
- print("No run method")
14
-
15
- print("\n=== Agent.run_async ===")
16
- if hasattr(Agent, 'run_async'):
17
- print(inspect.signature(Agent.run_async))
18
- else:
19
- print("No run_async method")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
locate_adk_modules.py DELETED
@@ -1,17 +0,0 @@
1
-
2
- import pkgutil
3
- import google.adk
4
- import importlib
5
-
6
- def list_submodules(package, prefix):
7
- print(f"package: {prefix}")
8
- for loader, module_name, is_pkg in pkgutil.walk_packages(package.__path__, prefix + "."):
9
- print(module_name)
10
- if is_pkg:
11
- try:
12
- module = importlib.import_module(module_name)
13
- # print(dir(module))
14
- except Exception as e:
15
- print(f"Failed to import {module_name}: {e}")
16
-
17
- list_submodules(google.adk, "google.adk")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
reproduce_crash.py DELETED
@@ -1,61 +0,0 @@
1
-
2
- import asyncio
3
- import sys
4
- import os
5
- import logging
6
- import traceback
7
- from dotenv import load_dotenv
8
-
9
- # Load env vars
10
- load_dotenv(override=True)
11
-
12
- # Add project root to path
13
- sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
14
-
15
- # Windows asyncio policy
16
- if sys.platform == 'win32':
17
- asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
18
-
19
- from app.core.orchestrator import Orchestrator
20
-
21
- # Configure Logging
22
- logging.basicConfig(level=logging.INFO)
23
- logger = logging.getLogger("debugger")
24
-
25
- async def reproduce():
26
- print("Attempting to reproduce crash with 'solve 4x=36'...")
27
- try:
28
- orchestrator = Orchestrator()
29
-
30
- # Test case that crashed for user - force new hash
31
- import time
32
- query = f"solve 4x=36 {int(time.time())}" # Add timestamp to bypass cache
33
- # Wait, adding timestamp breaks "solve" logic?
34
- # "solve 4x=36 12345" might fail the regex?
35
- # Better: Disable cache in settings temporarily?
36
- # Or just clear cache first.
37
- # Or just query = "solve 4x=36" and I clear cache in script.
38
-
39
- from app.core.settings import settings
40
- settings.ENABLE_CACHE = False
41
-
42
- query = "solve 4x=36"
43
-
44
- print(f"Executing: {query}")
45
- result = await orchestrator.process_problem(text=query)
46
-
47
- print("Finished without crash.")
48
- print(f"Result: {result.get('answer')}")
49
-
50
- except Exception as e:
51
- print(f"Exception caught in main loop: {e}")
52
- traceback.print_exc()
53
-
54
- if __name__ == "__main__":
55
- try:
56
- asyncio.run(reproduce())
57
- except KeyboardInterrupt:
58
- print("Interrupted")
59
- except Exception as e:
60
- print(f"Fatal crash: {e}")
61
- traceback.print_exc()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
test_api.py DELETED
@@ -1,21 +0,0 @@
1
- import requests
2
- import json
3
- import time
4
-
5
- url = "http://localhost:8000/solve"
6
- payload = {
7
- "text": "what is 2+2?",
8
- "session_id": "test_session",
9
- "request_id": "test_rid_" + str(time.time())
10
- }
11
-
12
- print(f"Calling {url}...")
13
- headers = {"Authorization": "Bearer mock_token_123"}
14
- try:
15
- with requests.post(url, json=payload, headers=headers, stream=True, timeout=30) as r:
16
- print(f"Status: {r.status_code}")
17
- for chunk in r.iter_content(chunk_size=1, decode_unicode=True):
18
- if chunk:
19
- print(chunk, end="", flush=True)
20
- except Exception as e:
21
- print(f"\nFAILED: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
test_gemini_name.py DELETED
@@ -1,13 +0,0 @@
1
- from google.genai import Client
2
- from app.core.settings import settings
3
-
4
- client = Client(api_key=settings.GOOGLE_API_KEY)
5
-
6
- print("Searching for Flash models...")
7
- try:
8
- models = client.models.list()
9
- for m in models:
10
- if "flash" in m.name.lower():
11
- print(f"Name: {m.name}")
12
- except Exception as e:
13
- print(f"List failed: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
test_lang.py DELETED
@@ -1,12 +0,0 @@
1
- # quick test in python console or new file
2
- from app.agents.langchain_mathminds import MathMindsLangChainAgent
3
- import asyncio
4
-
5
- async def test():
6
- agent = MathMindsLangChainAgent()
7
- result = await agent.solve("Solve 4x - 12 = 8")
8
- print(result)
9
- result2 = await agent.solve("What is the gold price today in India?")
10
- print(result2)
11
-
12
- asyncio.run(test())
 
 
 
 
 
 
 
 
 
 
 
 
 
test_mongo_connection.py DELETED
@@ -1,19 +0,0 @@
1
-
2
- import os
3
- import pymongo
4
- from dotenv import load_dotenv
5
-
6
- # Force reload of .env
7
- load_dotenv(override=True)
8
-
9
- uri = os.getenv("MONGO_URI")
10
- print(f"Testing URI: {uri}")
11
-
12
- try:
13
- # Need to handle potential "dnspython" requirement for SRV
14
- client = pymongo.MongoClient(uri, serverSelectionTimeoutMS=5000)
15
- info = client.server_info()
16
- print("SUCCESS: Connected to MongoDB Atlas!")
17
- print(f"Version: {info.get('version')}")
18
- except Exception as e:
19
- print(f"FAILURE: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
test_orch.py DELETED
@@ -1,27 +0,0 @@
1
- import asyncio
2
- import os
3
- os.environ["DISABLE_MODEL_SOURCE_CHECK"] = "True"
4
- import json
5
- import sys
6
-
7
- # Add current dir to path
8
- sys.path.append(os.getcwd())
9
-
10
- from app.core.orchestrator import Orchestrator
11
-
12
- async def test_stream():
13
- # Mock dependencies
14
- orch = Orchestrator()
15
- print("Orchestrator initialized.")
16
-
17
- query = "what is 9^3?"
18
- print(f"Solving: {query}")
19
-
20
- try:
21
- async for event in orch.solve_problem_stream(query=query, request_id="test-rid"):
22
- print(f"EVENT: {event}")
23
- except Exception as e:
24
- print(f"FAILED: {e}")
25
-
26
- if __name__ == "__main__":
27
- asyncio.run(test_stream())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
test_playwright.py DELETED
@@ -1,22 +0,0 @@
1
- import asyncio
2
- from playwright.async_api import async_playwright
3
- import sys
4
-
5
- # Ensure policy is set here for isolation test
6
- if sys.platform == 'win32':
7
- asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
8
-
9
- async def main():
10
- print("Starting Playwright...")
11
- async with async_playwright() as p:
12
- print("Launching Browser...")
13
- browser = await p.chromium.launch(headless=True)
14
- print("Browser Launched!")
15
- page = await browser.new_page()
16
- await page.goto("http://example.com")
17
- print(await page.title())
18
- await browser.close()
19
- print("Done.")
20
-
21
- if __name__ == "__main__":
22
- asyncio.run(main())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
test_request.py DELETED
@@ -1,23 +0,0 @@
1
- import requests
2
- import json
3
- import time
4
-
5
- url = "http://localhost:8001/solve"
6
- # Read from payload.json
7
- with open("payload.json", "r") as f:
8
- payload = json.load(f)
9
-
10
- headers = {
11
- "Content-Type": "application/json"
12
- }
13
-
14
- start_time = time.time()
15
- try:
16
- print(f"Sending request to {url}...")
17
- response = requests.post(url, json=payload, headers=headers)
18
- print(f"Status Code: {response.status_code}")
19
- print(f"Duration: {time.time() - start_time:.2f}s")
20
- print("Response Body:")
21
- print(response.text)
22
- except Exception as e:
23
- print(f"Error: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
test_scraper_local.py DELETED
@@ -1,33 +0,0 @@
1
- import logging
2
- import sys
3
- import os
4
-
5
- # Add the current directory to sys.path so we can import 'app'
6
- sys.path.append(os.getcwd())
7
-
8
- from app.tools.web_scraper import run_playwright_sync
9
-
10
- logging.basicConfig(level=logging.INFO)
11
- logger = logging.getLogger(__name__)
12
-
13
- def test_direct_scrape():
14
- print("--- Testing run_playwright_sync DIRECTLY (Subprocess-safe) ---")
15
- query = "current gold rate in mumbai"
16
-
17
- try:
18
- # We run it synchronously as it's designed
19
- result = run_playwright_sync(query, headless=True)
20
-
21
- print("\n[RESULT]")
22
- if result.get("status") == "success":
23
- print(f"URL: {result.get('url')}")
24
- print(f"Content Length: {len(result.get('content', ''))}")
25
- print(f"Sample: {result.get('content')[:500]}...")
26
- else:
27
- print(f"Error: {result.get('error')}")
28
-
29
- except Exception as e:
30
- print(f"Crashed: {e}")
31
-
32
- if __name__ == "__main__":
33
- test_direct_scrape()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
test_sync_playwright.py DELETED
@@ -1,18 +0,0 @@
1
- from playwright.sync_api import sync_playwright
2
-
3
- def run():
4
- print("Starting sync_playwright...")
5
- try:
6
- with sync_playwright() as p:
7
- print("Launching browser...")
8
- browser = p.chromium.launch(headless=True)
9
- print("Browser launched.")
10
- page = browser.new_page()
11
- page.goto("http://example.com")
12
- print("Title:", page.title())
13
- browser.close()
14
- except Exception as e:
15
- print(f"Error: {e}")
16
-
17
- if __name__ == "__main__":
18
- run()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
test_tool_wrapping.py DELETED
@@ -1,31 +0,0 @@
1
-
2
- from google.adk.agents import LlmAgent
3
- from google.adk.tools import FunctionTool
4
-
5
- def my_tool(x: int) -> int:
6
- """doubles x"""
7
- return x * 2
8
-
9
- try:
10
- print("Trying to init LlmAgent with raw function...")
11
- agent = LlmAgent(
12
- name="test",
13
- model="gemini-flash-latest",
14
- instruction="test",
15
- tools=[my_tool]
16
- )
17
- print("Success! LlmAgent accepted raw function.")
18
- except Exception as e:
19
- print(f"Failed with raw function: {e}")
20
-
21
- try:
22
- print("\nTrying to init LlmAgent with FunctionTool...")
23
- agent = LlmAgent(
24
- name="test",
25
- model="gemini-flash-latest",
26
- instruction="test",
27
- tools=[FunctionTool(my_tool)]
28
- )
29
- print("Success! LlmAgent accepted FunctionTool.")
30
- except Exception as e:
31
- print(f"Failed with FunctionTool: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
verify_output.txt DELETED
Binary file (5.74 kB)
 
verify_output_2.txt DELETED
Binary file (5.84 kB)
 
verify_phase1.py DELETED
@@ -1,34 +0,0 @@
1
-
2
- import sys
3
- import os
4
-
5
- # Add project root to path
6
- sys.path.append(os.getcwd())
7
-
8
- print("Verifying Phase 1 Implementation...")
9
-
10
- try:
11
- from app.agents.langchain_mathminds import MathMindsLangChainAgent
12
- print("βœ… MathMindsLangChainAgent imported successfully (Syntax Check Passed).")
13
- except ImportError as e:
14
- print(f"⚠️ Import Error for Agent (Likely missing dependencies): {e}")
15
- except Exception as e:
16
- print(f"❌ Syntax/Runtime Error in Agent: {e}")
17
-
18
- try:
19
- from app.tools.data_processor import DataProcessor
20
- print("βœ… DataProcessor imported successfully (Syntax Check Passed).")
21
- except ImportError as e:
22
- print(f"⚠️ Import Error for DataProcessor (Likely missing dependencies): {e}")
23
- except Exception as e:
24
- print(f"❌ Syntax/Runtime Error in DataProcessor: {e}")
25
-
26
- try:
27
- from app.tools.advanced_ocr import AdvancedOCR
28
- print("βœ… AdvancedOCR imported successfully (Syntax Check Passed).")
29
- except ImportError as e:
30
- print(f"⚠️ Import Error for AdvancedOCR (Likely missing dependencies): {e}")
31
- except Exception as e:
32
- print(f"❌ Syntax/Runtime Error in AdvancedOCR: {e}")
33
-
34
- print("\nVerification Complete. If you see 'Import Error', please run: pip install -r requirements.txt")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
verify_phase2.py DELETED
@@ -1,26 +0,0 @@
1
-
2
- import sys
3
- import os
4
-
5
- # Add project root to path
6
- sys.path.append(os.getcwd())
7
-
8
- print("Verifying Phase 2 Implementation...")
9
-
10
- try:
11
- from app.models.vertex_gemini import VertexGeminiModel
12
- print("βœ… VertexGeminiModel imported successfully.")
13
- except ImportError as e:
14
- print(f"⚠️ Import Error for VertexGeminiModel: {e}")
15
- except Exception as e:
16
- print(f"❌ Error in VertexGeminiModel: {e}")
17
-
18
- try:
19
- from app.database.firestore_client import FirestoreClient
20
- print("βœ… FirestoreClient imported successfully.")
21
- except ImportError as e:
22
- print(f"⚠️ Import Error for FirestoreClient: {e}")
23
- except Exception as e:
24
- print(f"❌ Error in FirestoreClient: {e}")
25
-
26
- print("\nVerification Complete.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
verify_phase3.py DELETED
@@ -1,38 +0,0 @@
1
-
2
- import sys
3
- import os
4
- import yaml
5
-
6
- # Add project root to path
7
- sys.path.append(os.getcwd())
8
-
9
- print("Verifying Phase 3 Implementation...")
10
-
11
- # 1. Verify Worker Import
12
- try:
13
- from app.worker import scrape_web_task
14
- print("βœ… Celery Task 'scrape_web_task' imported successfully.")
15
- except ImportError as e:
16
- print(f"❌ Import Error for app.worker: {e}")
17
-
18
- # 2. Verify Orchestrator Import (Static Check not easy, so we check if file content has changed)
19
- # We trust the file write, but let's check docker-compose.yml
20
- try:
21
- with open("docker-compose.yml", "r") as f:
22
- dc = yaml.safe_load(f)
23
-
24
- services = dc.get("services", {})
25
- if "worker" in services:
26
- print("βœ… Docker Compose: 'worker' service found.")
27
- else:
28
- print("❌ Docker Compose: 'worker' service MISSING.")
29
-
30
- if "n8n" in services:
31
- print("βœ… Docker Compose: 'n8n' service found.")
32
- else:
33
- print("❌ Docker Compose: 'n8n' service MISSING.")
34
-
35
- except Exception as e:
36
- print(f"❌ Error reading docker-compose.yml: {e}")
37
-
38
- print("\nVerification Complete.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
verify_phase4.py DELETED
@@ -1,26 +0,0 @@
1
-
2
- import sys
3
- import os
4
-
5
- # Add project root to path
6
- sys.path.append(os.getcwd())
7
-
8
- print("Verifying Phase 4 Implementation...")
9
-
10
- try:
11
- import gradio_demo
12
- print("βœ… Gradio Demo imported successfully (Syntax Check Passed).")
13
- except ImportError as e:
14
- print(f"⚠️ Import Error for Gradio Demo (Likely missing dependencies): {e}")
15
- except Exception as e:
16
- print(f"❌ Syntax/Runtime Error in Gradio Demo: {e}")
17
-
18
- try:
19
- from app.tools.selenium_scraper import SeleniumScraper
20
- print("βœ… SeleniumScraper imported successfully (Syntax Check Passed).")
21
- except ImportError as e:
22
- print(f"⚠️ Import Error for SeleniumScraper (Likely missing dependencies): {e}")
23
- except Exception as e:
24
- print(f"❌ Syntax/Runtime Error in SeleniumScraper: {e}")
25
-
26
- print("\nVerification Complete.")