Spaces:
Running
Running
Commit Β·
3a4bdd3
1
Parent(s): 3659da9
Sub-agent with native grounding
Browse filesThis view is limited to 50 files because it contains too many changes. Β See raw diff
- .gitignore +22 -0
- app/agents/adk_mathminds.py +326 -192
- app/api/deps.py +85 -28
- app/api/main.py +290 -322
- app/core/math_normalizer.py +32 -13
- app/core/orchestrator.py +257 -128
- app/core/schemas.py +66 -52
- app/core/settings.py +1 -1
- app/core/sympy_solver.py +120 -0
- app/memory/cache.py +123 -83
- app/memory/semantic_cache.py +245 -0
- app/services/automation.py +15 -2
- app/tools/symbolic_solver.py +0 -162
- check_agent.py +0 -12
- check_redis.py +0 -18
- db_diag.py +0 -21
- debug_adk.py +0 -23
- debug_adk_events.py +0 -91
- debug_celery_worker.py +0 -49
- debug_env.py +0 -23
- debug_history.py +0 -15
- debug_history_all.py +0 -13
- debug_import.py +0 -21
- debug_models.py +0 -30
- debug_response.py +0 -50
- debug_scraper.py +0 -21
- debug_scraper_manual.py +0 -41
- debug_ui_v2.py +0 -30
- find_embedding_models.py +0 -11
- frontend/app.py +548 -581
- inspect_adk.py +0 -58
- inspect_agent_class.py +0 -19
- locate_adk_modules.py +0 -17
- reproduce_crash.py +0 -61
- test_api.py +0 -21
- test_gemini_name.py +0 -13
- test_lang.py +0 -12
- test_mongo_connection.py +0 -19
- test_orch.py +0 -27
- test_playwright.py +0 -22
- test_request.py +0 -23
- test_scraper_local.py +0 -33
- test_sync_playwright.py +0 -18
- test_tool_wrapping.py +0 -31
- verify_output.txt +0 -0
- verify_output_2.txt +0 -0
- verify_phase1.py +0 -34
- verify_phase2.py +0 -26
- verify_phase3.py +0 -38
- verify_phase4.py +0 -26
.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 |
-
|
|
|
|
| 40 |
self.redis_client = redis_client
|
|
|
|
| 41 |
|
| 42 |
if not self.api_key:
|
| 43 |
-
logger.warning("No Google API Key found.
|
|
|
|
|
|
|
| 44 |
|
| 45 |
-
#
|
| 46 |
-
self.
|
| 47 |
-
self.
|
| 48 |
-
self.similar_finder = SimilarProblemFinder()
|
| 49 |
self.python_executor = PythonInterpreter()
|
| 50 |
-
self.advanced_ocr
|
| 51 |
self.vision_analyzer = VisionAnalyzer()
|
| 52 |
|
| 53 |
-
#
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
""
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
try:
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 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 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 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"
|
| 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
|
| 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
|
| 123 |
except Exception as e:
|
| 124 |
-
return f"
|
| 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
|
| 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
|
| 143 |
-
|
| 144 |
-
|
|
|
|
|
|
|
|
|
|
| 145 |
|
| 146 |
-
def find_similar_problems(query: str) -> str:
|
| 147 |
-
|
| 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 +=
|
|
|
|
|
|
|
|
|
|
| 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
|
| 167 |
return f"Automation triggered: {result.get('status')}"
|
| 168 |
except Exception as e:
|
| 169 |
-
return f"Automation failed: {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 170 |
|
| 171 |
-
|
| 172 |
-
self.agent = Agent(
|
| 173 |
name="math_minds_core",
|
| 174 |
-
model=
|
| 175 |
-
tools=
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 199 |
)
|
| 200 |
|
| 201 |
-
|
| 202 |
-
|
| 203 |
app_name="mathminds",
|
| 204 |
-
agent=self.
|
| 205 |
-
session_service=self.session_service
|
| 206 |
)
|
| 207 |
|
| 208 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 226 |
if self.redis_client:
|
| 227 |
allowed, used, limit = check_and_increment(self.redis_client, user_id)
|
| 228 |
if not allowed:
|
| 229 |
-
|
|
|
|
| 230 |
return
|
| 231 |
-
|
| 232 |
-
logger.warning("Redis unavailable β skipping quota check (failing open).")
|
| 233 |
|
| 234 |
-
#
|
| 235 |
try:
|
| 236 |
existing = await self.session_service.get_session(
|
| 237 |
-
app_name="mathminds",
|
|
|
|
|
|
|
| 238 |
)
|
| 239 |
if not existing:
|
| 240 |
await self.session_service.create_session(
|
| 241 |
-
app_name="mathminds",
|
|
|
|
|
|
|
| 242 |
)
|
| 243 |
except Exception as e:
|
| 244 |
-
logger.warning(f"Session setup warning: {e}")
|
| 245 |
|
| 246 |
-
#
|
| 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 =
|
| 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 |
-
#
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
| 275 |
try:
|
| 276 |
is_final = event.is_final_response()
|
| 277 |
except Exception:
|
| 278 |
is_final = False
|
| 279 |
-
|
| 280 |
-
#
|
|
|
|
| 281 |
if hasattr(event, "content") and event.content and event.content.parts:
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
| 299 |
for fc in event.get_function_calls():
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
|
| 305 |
-
# ββ Capture Tool Response ββ
|
| 306 |
for fr in event.get_function_responses():
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 311 |
|
| 312 |
except Exception as e:
|
| 313 |
-
|
| 314 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 315 |
finally:
|
| 316 |
try:
|
| 317 |
current_image_ctx.reset(token)
|
| 318 |
-
except ValueError:
|
| 319 |
-
|
| 320 |
-
|
| 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 |
-
|
| 4 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
"""
|
| 6 |
|
| 7 |
-
import
|
| 8 |
-
import redis
|
| 9 |
-
import pymongo
|
| 10 |
from functools import lru_cache
|
|
|
|
| 11 |
from typing import Optional
|
| 12 |
-
|
|
|
|
|
|
|
| 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.
|
| 18 |
|
| 19 |
logger = logging.getLogger(__name__)
|
| 20 |
|
| 21 |
-
# ββ
|
| 22 |
-
_redis_pool:
|
| 23 |
-
|
|
|
|
| 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
|
| 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 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 59 |
return _mongo_client
|
| 60 |
except Exception as e:
|
| 61 |
-
logger.error(f"Failed to create
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
|
| 77 |
-
|
| 78 |
-
|
|
|
|
|
|
|
| 79 |
|
| 80 |
|
| 81 |
def get_orchestrator() -> Orchestrator:
|
| 82 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 92 |
|
| 93 |
-
|
| 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 |
-
|
|
|
|
| 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
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
from fastapi import FastAPI, HTTPException, status, Depends, Request
|
| 17 |
-
from fastapi.responses import JSONResponse
|
| 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
|
| 24 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
from app.core.logging_config import configure_logging
|
| 26 |
-
from app.core.
|
| 27 |
-
from app.core.
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
|
| 33 |
-
from
|
| 34 |
|
| 35 |
-
#
|
| 36 |
configure_logging()
|
| 37 |
logger = logging.getLogger(__name__)
|
| 38 |
|
|
|
|
| 39 |
|
| 40 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
@asynccontextmanager
|
| 42 |
async def lifespan(app: FastAPI):
|
| 43 |
-
|
| 44 |
-
|
| 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"
|
| 60 |
-
|
| 61 |
-
# or let the first request fail.
|
| 62 |
-
|
| 63 |
yield
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
|
| 69 |
app = FastAPI(
|
| 70 |
title="MathMinds AI API",
|
| 71 |
-
description="
|
| 72 |
version="1.0.0",
|
| 73 |
-
lifespan=lifespan
|
| 74 |
)
|
| 75 |
-
@app.get("/")
|
| 76 |
-
async def root():
|
| 77 |
-
return {"message": "MathMinds API running"}
|
| 78 |
|
| 79 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
app.add_middleware(
|
| 81 |
CORSMiddleware,
|
| 82 |
-
allow_origins=
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
try:
|
| 111 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
return JSONResponse(
|
| 127 |
-
status_code=
|
| 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.
|
| 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 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
ErrorCodes.
|
| 150 |
-
ErrorCodes.
|
| 151 |
-
ErrorCodes.
|
| 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 |
-
|
| 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=
|
| 165 |
content={
|
| 166 |
"status": "error",
|
| 167 |
"error": exc.message,
|
| 168 |
"error_code": exc.code,
|
| 169 |
"metadata": {
|
| 170 |
"request_id": request_id,
|
| 171 |
-
"timestamp": datetime.
|
| 172 |
-
}
|
| 173 |
-
}
|
| 174 |
)
|
| 175 |
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 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
|
| 204 |
-
|
| 205 |
-
|
| 206 |
"status": "healthy",
|
| 207 |
-
"
|
| 208 |
-
"
|
| 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 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
health_status["components"]["mongodb"] = "β healthy"
|
| 230 |
-
else:
|
| 231 |
-
health_status["components"]["mongodb"] = "β unavailable"
|
| 232 |
except Exception as e:
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
try:
|
| 237 |
-
#
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
else:
|
| 242 |
-
health_status["components"]["gemini"] = "β not configured"
|
| 243 |
except Exception as e:
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
|
|
|
|
|
|
| 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)
|
| 259 |
):
|
| 260 |
"""
|
| 261 |
-
|
| 262 |
"""
|
| 263 |
-
# Grab request_id from state
|
| 264 |
-
req_id = getattr(request.state, "request_id", str(uuid.uuid4()))
|
| 265 |
|
| 266 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 267 |
raise HTTPException(
|
| 268 |
-
status_code=
|
| 269 |
-
detail="
|
| 270 |
)
|
| 271 |
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 277 |
try:
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 327 |
|
| 328 |
@app.get("/chat/sessions", response_model=List[ChatSession])
|
| 329 |
-
async def
|
| 330 |
current_user: dict = Depends(get_current_user),
|
| 331 |
-
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
|
| 338 |
current_user: dict = Depends(get_current_user),
|
| 339 |
-
db_manager
|
| 340 |
):
|
| 341 |
-
|
| 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.
|
| 349 |
}
|
| 350 |
-
|
|
|
|
|
|
|
| 351 |
|
| 352 |
@app.get("/chat/sessions/{session_id}/messages", response_model=List[Message])
|
| 353 |
-
async def
|
| 354 |
session_id: str,
|
| 355 |
current_user: dict = Depends(get_current_user),
|
| 356 |
-
db_manager
|
| 357 |
):
|
| 358 |
-
|
| 359 |
history = db_manager.get_chat_history(current_user["uid"], session_id)
|
| 360 |
-
|
| 361 |
-
|
|
|
|
|
|
|
| 362 |
return history
|
| 363 |
|
|
|
|
| 364 |
@app.patch("/chat/sessions/{session_id}")
|
| 365 |
-
async def
|
| 366 |
session_id: str,
|
| 367 |
rename_data: SessionRename,
|
| 368 |
current_user: dict = Depends(get_current_user),
|
| 369 |
-
db_manager
|
| 370 |
):
|
| 371 |
-
|
| 372 |
-
if db_manager.rename_session(
|
| 373 |
-
|
| 374 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 375 |
|
| 376 |
@app.delete("/chat/sessions/{session_id}")
|
| 377 |
-
async def
|
| 378 |
session_id: str,
|
| 379 |
current_user: dict = Depends(get_current_user),
|
| 380 |
-
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 |
-
|
| 388 |
-
|
| 389 |
-
from typing import List, Optional
|
| 390 |
|
| 391 |
-
|
| 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 |
-
|
| 424 |
-
|
| 425 |
-
|
| 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 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 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
|
| 109 |
-
if self._is_arithmetic(clean_text):
|
|
|
|
| 110 |
return MathIntent(
|
| 111 |
intent="arithmetic",
|
| 112 |
-
expression=
|
| 113 |
original_query=text
|
| 114 |
)
|
| 115 |
|
| 116 |
return None
|
| 117 |
|
| 118 |
def _clean_expression(self, text: str) -> str:
|
| 119 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
text = text.strip()
|
| 121 |
-
|
| 122 |
-
#
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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":
|
| 66 |
-
"model":
|
| 67 |
-
"tools_used":
|
| 68 |
-
"logic_trace": []
|
| 69 |
},
|
| 70 |
}
|
| 71 |
|
| 72 |
try:
|
| 73 |
# ββ 1. Input processing βββββββββββββββββββββββββββββοΏ½οΏ½οΏ½βββββββββββββ
|
| 74 |
-
processed = self.input_processor.process_compound(
|
|
|
|
|
|
|
| 75 |
if not processed.is_valid:
|
| 76 |
yield {"type": "error", "content": processed.error_message}
|
| 77 |
return
|
| 78 |
|
| 79 |
-
query
|
| 80 |
-
image_data = processed.metadata.get("image_data")
|
| 81 |
|
| 82 |
-
# 1.5. Persist user message (
|
| 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 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
if user_id and session_id:
|
| 102 |
-
await self._persist_log(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
return
|
| 104 |
-
else:
|
| 105 |
-
cache_key = None
|
| 106 |
|
| 107 |
-
# ββ 3.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
if not image_data:
|
| 109 |
-
|
| 110 |
-
if
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
"
|
| 116 |
-
"
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
yield event
|
| 131 |
-
|
| 132 |
-
|
|
|
|
| 133 |
yield event
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
elif event["type"] == "observation": label = "ποΈ "
|
| 138 |
-
|
| 139 |
-
result_schema["metadata"]["logic_trace"].append(f"{label}{event['content']}")
|
| 140 |
yield event
|
| 141 |
-
|
|
|
|
| 142 |
yield event
|
|
|
|
| 143 |
else:
|
| 144 |
-
#
|
| 145 |
-
|
| 146 |
-
|
|
|
|
| 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 |
-
|
| 154 |
-
|
|
|
|
|
|
|
| 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 |
-
"""
|
| 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,
|
| 175 |
-
|
|
|
|
| 176 |
)
|
| 177 |
if settings.ENABLE_CACHE and cache_key:
|
|
|
|
| 178 |
self.cache_manager.set_cached_answer(cache_key, schema)
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
if
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 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 |
-
|
| 8 |
-
|
| 9 |
-
"""
|
| 10 |
-
|
| 11 |
-
|
| 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 |
-
|
| 33 |
-
|
| 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 |
-
|
| 42 |
-
""
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 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:
|
| 67 |
-
content:
|
| 68 |
-
timestamp:
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
|
| 73 |
class ChatSession(BaseModel):
|
| 74 |
session_id: str
|
| 75 |
-
title:
|
| 76 |
created_at: datetime
|
| 77 |
-
|
| 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:
|
| 86 |
-
password:
|
| 87 |
full_name: Optional[str] = None
|
| 88 |
|
|
|
|
| 89 |
class UserLogin(BaseModel):
|
| 90 |
-
email:
|
| 91 |
password: str = Field(..., max_length=72)
|
| 92 |
|
|
|
|
| 93 |
class TokenResponse(BaseModel):
|
| 94 |
access_token: str
|
| 95 |
-
token_type:
|
| 96 |
-
user_id:
|
| 97 |
-
email:
|
|
|
|
| 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 =
|
| 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 |
-
|
|
|
|
| 11 |
logger = logging.getLogger(__name__)
|
| 12 |
|
|
|
|
| 13 |
class CacheManager:
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 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(
|
|
|
|
|
|
|
|
|
|
| 33 |
else:
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
|
|
|
|
|
|
| 39 |
self.redis_client.ping()
|
| 40 |
-
|
| 41 |
-
|
|
|
|
| 42 |
except RedisError as e:
|
| 43 |
-
|
|
|
|
| 44 |
self.redis_client = None
|
| 45 |
|
| 46 |
-
|
|
|
|
| 47 |
|
| 48 |
-
def
|
| 49 |
-
""
|
| 50 |
-
Retrieve a cached answer by its hash key.
|
| 51 |
|
| 52 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
if data:
|
| 65 |
-
logger.
|
| 66 |
return json.loads(data)
|
| 67 |
-
|
| 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 |
-
|
| 77 |
-
"""
|
| 78 |
-
Cache an answer with a TTL.
|
| 79 |
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 94 |
-
self.
|
| 95 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
return True
|
|
|
|
| 97 |
except (RedisError, TypeError) as e:
|
| 98 |
-
|
| 99 |
-
logger.error(f"
|
| 100 |
return False
|
| 101 |
|
| 102 |
-
def set_if_not_exists(
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 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 |
-
|
| 120 |
-
|
| 121 |
-
|
|
|
|
|
|
|
| 122 |
result = self.redis_client.set(
|
| 123 |
-
|
| 124 |
-
serialized_data,
|
| 125 |
-
ex=ttl,
|
| 126 |
-
nx=True
|
| 127 |
)
|
|
|
|
| 128 |
return bool(result)
|
|
|
|
| 129 |
except Exception as e:
|
| 130 |
-
|
|
|
|
| 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":
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 17 |
-
#
|
| 18 |
-
#
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 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
|
| 98 |
-
.badge-purple{ background:
|
| 99 |
-
.badge-green
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
"""
|
| 132 |
-
|
| 133 |
-
st.session_state.
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
#
|
| 146 |
-
#
|
| 147 |
-
#
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
"""Fetch THIS user's chat sessions from the backend and populate state."""
|
| 156 |
try:
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 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 |
-
|
| 194 |
-
|
| 195 |
|
| 196 |
|
| 197 |
-
def
|
| 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 |
-
|
| 205 |
-
response = requests.get(
|
| 206 |
f"{BACKEND_URL}/chat/sessions/{session_id}/messages",
|
| 207 |
-
headers=
|
| 208 |
)
|
| 209 |
-
if
|
| 210 |
-
|
| 211 |
-
|
| 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 |
-
|
| 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
|
| 274 |
try:
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
if
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
load_sessions()
|
| 282 |
-
st.rerun()
|
| 283 |
-
else:
|
| 284 |
-
st.error("Failed to create new chat")
|
| 285 |
except Exception as e:
|
| 286 |
-
|
| 287 |
|
| 288 |
|
| 289 |
-
def
|
| 290 |
try:
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
except Exception as e:
|
| 302 |
-
|
| 303 |
|
| 304 |
|
| 305 |
-
def
|
| 306 |
try:
|
| 307 |
-
|
| 308 |
-
response = requests.patch(
|
| 309 |
f"{BACKEND_URL}/chat/sessions/{sid}",
|
| 310 |
-
|
| 311 |
)
|
| 312 |
-
|
| 313 |
-
load_sessions()
|
| 314 |
-
st.rerun()
|
| 315 |
-
else:
|
| 316 |
-
st.error("Failed to rename chat")
|
| 317 |
except Exception as e:
|
| 318 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 319 |
|
| 320 |
|
| 321 |
-
|
| 322 |
-
|
| 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);
|
| 331 |
-
|
| 332 |
-
<
|
|
|
|
| 333 |
</div>
|
| 334 |
""", unsafe_allow_html=True)
|
| 335 |
|
| 336 |
-
|
| 337 |
|
| 338 |
-
with
|
| 339 |
with st.form("login_form"):
|
| 340 |
-
email = st.text_input("Email",
|
| 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 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 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
|
| 365 |
with st.form("signup_form"):
|
| 366 |
-
new_email
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
st.error("Passwords do not match!")
|
| 374 |
else:
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 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 |
-
|
| 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=
|
| 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
|
| 437 |
-
levels
|
| 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
|
| 444 |
-
index=levels.index(data.get("math_level"
|
| 445 |
-
if data.get("math_level") in levels else 1
|
| 446 |
)
|
| 447 |
interests = st.multiselect(
|
| 448 |
-
"
|
| 449 |
-
default=[i for i in data.get("interests", []) if i in interests_all]
|
| 450 |
)
|
| 451 |
-
if st.form_submit_button("Save
|
| 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=
|
| 455 |
if r.status_code == 200:
|
| 456 |
-
st.success("
|
| 457 |
st.session_state.profile_data = payload
|
| 458 |
-
time.sleep(1)
|
| 459 |
-
st.rerun()
|
| 460 |
else:
|
| 461 |
-
st.error(f"
|
| 462 |
except Exception as e:
|
| 463 |
-
st.error(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 479 |
-
|
|
|
|
|
|
|
| 480 |
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
# No rerun needed here, just continue to render
|
| 486 |
|
| 487 |
-
# ββ
|
|
|
|
| 488 |
for msg in st.session_state.messages:
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 532 |
-
if text_prompt:
|
| 533 |
-
prompt = text_prompt
|
| 534 |
|
| 535 |
with tab_draw:
|
| 536 |
-
|
| 537 |
-
with
|
| 538 |
-
|
| 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 |
-
|
| 544 |
-
|
| 545 |
-
|
| 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 |
-
|
| 554 |
-
|
| 555 |
-
|
|
|
|
| 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 =
|
|
|
|
|
|
|
| 562 |
|
| 563 |
with tab_upload:
|
| 564 |
-
|
| 565 |
-
|
| 566 |
-
if
|
| 567 |
-
image_b64 = base64.b64encode(
|
| 568 |
-
prompt =
|
| 569 |
-
|
| 570 |
-
# ββ
|
|
|
|
|
|
|
|
|
|
| 571 |
if prompt:
|
| 572 |
-
|
| 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 |
-
|
| 697 |
-
|
| 698 |
-
|
| 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 |
-
|
| 707 |
-
|
| 708 |
-
|
| 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 |
-
|
| 717 |
-
|
| 718 |
-
|
| 719 |
-
|
| 720 |
-
|
| 721 |
-
|
| 722 |
-
|
| 723 |
-
|
| 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 |
-
|
|
|
|
| 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.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|