import asyncio import json import logging from typing import Awaitable, Callable, List, Optional, Tuple from google import genai from google.genai import types from app.models.schemas import SubReview, RagStatus # Configure logging logger = logging.getLogger(__name__) # Timeout for each Gemini API call. # google-genai http_options 'timeout' is in MILLISECONDS. _PRIMARY_TIMEOUT_MS = 45_000 # 45 seconds _FALLBACK_TIMEOUT_MS = 150_000 # 150 seconds class GeminiService: """Service wrapper for Google Gemini API calls.""" def __init__(self, api_key: str): """ Initialize the Gemini service. Args: api_key: Google Gemini API key """ # Two separate clients with different HTTP-level timeouts so the # network connection is torn down when the deadline is reached. # Note: google-genai http_options 'timeout' is in milliseconds. self.primary_client = genai.Client( api_key=api_key, http_options={"timeout": _PRIMARY_TIMEOUT_MS}, ) self.fallback_client = genai.Client( api_key=api_key, http_options={"timeout": _FALLBACK_TIMEOUT_MS}, ) self.model = "gemini-3.1-pro-preview" self.fallback_model = "gemini-3-flash-preview" async def _generate_content( self, contents, config, on_fallback: Optional[Callable[[], Awaitable[None]]] = None, ) -> any: """Call generate_content, falling back to fallback_model if the primary fails or times out.""" try: return await self.primary_client.aio.models.generate_content( model=self.model, contents=contents, config=config, ) except Exception as e: logger.warning( f"[GEMINI API] Primary model {self.model} failed: {e}. " f"Retrying with fallback {self.fallback_model}" ) if on_fallback: await on_fallback() return await self.fallback_client.aio.models.generate_content( model=self.fallback_model, contents=contents, config=config, ) async def analyze_with_image( self, prompt: str, file_data: bytes, file_type: str, include_revision_fields: bool = False, on_fallback: Optional[Callable[[], Awaitable[None]]] = None, ) -> SubReview: """ Analyze an image/file with Gemini and return a structured SubReview. Args: prompt: The analysis prompt including reference doc context file_data: Raw bytes of the file file_type: MIME type of the file (e.g., "image/png") include_revision_fields: If True, require revision fields in response schema Returns: SubReview with ragStatus, feedback, and issues. When include_revision_fields is True, also includes resolvedIssues, outstandingIssues, and newIssues. """ try: logger.info(f"[GEMINI API] Starting image analysis - file_type: {file_type}, file_size: {len(file_data)} bytes, revision_fields: {include_revision_fields}") # Create inline data part for the file file_part = types.Part.from_bytes( data=file_data, mime_type=file_type ) # Define the response schema for structured output response_schema = { "type": "object", "properties": { "analysisStatus": { "type": "string", "enum": ["success", "low_confidence"], "description": "Set to 'low_confidence' if the proof is nonsensical, completely irrelevant to marketing, or otherwise impossible to analyze. Otherwise, set to 'success'." }, "ragStatus": { "type": "string", "enum": ["Red", "Amber", "Green"], "description": "A RAG status. Red: Issues that must be resolved. Amber: Issues that should be addressed. Green: No issues found." }, "feedback": { "type": "string", "description": "Brief bullet-point feedback. Each bullet has two parts: '**Issue:** [problem]' on one line, then '**Recommendation:** [fix]' on the next line. Use • for bullets. Max 5 bullets." }, "issues": { "type": "array", "items": {"type": "string"}, "description": "A list of specific, actionable issues found. If no issues, return an empty array." } }, "required": ["analysisStatus", "ragStatus", "feedback", "issues"] } # Add revision fields if requested if include_revision_fields: response_schema["properties"]["resolvedIssues"] = { "type": "array", "items": {"type": "string"}, "description": "Issues from the previous version that have been resolved in this revision." } response_schema["properties"]["outstandingIssues"] = { "type": "array", "items": {"type": "string"}, "description": "Issues from the previous version that remain unresolved in this revision." } response_schema["properties"]["newIssues"] = { "type": "array", "items": {"type": "string"}, "description": "New issues introduced in this revision that were not present in the previous version." } response_schema["required"].extend(["resolvedIssues", "outstandingIssues", "newIssues"]) # Make the API call logger.info(f"[GEMINI API] Calling Gemini model: {self.model}") response = await self._generate_content( contents=[file_part, prompt], config=types.GenerateContentConfig( response_mime_type="application/json", response_schema=response_schema, ), on_fallback=on_fallback, ) logger.info(f"[GEMINI API] Response received from Gemini") # Parse the JSON response json_text = response.text.strip() parsed_result = json.loads(json_text) logger.info(f"[GEMINI API] Parsed result - ragStatus: {parsed_result.get('ragStatus')}, analysisStatus: {parsed_result.get('analysisStatus')}") # Handle low confidence analysis if parsed_result.get("analysisStatus") == "low_confidence": return SubReview( ragStatus=RagStatus.ERROR, feedback="The agent could not analyze this proof with high confidence. This may be because the content is irrelevant, nonsensical, or too far outside of expected marketing materials.", issues=[] ) # Build SubReview with optional revision fields review_kwargs = { "ragStatus": RagStatus(parsed_result["ragStatus"]), "feedback": parsed_result["feedback"], "issues": parsed_result["issues"], } if include_revision_fields: review_kwargs["resolvedIssues"] = parsed_result.get("resolvedIssues", []) review_kwargs["outstandingIssues"] = parsed_result.get("outstandingIssues", []) review_kwargs["newIssues"] = parsed_result.get("newIssues", []) return SubReview(**review_kwargs) except json.JSONDecodeError as e: logger.error(f"[GEMINI API] JSON parse error: {str(e)}") return SubReview( ragStatus=RagStatus.ERROR, feedback=f"Failed to parse AI response as JSON: {str(e)}", issues=[] ) except Exception as e: logger.error(f"[GEMINI API] Error during analysis: {str(e)}") return SubReview( ragStatus=RagStatus.ERROR, feedback=f"An error occurred during analysis: {str(e)}", issues=[] ) async def analyze_with_images( self, prompt: str, images: List[Tuple[bytes, str]], include_revision_fields: bool = False, on_fallback: Optional[Callable[[], Awaitable[None]]] = None, ) -> SubReview: """ Analyze multiple images with Gemini and return a structured SubReview. This is used for multi-page PDFs where all pages need to be analyzed together. Args: prompt: The analysis prompt including reference doc context images: List of (file_data, mime_type) tuples for each image include_revision_fields: If True, require revision fields in response schema Returns: SubReview with ragStatus, feedback, and issues. When include_revision_fields is True, also includes resolvedIssues, outstandingIssues, and newIssues. """ try: logger.info(f"[GEMINI API] Starting multi-image analysis - {len(images)} images, revision_fields: {include_revision_fields}") # Create inline data parts for all images file_parts = [] for i, (file_data, file_type) in enumerate(images): part = types.Part.from_bytes(data=file_data, mime_type=file_type) file_parts.append(part) logger.info(f"[GEMINI API] Added image {i + 1}/{len(images)} - type: {file_type}, size: {len(file_data)} bytes") # Define the response schema for structured output response_schema = { "type": "object", "properties": { "analysisStatus": { "type": "string", "enum": ["success", "low_confidence"], "description": "Set to 'low_confidence' if the proof is nonsensical, completely irrelevant to marketing, or otherwise impossible to analyze. Otherwise, set to 'success'." }, "ragStatus": { "type": "string", "enum": ["Red", "Amber", "Green"], "description": "A RAG status. Red: Issues that must be resolved. Amber: Issues that should be addressed. Green: No issues found." }, "feedback": { "type": "string", "description": "Brief bullet-point feedback. Each bullet has two parts: '**Issue:** [problem]' on one line, then '**Recommendation:** [fix]' on the next line. Use • for bullets. Max 5 bullets." }, "issues": { "type": "array", "items": {"type": "string"}, "description": "A list of specific, actionable issues found. If no issues, return an empty array." } }, "required": ["analysisStatus", "ragStatus", "feedback", "issues"] } # Add revision fields if requested if include_revision_fields: response_schema["properties"]["resolvedIssues"] = { "type": "array", "items": {"type": "string"}, "description": "Issues from the previous version that have been resolved in this revision." } response_schema["properties"]["outstandingIssues"] = { "type": "array", "items": {"type": "string"}, "description": "Issues from the previous version that remain unresolved in this revision." } response_schema["properties"]["newIssues"] = { "type": "array", "items": {"type": "string"}, "description": "New issues introduced in this revision that were not present in the previous version." } response_schema["required"].extend(["resolvedIssues", "outstandingIssues", "newIssues"]) # Combine file parts with prompt contents = file_parts + [prompt] # Make the API call logger.info(f"[GEMINI API] Calling Gemini model: {self.model} with {len(images)} images") response = await self._generate_content( contents=contents, config=types.GenerateContentConfig( response_mime_type="application/json", response_schema=response_schema, ), on_fallback=on_fallback, ) logger.info(f"[GEMINI API] Response received from Gemini (multi-image)") # Parse the JSON response json_text = response.text.strip() parsed_result = json.loads(json_text) logger.info(f"[GEMINI API] Parsed result - ragStatus: {parsed_result.get('ragStatus')}, analysisStatus: {parsed_result.get('analysisStatus')}") # Handle low confidence analysis if parsed_result.get("analysisStatus") == "low_confidence": return SubReview( ragStatus=RagStatus.ERROR, feedback="The agent could not analyze this proof with high confidence. This may be because the content is irrelevant, nonsensical, or too far outside of expected marketing materials.", issues=[] ) # Build SubReview with optional revision fields review_kwargs = { "ragStatus": RagStatus(parsed_result["ragStatus"]), "feedback": parsed_result["feedback"], "issues": parsed_result["issues"], } if include_revision_fields: review_kwargs["resolvedIssues"] = parsed_result.get("resolvedIssues", []) review_kwargs["outstandingIssues"] = parsed_result.get("outstandingIssues", []) review_kwargs["newIssues"] = parsed_result.get("newIssues", []) return SubReview(**review_kwargs) except json.JSONDecodeError as e: logger.error(f"[GEMINI API] JSON parse error: {str(e)}") return SubReview( ragStatus=RagStatus.ERROR, feedback=f"Failed to parse AI response as JSON: {str(e)}", issues=[] ) except Exception as e: logger.error(f"[GEMINI API] Error during multi-image analysis: {str(e)}") return SubReview( ragStatus=RagStatus.ERROR, feedback=f"An error occurred during analysis: {str(e)}", issues=[] ) async def generate_summary( self, prompt: str, on_fallback: Optional[Callable[[], Awaitable[None]]] = None, ) -> dict: """ Generate a text summary (for lead agent). Args: prompt: The prompt for generating the summary Returns: Parsed JSON response with summary and overall_status """ try: logger.info("[GEMINI API] Generating lead agent summary") response_schema = { "type": "object", "properties": { "overallStatus": { "type": "string", "enum": ["Passed", "Failed", "Analysis Error", "Requires Manual Legal Review"], }, "summary": { "type": "string", "description": "Brief summary: one-line verdict, then 3-5 bullet points listing key issues/recommendations." } }, "required": ["overallStatus", "summary"] } response = await self._generate_content( contents=prompt, config=types.GenerateContentConfig( response_mime_type="application/json", response_schema=response_schema, ), on_fallback=on_fallback, ) result = json.loads(response.text.strip()) logger.info(f"[GEMINI API] Summary generated - overallStatus: {result.get('overallStatus')}") return result except Exception as e: logger.error(f"[GEMINI API] Error generating summary: {str(e)}") return { "overallStatus": "Analysis Error", "summary": f"An error occurred while generating the summary: {str(e)}" }