diff --git a/backend/app/agents/base_agent.py b/backend/app/agents/base_agent.py index 85509fb..bf81a9d 100755 --- a/backend/app/agents/base_agent.py +++ b/backend/app/agents/base_agent.py @@ -1,5 +1,5 @@ from abc import ABC, abstractmethod -from typing import List, Optional, Tuple +from typing import Awaitable, Callable, List, Optional, Tuple from app.models.schemas import PreviousReviewContext, SubReview @@ -14,6 +14,7 @@ class BaseAgent(ABC): self, images: List[Tuple[bytes, str]], previous_review: Optional[PreviousReviewContext] = None, + on_fallback: Optional[Callable[[], Awaitable[None]]] = None, ) -> SubReview: """ Analyze the proof and return a SubReview. diff --git a/backend/app/agents/brand_agent.py b/backend/app/agents/brand_agent.py index 1747cdf..d841304 100755 --- a/backend/app/agents/brand_agent.py +++ b/backend/app/agents/brand_agent.py @@ -74,6 +74,7 @@ Your response MUST include: channel: Optional[str] = None, sub_channel: Optional[str] = None, proof_type: Optional[str] = None, + on_fallback=None, ) -> SubReview: """ Analyze the proof for brand guideline adherence. @@ -169,9 +170,9 @@ If the proof is nonsensical, not a marketing material, or cannot be analyzed, se if len(images) == 1: file_data, file_type = images[0] return await self.gemini.analyze_with_image( - prompt, file_data, file_type, include_revision_fields=include_revision_fields + prompt, file_data, file_type, include_revision_fields=include_revision_fields, on_fallback=on_fallback ) else: return await self.gemini.analyze_with_images( - prompt, images, include_revision_fields=include_revision_fields + prompt, images, include_revision_fields=include_revision_fields, on_fallback=on_fallback ) diff --git a/backend/app/agents/channel_best_practices_agent.py b/backend/app/agents/channel_best_practices_agent.py index 75f4d9e..5e7c73f 100644 --- a/backend/app/agents/channel_best_practices_agent.py +++ b/backend/app/agents/channel_best_practices_agent.py @@ -57,6 +57,7 @@ Your response MUST include: channel: Optional[str] = None, sub_channel: Optional[str] = None, proof_type: Optional[str] = None, + on_fallback=None, ) -> SubReview: """ Analyze the proof for channel best practices and content strategy. @@ -162,9 +163,9 @@ If the proof is nonsensical, not a marketing material, or cannot be analyzed, se if len(images) == 1: file_data, file_type = images[0] return await self.gemini.analyze_with_image( - prompt, file_data, file_type, include_revision_fields=include_revision_fields + prompt, file_data, file_type, include_revision_fields=include_revision_fields, on_fallback=on_fallback ) else: return await self.gemini.analyze_with_images( - prompt, images, include_revision_fields=include_revision_fields + prompt, images, include_revision_fields=include_revision_fields, on_fallback=on_fallback ) diff --git a/backend/app/agents/channel_tech_specs_agent.py b/backend/app/agents/channel_tech_specs_agent.py index 75d7fd5..fe338b7 100644 --- a/backend/app/agents/channel_tech_specs_agent.py +++ b/backend/app/agents/channel_tech_specs_agent.py @@ -57,6 +57,7 @@ Your response MUST include: channel: Optional[str] = None, sub_channel: Optional[str] = None, proof_type: Optional[str] = None, + on_fallback=None, ) -> SubReview: """ Analyze the proof for technical specifications compliance. @@ -170,9 +171,9 @@ If the proof is nonsensical, not a marketing material, or cannot be analyzed, se if len(images) == 1: file_data, file_type = images[0] return await self.gemini.analyze_with_image( - prompt, file_data, file_type, include_revision_fields=include_revision_fields + prompt, file_data, file_type, include_revision_fields=include_revision_fields, on_fallback=on_fallback ) else: return await self.gemini.analyze_with_images( - prompt, images, include_revision_fields=include_revision_fields + prompt, images, include_revision_fields=include_revision_fields, on_fallback=on_fallback ) diff --git a/backend/app/agents/lead_agent.py b/backend/app/agents/lead_agent.py index 8136be0..3438fcf 100755 --- a/backend/app/agents/lead_agent.py +++ b/backend/app/agents/lead_agent.py @@ -79,6 +79,7 @@ In your summary: channel: Optional[str] = None, sub_channel: Optional[str] = None, proof_type: Optional[str] = None, + on_fallback=None, ) -> tuple[OverallStatus, str, str | None]: """ Synthesize specialist reviews into final status and summary. @@ -175,7 +176,7 @@ Here are the specialist reviews: Now, provide your final status and summary as a JSON object. """ - result = await self.gemini.generate_summary(prompt) + result = await self.gemini.generate_summary(prompt, on_fallback=on_fallback) overall_status = OverallStatus(result.get("overallStatus", "Analysis Error")) summary = result.get("summary", "Unable to generate summary.") diff --git a/backend/app/agents/legal_agent.py b/backend/app/agents/legal_agent.py index a988cf4..257bdfc 100755 --- a/backend/app/agents/legal_agent.py +++ b/backend/app/agents/legal_agent.py @@ -57,6 +57,7 @@ Your response MUST include: channel: Optional[str] = None, sub_channel: Optional[str] = None, proof_type: Optional[str] = None, + on_fallback=None, ) -> SubReview: """ Analyze the proof for legal compliance. @@ -172,9 +173,9 @@ If the proof is nonsensical, not a marketing material, or cannot be analyzed, se if len(images) == 1: file_data, file_type = images[0] return await self.gemini.analyze_with_image( - prompt, file_data, file_type, include_revision_fields=include_revision_fields + prompt, file_data, file_type, include_revision_fields=include_revision_fields, on_fallback=on_fallback ) else: return await self.gemini.analyze_with_images( - prompt, images, include_revision_fields=include_revision_fields + prompt, images, include_revision_fields=include_revision_fields, on_fallback=on_fallback ) diff --git a/backend/app/services/analysis_service.py b/backend/app/services/analysis_service.py index 6a6e595..a922711 100755 --- a/backend/app/services/analysis_service.py +++ b/backend/app/services/analysis_service.py @@ -108,6 +108,7 @@ class AnalysisService: channel: Optional[str] = None, sub_channel: Optional[str] = None, proof_type: Optional[str] = None, + on_fallback=None, ) -> Tuple[str, SubReview]: """Run a single agent with callback notifications.""" agent = self.agents[agent_name] @@ -117,9 +118,9 @@ class AnalysisService: await on_agent_update(agent_name, None) if agent_name == "Brand Agent": - review = await agent.analyze(images, previous_review=previous_review, brand=brand, channel=channel, sub_channel=sub_channel, proof_type=proof_type) + review = await agent.analyze(images, previous_review=previous_review, brand=brand, channel=channel, sub_channel=sub_channel, proof_type=proof_type, on_fallback=on_fallback) else: - review = await agent.analyze(images, previous_review=previous_review, channel=channel, sub_channel=sub_channel, proof_type=proof_type) + review = await agent.analyze(images, previous_review=previous_review, channel=channel, sub_channel=sub_channel, proof_type=proof_type, on_fallback=on_fallback) logger.info(f"[ANALYSIS] Agent completed: {agent_name} - ragStatus: {review.ragStatus}") if on_agent_update: @@ -138,6 +139,7 @@ class AnalysisService: channel: Optional[str] = None, sub_channel: Optional[str] = None, proof_type: Optional[str] = None, + on_fallback=None, ) -> Tuple[AgentReview, Optional[List[Tuple[bytes, int, int]]]]: """ Analyze a proof using all agents in parallel. @@ -207,6 +209,7 @@ class AnalysisService: channel=channel, sub_channel=sub_channel, proof_type=proof_type, + on_fallback=on_fallback, ) for agent_name in self.AGENT_ORDER ] @@ -221,6 +224,7 @@ class AnalysisService: overall_status, summary, financial_promotion_reason = await self.lead_agent.synthesize( reviews, previous_analysis=previous_analysis, channel=channel, sub_channel=sub_channel, proof_type=proof_type, + on_fallback=on_fallback, ) logger.info(f"[ANALYSIS] Analysis complete - overallStatus: {overall_status}") diff --git a/backend/app/services/gemini_service.py b/backend/app/services/gemini_service.py index d15c659..49f12c2 100755 --- a/backend/app/services/gemini_service.py +++ b/backend/app/services/gemini_service.py @@ -1,7 +1,7 @@ import asyncio import json import logging -from typing import List, Tuple +from typing import Awaitable, Callable, List, Optional, Tuple from google import genai from google.genai import types @@ -41,7 +41,12 @@ class GeminiService: self.model = "gemini-3.1-pro-preview" self.fallback_model = "gemini-3-flash-preview" - async def _generate_content(self, contents, config) -> any: + 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( @@ -54,6 +59,8 @@ class GeminiService: 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, @@ -66,6 +73,7 @@ class GeminiService: 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. @@ -142,8 +150,9 @@ class GeminiService: contents=[file_part, prompt], config=types.GenerateContentConfig( response_mime_type="application/json", - response_schema=response_schema + response_schema=response_schema, ), + on_fallback=on_fallback, ) logger.info(f"[GEMINI API] Response received from Gemini") @@ -194,6 +203,7 @@ class GeminiService: 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. @@ -275,8 +285,9 @@ class GeminiService: contents=contents, config=types.GenerateContentConfig( response_mime_type="application/json", - response_schema=response_schema + response_schema=response_schema, ), + on_fallback=on_fallback, ) logger.info(f"[GEMINI API] Response received from Gemini (multi-image)") @@ -325,6 +336,7 @@ class GeminiService: async def generate_summary( self, prompt: str, + on_fallback: Optional[Callable[[], Awaitable[None]]] = None, ) -> dict: """ Generate a text summary (for lead agent). @@ -356,8 +368,9 @@ class GeminiService: contents=prompt, config=types.GenerateContentConfig( response_mime_type="application/json", - response_schema=response_schema + response_schema=response_schema, ), + on_fallback=on_fallback, ) result = json.loads(response.text.strip()) diff --git a/backend/app/websocket/handlers.py b/backend/app/websocket/handlers.py index 31beb32..e13100c 100755 --- a/backend/app/websocket/handlers.py +++ b/backend/app/websocket/handlers.py @@ -133,6 +133,18 @@ async def handle_analyze_message( sub_channel = data.get("sub_channel") proof_type = data.get("proof_type") + # Build a once-only callback that notifies the client when the primary + # Gemini model is unavailable and the fallback model is used instead. + fallback_notified = False + + async def on_model_fallback() -> None: + nonlocal fallback_notified + if fallback_notified: + return + fallback_notified = True + if manager.is_connected(client_id): + await manager.send_message(client_id, {"type": "model_fallback"}) + # Run the analysis logger.info("[WEBSOCKET] Starting analysis...") result, pdf_pages = await analysis_service.analyze_proof( @@ -145,6 +157,7 @@ async def handle_analyze_message( channel=channel, sub_channel=sub_channel, proof_type=proof_type, + on_fallback=on_model_fallback, ) # Build the result dict diff --git a/frontend/App.tsx b/frontend/App.tsx index 9b4e263..0edf473 100755 --- a/frontend/App.tsx +++ b/frontend/App.tsx @@ -85,6 +85,14 @@ const AppContent: React.FC<{ msalInstance: any }> = ({ msalInstance }) => { const [pendingProofId, setPendingProofId] = useState(initialUrlState.proofId); const [error, setError] = useState(null); const [isLoadingData, setIsLoadingData] = useState(true); + const [notification, setNotification] = useState(null); + const notificationTimerRef = React.useRef | null>(null); + + const showNotification = (message: string) => { + setNotification(message); + if (notificationTimerRef.current) clearTimeout(notificationTimerRef.current); + notificationTimerRef.current = setTimeout(() => setNotification(null), 8000); + }; // Agency filter state (session-level, for oversight_admin / super_admin) const [selectedAgencyId, setSelectedAgencyId] = useState(null); @@ -339,7 +347,7 @@ const AppContent: React.FC<{ msalInstance: any }> = ({ msalInstance }) => { subChannel, proofType, brand: campaign.brandGuidelines, - }); + }, showNotification); const feedback = result.review; @@ -451,7 +459,7 @@ const AppContent: React.FC<{ msalInstance: any }> = ({ msalInstance }) => { subChannel, proofType, brand: campaign.brandGuidelines, - }); + }, showNotification); // Refresh proofs from API to get the persisted data try { @@ -971,6 +979,24 @@ const AppContent: React.FC<{ msalInstance: any }> = ({ msalInstance }) => { {renderContent()} + + {/* Model fallback notification toast */} + {notification && ( +
+ + + +
+

AI Model Notice

+

{notification}

+
+ +
+ )} ); }; diff --git a/frontend/services/geminiService.ts b/frontend/services/geminiService.ts index 7009da4..77d35f5 100755 --- a/frontend/services/geminiService.ts +++ b/frontend/services/geminiService.ts @@ -40,7 +40,8 @@ export const analyzeProof = async ( file: File, onAgentUpdate: (name: AgentName | 'Summary', review?: SubReview) => void, msalInstance: IPublicClientApplication, - options?: AnalyzeProofOptions + options?: AnalyzeProofOptions, + onNotification?: (message: string) => void, ): Promise => { // Acquire token before connecting const accessToken = await getAccessToken(msalInstance); @@ -126,6 +127,10 @@ export const analyzeProof = async ( }); break; + case 'model_fallback': + onNotification?.('The primary AI model is currently unavailable. Analysis is continuing with the backup model and may take longer than usual.'); + break; + case 'error': // Error occurred resolved = true;