math-api / src /services /gemini_service.py
False45's picture
Configure Firebase for production and prepare for deployment
4f811d2
Raw History Blame Contribute Delete
13.3 kB
"""
Gemini Service — Google AI Studio integration for math tutoring.
Provides:
- GeminiService: Text-based math query processing with Gemini 2.0 Flash
- GeminiVisionService: Image math extraction using Gemini Vision
- Streaming support for token-by-token SSE delivery
- Automatic fallback from Gemini 2.0 Flash → Gemini 1.5 Flash on rate limits
Uses Google AI Studio free tier via the google-genai SDK.
"""
import time
import logging
from typing import Dict, Any, Optional, AsyncGenerator
from backend.src.config import settings
from backend.src.services.prompt_service import PromptService
logger = logging.getLogger("math_assistant.gemini")
_GEMINI_CLIENT = None
def _get_gemini_client(custom_api_key: str = None):
from google import genai
# If a custom key is provided, always create a fresh client
if custom_api_key:
return genai.Client(api_key=custom_api_key)
global _GEMINI_CLIENT
if _GEMINI_CLIENT is None:
api_key = settings.gemini_api_key
if not api_key:
raise ValueError(
"GEMINI_API_KEY not set. Get a free key at https://aistudio.google.com/apikey "
"and add it to your .env file."
)
_GEMINI_CLIENT = genai.Client(api_key=api_key)
logger.info("Initialized google-genai Client")
return _GEMINI_CLIENT
def _get_safety_settings():
from google.genai import types
return [
types.SafetySetting(
category=types.HarmCategory.HARM_CATEGORY_HATE_SPEECH,
threshold=types.HarmBlockThreshold.BLOCK_LOW_AND_ABOVE,
),
types.SafetySetting(
category=types.HarmCategory.HARM_CATEGORY_HARASSMENT,
threshold=types.HarmBlockThreshold.BLOCK_LOW_AND_ABOVE,
),
types.SafetySetting(
category=types.HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT,
threshold=types.HarmBlockThreshold.BLOCK_LOW_AND_ABOVE,
),
types.SafetySetting(
category=types.HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT,
threshold=types.HarmBlockThreshold.BLOCK_LOW_AND_ABOVE,
),
]
class GeminiService:
"""Google Gemini text-based math query service with automatic fallback."""
def __init__(self, custom_api_key: str = None):
self.primary_model = settings.gemini_primary_model
self.fallback_model = settings.gemini_fallback_model
self.prompt_service = PromptService()
self.custom_api_key = custom_api_key
def query(self, user_input: str, context: str = "", chat_history: list = None) -> str:
"""Send a math query to Gemini and return the response."""
from google.genai import types
system_template = self.prompt_service.get_system_prompt()
system_text = system_template.replace("{context}", context or "")
# Build conversation parts
parts = [system_text + "\n\n"]
if chat_history:
for msg in chat_history:
role = getattr(msg, "type", "human")
content = getattr(msg, "content", str(msg))
prefix = "Student: " if role == "human" else "Teacher: "
parts.append(f"{prefix}{content}\n")
parts.append(f"Student: {user_input}\nTeacher: ")
prompt = "".join(parts)
models_to_try = [self.primary_model, self.fallback_model]
last_error = None
client = _get_gemini_client(self.custom_api_key)
config = types.GenerateContentConfig(
temperature=settings.gemini_temperature,
max_output_tokens=settings.gemini_max_tokens,
safety_settings=_get_safety_settings()
)
for model_name in models_to_try:
for attempt in range(2):
try:
response = client.models.generate_content(
model=model_name,
contents=prompt,
config=config
)
if response.text:
answer = response.text.strip()
if model_name != self.primary_model:
answer = f"*(Using fallback: {model_name})*\n\n" + answer
return answer
else:
raise Exception("Empty response or content blocked by safety filters.")
except Exception as e:
last_error = e
err_str = str(e).lower()
logger.warning(f"Gemini {model_name} attempt {attempt+1} failed: {e}")
if "429" in str(e) or "resource_exhausted" in err_str or "quota" in err_str:
logger.info(f"Rate limit on {model_name}, trying fallback...")
time.sleep(1)
break # Skip to next model
elif "timeout" in err_str or "deadline" in err_str:
time.sleep(1)
continue
else:
break # Non-retryable
logger.error(f"All Gemini models failed: {last_error}")
return f"⚠️ Gemini API error: {last_error}"
async def stream(self, user_input: str, context: str = "", chat_history: list = None) -> AsyncGenerator[str, None]:
"""Stream a response token by token for SSE delivery."""
from google.genai import types
system_template = self.prompt_service.get_system_prompt()
system_text = system_template.replace("{context}", context or "")
parts = [system_text + "\n\n"]
if chat_history:
for msg in chat_history:
role = getattr(msg, "type", "human")
content = getattr(msg, "content", str(msg))
prefix = "Student: " if role == "human" else "Teacher: "
parts.append(f"{prefix}{content}\n")
parts.append(f"Student: {user_input}\nTeacher: ")
prompt = "".join(parts)
client = _get_gemini_client(self.custom_api_key)
config = types.GenerateContentConfig(
temperature=settings.gemini_temperature,
max_output_tokens=settings.gemini_max_tokens,
safety_settings=_get_safety_settings()
)
try:
response_stream = client.models.generate_content_stream(
model=self.primary_model,
contents=prompt,
config=config
)
for chunk in response_stream:
if chunk.text:
yield chunk.text
except Exception as e:
logger.error(f"Primary model {self.primary_model} streaming failed: {e}")
logger.info(f"Falling back to {self.fallback_model}...")
try:
response_stream = client.models.generate_content_stream(
model=self.fallback_model,
contents=prompt,
config=config
)
for chunk in response_stream:
if chunk.text:
yield chunk.text
except Exception as e2:
logger.error(f"Fallback model {self.fallback_model} streaming also failed: {e2}")
logger.info("Falling back to gemini-flash-lite-latest...")
try:
response_stream = client.models.generate_content_stream(
model="gemini-flash-lite-latest",
contents=prompt,
config=config
)
for chunk in response_stream:
if chunk.text:
yield chunk.text
except Exception as e3:
logger.error(f"All Gemini streaming models failed: {e3}")
yield f"\n\n⚠️ Streaming error: All Gemini models failed. You might be out of API quota."
class GeminiVisionService:
"""Gemini Vision for extracting math from images."""
def __init__(self, custom_api_key: str = None):
self.model_name = settings.gemini_vision_model
self.custom_api_key = custom_api_key
def extract_math_from_image(self, image_bytes: bytes, mime_type: str = "image/jpeg") -> str:
from google.genai import types
client = _get_gemini_client(self.custom_api_key)
prompt = (
"You are a math OCR expert. Extract ALL mathematical content from this image.\n\n"
"Rules:\n"
"1. If the image does NOT contain any math, physics, or quantitative concepts (e.g., it is a picture of a dog, car, person, or normal text), you MUST reply exactly with: REJECTED: This image does not appear to contain a math problem. Please upload a picture of a math question.\n"
"2. Convert handwritten or printed math into clean text.\n"
"3. Use standard notation: x², √, π, ±, ∫, Σ, etc.\n"
"4. If it's an equation, write it as: equation\n"
"5. If it's a word problem, transcribe it exactly.\n"
"6. If there are multiple problems, number them.\n"
"7. Return ONLY the extracted math — no commentary.\n"
)
config = types.GenerateContentConfig(safety_settings=_get_safety_settings())
try:
response = client.models.generate_content(
model=self.model_name,
contents=[
prompt,
types.Part.from_bytes(data=image_bytes, mime_type=mime_type)
],
config=config
)
if response.text:
return response.text.strip()
return "Could not extract math from image."
except Exception as e:
logger.error(f"Vision extraction error: {e}")
return f"Vision error: {e}"
def extract_and_solve(self, image_bytes: bytes, mime_type: str = "image/jpeg") -> Dict[str, str]:
from google.genai import types
client = _get_gemini_client(self.custom_api_key)
prompt = (
"You are an Indian math teacher. Look at this image.\n\n"
"IMPORTANT DOMAIN RULE: If the image does NOT contain a math problem, physics problem, or quantitative concept, you MUST ignore the steps below and respond exactly with: REJECTED: This image does not appear to contain a math problem. Please upload a picture of a math question.\n\n"
"Step 1: Extract the math problem from the image.\n"
"Step 2: Solve it step-by-step in whiteboard style.\n\n"
"Format your response as:\n"
"EXTRACTED: [the math problem]\n"
"---\n"
"SOLUTION:\n[step-by-step solution]\n"
)
config = types.GenerateContentConfig(safety_settings=_get_safety_settings())
try:
response = client.models.generate_content(
model=self.model_name,
contents=[prompt, types.Part.from_bytes(data=image_bytes, mime_type=mime_type)],
config=config
)
except Exception as e:
logger.error(f"Primary vision model failed: {e}")
logger.info("Falling back to gemini-flash-lite-latest for vision...")
try:
response = client.models.generate_content(
model="gemini-flash-lite-latest",
contents=[prompt, types.Part.from_bytes(data=image_bytes, mime_type=mime_type)],
config=config
)
except Exception as e2:
raise Exception(f"All vision models failed. You might be out of API quota. ({e2})")
text = response.text.strip() if response.text else ""
if "EXTRACTED:" in text and "SOLUTION:" in text:
parts = text.split("---", 1)
extracted = parts[0].replace("EXTRACTED:", "").strip()
solution = parts[1].replace("SOLUTION:", "").strip() if len(parts) > 1 else ""
else:
extracted = ""
solution = text
return {"extracted": extracted, "solution": solution}
except Exception as e:
logger.error(f"Extract and solve error: {e}")
return {"extracted": "", "solution": f"⚠️ Error: {e}"}
class GroqVisionService:
def __init__(self):
# Groq has decommissioned their vision models. We provide a mock fallback for local testing.
pass
def extract_math_from_image(self, image_bytes: bytes, mime_type: str = "image/jpeg") -> str:
return "x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}\n\n(Note: This is a mock response because Groq vision models are currently offline. Please add a Gemini API Key to use real vision extraction.)"
def extract_and_solve(self, image_bytes: bytes, mime_type: str = "image/jpeg") -> Dict[str, str]:
extracted = "x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}"
solution = "To solve this, we use the quadratic formula...\n\n(Note: This is a mock response because Groq vision models are currently offline. Please add a Gemini API Key to use real vision extraction.)"
return {"extracted": extracted, "solution": solution}