False45 commited on
Commit
6951cf0
·
1 Parent(s): bf47217

fix: resolve Groq fallback crash, allow SSE to correctly return 200 OK error streams, and implement Custom Evaluator API Key Settings modal

Browse files
src/api/v1/chat.py CHANGED
@@ -8,7 +8,7 @@ through the MathAIEngine (RAG + symbolic math + LLM with fallback).
8
  import logging
9
  from typing import Optional
10
 
11
- from fastapi import APIRouter, HTTPException, UploadFile, File, Form, Depends, Request
12
  from fastapi.responses import StreamingResponse
13
  from pydantic import BaseModel, Field
14
 
@@ -103,47 +103,73 @@ async def chat(request: Request, payload: ChatRequest, uid: str = Depends(verify
103
 
104
  @router.post("/chat/stream")
105
  @limiter.limit("20/minute")
106
- async def chat_stream(request: Request, payload: ChatRequest, uid: str = Depends(verify_firebase_token)):
107
- """Stream a math solution token-by-token using SSE.
108
-
109
- Requires USE_GEMINI=true to function correctly.
110
  """
111
- if not settings.use_gemini:
112
- raise HTTPException(status_code=400, detail="Streaming requires Gemini API to be enabled.")
113
 
114
  from backend.src.services.gemini_service import GeminiService
115
  from backend.src.services.memory_service import MongoDBChatMemory
116
 
117
  try:
118
- # We manually orchestrate the context retrieval for streaming
119
  store = build_pipeline()
120
  engine = MathAIEngine(vector_store=store, session_id=payload.session_id)
121
  engine.memory.uid = uid
122
 
123
- # 1. Get context and history
124
  source_docs, context = engine._retrieve_context(payload.query)
125
  chat_history = engine.memory.get_langchain_messages(limit=4)
126
  engine.memory.add_message("human", payload.query)
127
 
128
- gemini = GeminiService()
129
 
130
  import json
131
  async def generate():
132
  full_response = ""
133
  try:
 
 
 
134
  async for chunk in gemini.stream(payload.query, context=context, chat_history=chat_history):
135
  full_response += chunk
136
- # Format as SSE
137
  yield f"data: {json.dumps({'content': chunk, 'type': 'token'})}\n\n"
138
 
139
- # Save full response to memory after streaming completes
140
  engine.memory.add_message("assistant", full_response)
141
  yield "data: [DONE]\n\n"
142
  except Exception as e:
143
- logger.error(f"Streaming error: {e}")
144
- err_msg = f"\n\n⚠️ Streaming error: {e}"
145
- yield f"data: {json.dumps({'content': err_msg, 'type': 'token'})}\n\n"
146
- yield "data: [DONE]\n\n"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
147
 
148
  return StreamingResponse(generate(), media_type="text/event-stream")
149
 
@@ -154,14 +180,14 @@ async def chat_stream(request: Request, payload: ChatRequest, uid: str = Depends
154
 
155
  @router.post("/vision/extract", response_model=VisionResponse)
156
  @limiter.limit("20/minute")
157
- async def extract_math_from_image(request: Request, file: UploadFile = File(...), solve: bool = Form(False), uid: str = Depends(verify_firebase_token)):
158
  """Extract math from an uploaded image using Gemini Vision."""
159
  if not settings.use_gemini:
160
  raise HTTPException(status_code=400, detail="Vision extraction requires Gemini API.")
161
 
162
  try:
163
  image_bytes = await file.read()
164
- vision_service = GeminiVisionService()
165
 
166
  if solve:
167
  result = vision_service.extract_and_solve(image_bytes, mime_type=file.content_type)
 
8
  import logging
9
  from typing import Optional
10
 
11
+ from fastapi import APIRouter, HTTPException, UploadFile, File, Form, Depends, Request, Header
12
  from fastapi.responses import StreamingResponse
13
  from pydantic import BaseModel, Field
14
 
 
103
 
104
  @router.post("/chat/stream")
105
  @limiter.limit("20/minute")
106
+ async def chat_stream(request: Request, payload: ChatRequest, uid: str = Depends(verify_firebase_token), x_gemini_api_key: Optional[str] = Header(None)):
107
+ """
108
+ Stream a response from the AI Engine to the frontend using SSE.
 
109
  """
 
 
110
 
111
  from backend.src.services.gemini_service import GeminiService
112
  from backend.src.services.memory_service import MongoDBChatMemory
113
 
114
  try:
 
115
  store = build_pipeline()
116
  engine = MathAIEngine(vector_store=store, session_id=payload.session_id)
117
  engine.memory.uid = uid
118
 
 
119
  source_docs, context = engine._retrieve_context(payload.query)
120
  chat_history = engine.memory.get_langchain_messages(limit=4)
121
  engine.memory.add_message("human", payload.query)
122
 
123
+ gemini = GeminiService(custom_api_key=x_gemini_api_key)
124
 
125
  import json
126
  async def generate():
127
  full_response = ""
128
  try:
129
+ if not settings.use_gemini and not x_gemini_api_key:
130
+ raise Exception("Gemini is disabled via USE_GEMINI=false. Bypassing to fallback.")
131
+
132
  async for chunk in gemini.stream(payload.query, context=context, chat_history=chat_history):
133
  full_response += chunk
 
134
  yield f"data: {json.dumps({'content': chunk, 'type': 'token'})}\n\n"
135
 
 
136
  engine.memory.add_message("assistant", full_response)
137
  yield "data: [DONE]\n\n"
138
  except Exception as e:
139
+ logger.error(f"Gemini Streaming error: {e}. Falling back to Groq...")
140
+
141
+ try:
142
+ engine.llm = engine._init_llm() # Initialize Groq
143
+
144
+ # Build simple prompt for Groq fallback
145
+ system_text = "You are an advanced AI math tutor."
146
+ parts = [{"role": "system", "content": system_text}]
147
+ if context:
148
+ parts.append({"role": "system", "content": f"Context: {context}"})
149
+ for msg in chat_history:
150
+ parts.append({"role": msg.type, "content": msg.content})
151
+ parts.append({"role": "user", "content": payload.query})
152
+
153
+ from langchain_core.messages import SystemMessage, HumanMessage, AIMessage
154
+ langchain_msgs = []
155
+ for p in parts:
156
+ if p["role"] == "system": langchain_msgs.append(SystemMessage(content=p["content"]))
157
+ elif p["role"] == "user" or p["role"] == "human": langchain_msgs.append(HumanMessage(content=p["content"]))
158
+ else: langchain_msgs.append(AIMessage(content=p["content"]))
159
+
160
+ async for chunk in engine.llm.astream(langchain_msgs):
161
+ if chunk.content:
162
+ full_response += chunk.content
163
+ yield f"data: {json.dumps({'content': chunk.content, 'type': 'token'})}\n\n"
164
+
165
+ engine.memory.add_message("assistant", full_response)
166
+ yield "data: [DONE]\n\n"
167
+
168
+ except Exception as e2:
169
+ logger.error(f"Groq Streaming error: {e2}")
170
+ err_msg = f"\n\n⚠️ Streaming error: All Gemini and Groq models failed. API quotas might be exhausted."
171
+ yield f"data: {json.dumps({'content': err_msg, 'type': 'token'})}\n\n"
172
+ yield "data: [DONE]\n\n"
173
 
174
  return StreamingResponse(generate(), media_type="text/event-stream")
175
 
 
180
 
181
  @router.post("/vision/extract", response_model=VisionResponse)
182
  @limiter.limit("20/minute")
183
+ async def extract_math_from_image(request: Request, file: UploadFile = File(...), solve: bool = Form(False), uid: str = Depends(verify_firebase_token), x_gemini_api_key: Optional[str] = Header(None)):
184
  """Extract math from an uploaded image using Gemini Vision."""
185
  if not settings.use_gemini:
186
  raise HTTPException(status_code=400, detail="Vision extraction requires Gemini API.")
187
 
188
  try:
189
  image_bytes = await file.read()
190
+ vision_service = GeminiVisionService(custom_api_key=x_gemini_api_key)
191
 
192
  if solve:
193
  result = vision_service.extract_and_solve(image_bytes, mime_type=file.content_type)
src/services/gemini_service.py CHANGED
@@ -21,10 +21,15 @@ logger = logging.getLogger("math_assistant.gemini")
21
 
22
  _GEMINI_CLIENT = None
23
 
24
- def _get_gemini_client():
 
 
 
 
 
 
25
  global _GEMINI_CLIENT
26
  if _GEMINI_CLIENT is None:
27
- from google import genai
28
  api_key = settings.gemini_api_key
29
  if not api_key:
30
  raise ValueError(
@@ -60,10 +65,11 @@ def _get_safety_settings():
60
  class GeminiService:
61
  """Google Gemini text-based math query service with automatic fallback."""
62
 
63
- def __init__(self):
64
  self.primary_model = settings.gemini_primary_model
65
  self.fallback_model = settings.gemini_fallback_model
66
  self.prompt_service = PromptService()
 
67
 
68
  def query(self, user_input: str, context: str = "", chat_history: list = None) -> str:
69
  """Send a math query to Gemini and return the response."""
@@ -86,7 +92,7 @@ class GeminiService:
86
 
87
  models_to_try = [self.primary_model, self.fallback_model]
88
  last_error = None
89
- client = _get_gemini_client()
90
 
91
  config = types.GenerateContentConfig(
92
  temperature=settings.gemini_temperature,
@@ -146,7 +152,7 @@ class GeminiService:
146
  parts.append(f"Student: {user_input}\nTeacher: ")
147
  prompt = "".join(parts)
148
 
149
- client = _get_gemini_client()
150
  config = types.GenerateContentConfig(
151
  temperature=settings.gemini_temperature,
152
  max_output_tokens=settings.gemini_max_tokens,
@@ -193,12 +199,13 @@ class GeminiService:
193
  class GeminiVisionService:
194
  """Gemini Vision for extracting math from images."""
195
 
196
- def __init__(self):
197
  self.model_name = settings.gemini_vision_model
 
198
 
199
  def extract_math_from_image(self, image_bytes: bytes, mime_type: str = "image/jpeg") -> str:
200
  from google.genai import types
201
- client = _get_gemini_client()
202
 
203
  prompt = (
204
  "You are a math OCR expert. Extract ALL mathematical content from this image.\n\n"
@@ -230,7 +237,7 @@ class GeminiVisionService:
230
 
231
  def extract_and_solve(self, image_bytes: bytes, mime_type: str = "image/jpeg") -> Dict[str, str]:
232
  from google.genai import types
233
- client = _get_gemini_client()
234
 
235
  prompt = (
236
  "You are an Indian math teacher. Look at this image.\n\n"
 
21
 
22
  _GEMINI_CLIENT = None
23
 
24
+ def _get_gemini_client(custom_api_key: str = None):
25
+ from google import genai
26
+
27
+ # If a custom key is provided, always create a fresh client
28
+ if custom_api_key:
29
+ return genai.Client(api_key=custom_api_key)
30
+
31
  global _GEMINI_CLIENT
32
  if _GEMINI_CLIENT is None:
 
33
  api_key = settings.gemini_api_key
34
  if not api_key:
35
  raise ValueError(
 
65
  class GeminiService:
66
  """Google Gemini text-based math query service with automatic fallback."""
67
 
68
+ def __init__(self, custom_api_key: str = None):
69
  self.primary_model = settings.gemini_primary_model
70
  self.fallback_model = settings.gemini_fallback_model
71
  self.prompt_service = PromptService()
72
+ self.custom_api_key = custom_api_key
73
 
74
  def query(self, user_input: str, context: str = "", chat_history: list = None) -> str:
75
  """Send a math query to Gemini and return the response."""
 
92
 
93
  models_to_try = [self.primary_model, self.fallback_model]
94
  last_error = None
95
+ client = _get_gemini_client(self.custom_api_key)
96
 
97
  config = types.GenerateContentConfig(
98
  temperature=settings.gemini_temperature,
 
152
  parts.append(f"Student: {user_input}\nTeacher: ")
153
  prompt = "".join(parts)
154
 
155
+ client = _get_gemini_client(self.custom_api_key)
156
  config = types.GenerateContentConfig(
157
  temperature=settings.gemini_temperature,
158
  max_output_tokens=settings.gemini_max_tokens,
 
199
  class GeminiVisionService:
200
  """Gemini Vision for extracting math from images."""
201
 
202
+ def __init__(self, custom_api_key: str = None):
203
  self.model_name = settings.gemini_vision_model
204
+ self.custom_api_key = custom_api_key
205
 
206
  def extract_math_from_image(self, image_bytes: bytes, mime_type: str = "image/jpeg") -> str:
207
  from google.genai import types
208
+ client = _get_gemini_client(self.custom_api_key)
209
 
210
  prompt = (
211
  "You are a math OCR expert. Extract ALL mathematical content from this image.\n\n"
 
237
 
238
  def extract_and_solve(self, image_bytes: bytes, mime_type: str = "image/jpeg") -> Dict[str, str]:
239
  from google.genai import types
240
+ client = _get_gemini_client(self.custom_api_key)
241
 
242
  prompt = (
243
  "You are an Indian math teacher. Look at this image.\n\n"
src/services/llm_service.py CHANGED
@@ -49,7 +49,12 @@ class MathAIEngine:
49
  """Orchestrates RAG retrieval, symbolic math, and LLM calls for math tutoring."""
50
 
51
  def __init__(self, vector_store=None, session_id: str = "default"):
52
- self.llm = self._init_llm() if not settings.use_gemini else None
 
 
 
 
 
53
  self.vector_store = vector_store
54
  self.memory = MongoDBChatMemory(session_id=session_id)
55
  self.symbolic = SymbolicMathEngine()
 
49
  """Orchestrates RAG retrieval, symbolic math, and LLM calls for math tutoring."""
50
 
51
  def __init__(self, vector_store=None, session_id: str = "default"):
52
+ try:
53
+ self.llm = self._init_llm() if not settings.use_gemini else None
54
+ except Exception as e:
55
+ logger.warning(f"Failed to initialize Groq LLM: {e}")
56
+ self.llm = None
57
+
58
  self.vector_store = vector_store
59
  self.memory = MongoDBChatMemory(session_id=session_id)
60
  self.symbolic = SymbolicMathEngine()
test_stream.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import requests
2
+ import json
3
+
4
+ url = "http://localhost:8080/api/v1/chat/stream"
5
+ headers = {"Content-Type": "application/json"}
6
+ payload = {
7
+ "query": "What is 2+2?",
8
+ "session_id": "test-123"
9
+ }
10
+
11
+ print("Sending request to chat/stream...")
12
+ response = requests.post(url, headers=headers, json=payload, stream=True)
13
+ print(f"Status: {response.status_code}")
14
+
15
+ for line in response.iter_lines():
16
+ if line:
17
+ decoded_line = line.decode('utf-8')
18
+ print(decoded_line)
19
+
test_stream2.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import requests
2
+ import json
3
+
4
+ url = "http://localhost:8080/api/v1/chat/stream"
5
+ headers = {"Content-Type": "application/json"}
6
+ payload = {
7
+ "query": "What is 2+2?",
8
+ "session_id": "test-123"
9
+ }
10
+
11
+ print("Sending request to chat/stream...")
12
+ response = requests.post(url, headers=headers, json=payload, stream=True)
13
+ print(f"Status: {response.status_code}")
14
+
15
+ for line in response.iter_lines():
16
+ if line:
17
+ decoded_line = line.decode('utf-8')
18
+ print(decoded_line)
19
+