Download src/services/gemini_service.py from False45/math-api: direct link, hf CLI and curl.
- Browser
- Download file 13.3 kB
-
https://huggingface.co/spaces/False45/math-api/resolve/main/src/services/gemini_service.py
- Command line
-
hf download hf://spaces/False45/math-api/src/services/gemini_service.py
-
curl -L -o gemini_service.py https://huggingface.co/spaces/False45/math-api/resolve/main/src/services/gemini_service.py
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} | |