diff --git a/backend/app/agents/base_agent.py b/backend/app/agents/base_agent.py index dcefac0..85509fb 100755 --- a/backend/app/agents/base_agent.py +++ b/backend/app/agents/base_agent.py @@ -1,7 +1,7 @@ from abc import ABC, abstractmethod -from typing import List, Tuple +from typing import List, Optional, Tuple -from app.models.schemas import SubReview +from app.models.schemas import PreviousReviewContext, SubReview class BaseAgent(ABC): @@ -10,7 +10,11 @@ class BaseAgent(ABC): name: str = "Base Agent" @abstractmethod - async def analyze(self, images: List[Tuple[bytes, str]]) -> SubReview: + async def analyze( + self, + images: List[Tuple[bytes, str]], + previous_review: Optional[PreviousReviewContext] = None, + ) -> SubReview: """ Analyze the proof and return a SubReview. @@ -18,8 +22,12 @@ class BaseAgent(ABC): images: List of (file_data, mime_type) tuples representing the proof. For single images/videos, this will contain one tuple. For multi-page PDFs, this will contain one tuple per page. + previous_review: Optional context from the previous version's review + for revision-aware analysis. Returns: - SubReview containing ragStatus, feedback, and issues + SubReview containing ragStatus, feedback, and issues. + When previous_review is provided, also includes resolvedIssues, + outstandingIssues, and newIssues. """ pass diff --git a/backend/app/agents/brand_agent.py b/backend/app/agents/brand_agent.py index 4349bbd..0827e05 100755 --- a/backend/app/agents/brand_agent.py +++ b/backend/app/agents/brand_agent.py @@ -1,7 +1,7 @@ -from typing import List, Tuple +from typing import List, Optional, Tuple from app.agents.base_agent import BaseAgent -from app.models.schemas import SubReview +from app.models.schemas import PreviousReviewContext, SubReview from app.services.gemini_service import GeminiService from app.services.reference_docs import ReferenceDocsService @@ -38,12 +38,46 @@ class BrandAgent(BaseAgent): # Default to Barclaycard return self.reference_docs.get_barclaycard_brand_spec() - async def analyze(self, images: List[Tuple[bytes, str]], brand: str = "Barclaycard") -> SubReview: + def _build_revision_context(self, previous_review: PreviousReviewContext) -> str: + """Build prompt section for revision-aware analysis.""" + issues_list = "\n".join(f" - {issue}" for issue in previous_review.issues) if previous_review.issues else " (No issues)" + return f""" +--- + +**REVISION CONTEXT** + +This is a revision of a previously reviewed proof. The previous version (Version {previous_review.version}) had the following brand review: + +- RAG Status: {previous_review.ragStatus} +- Feedback: {previous_review.feedback} +- Issues identified: +{issues_list} + +When analyzing this revision, you MUST: +1. Compare against the previous issues and determine which have been RESOLVED +2. Identify which previous issues are still OUTSTANDING (not fixed) +3. Identify any NEW issues introduced in this revision + +Your response MUST include: +- resolvedIssues: Array of issues from the previous version that have been fixed +- outstandingIssues: Array of issues from the previous version that remain unfixed +- newIssues: Array of new issues not present in the previous version + +--- +""" + + async def analyze( + self, + images: List[Tuple[bytes, str]], + previous_review: Optional[PreviousReviewContext] = None, + brand: str = "Barclaycard", + ) -> SubReview: """ Analyze the proof for brand guideline adherence. Args: images: List of (file_data, mime_type) tuples representing the proof + previous_review: Optional context from previous version for revision-aware analysis brand: Brand to analyze against ('Barclays' or 'Barclaycard') Returns: @@ -52,12 +86,17 @@ class BrandAgent(BaseAgent): # Get the appropriate brand specification brand_context = self._get_brand_context(brand) + # Build revision context if available + revision_context = "" + if previous_review: + revision_context = self._build_revision_context(previous_review) + prompt = f"""You are a brand expert for {brand}. Your role is to analyze marketing proofs for adherence to {brand} brand guidelines. Here is the {brand} brand specification to use for your analysis: {brand_context} - +{revision_context} --- Analyze the uploaded proof against the {brand} brand specification above, checking for: @@ -93,9 +132,16 @@ If the proof is nonsensical, not a marketing material, or cannot be analyzed, se - Example: "Logo placement incorrect (bottom-left) - move to top-right corner per guidelines" """ + # Determine if revision fields should be included + include_revision_fields = previous_review is not None + # Use single-image or multi-image analysis depending on input if len(images) == 1: file_data, file_type = images[0] - return await self.gemini.analyze_with_image(prompt, file_data, file_type) + return await self.gemini.analyze_with_image( + prompt, file_data, file_type, include_revision_fields=include_revision_fields + ) else: - return await self.gemini.analyze_with_images(prompt, images) + return await self.gemini.analyze_with_images( + prompt, images, include_revision_fields=include_revision_fields + ) diff --git a/backend/app/agents/channel_best_practices_agent.py b/backend/app/agents/channel_best_practices_agent.py index 8daf4df..67237de 100644 --- a/backend/app/agents/channel_best_practices_agent.py +++ b/backend/app/agents/channel_best_practices_agent.py @@ -1,7 +1,7 @@ -from typing import List, Tuple +from typing import List, Optional, Tuple from app.agents.base_agent import BaseAgent -from app.models.schemas import SubReview +from app.models.schemas import PreviousReviewContext, SubReview from app.services.gemini_service import GeminiService from app.services.reference_docs import ReferenceDocsService @@ -22,12 +22,45 @@ class ChannelBestPracticesAgent(BaseAgent): self.gemini = gemini_service self.reference_docs = reference_docs - async def analyze(self, images: List[Tuple[bytes, str]]) -> SubReview: + def _build_revision_context(self, previous_review: PreviousReviewContext) -> str: + """Build prompt section for revision-aware analysis.""" + issues_list = "\n".join(f" - {issue}" for issue in previous_review.issues) if previous_review.issues else " (No issues)" + return f""" +--- + +**REVISION CONTEXT** + +This is a revision of a previously reviewed proof. The previous version (Version {previous_review.version}) had the following channel best practices review: + +- RAG Status: {previous_review.ragStatus} +- Feedback: {previous_review.feedback} +- Issues identified: +{issues_list} + +When analyzing this revision, you MUST: +1. Compare against the previous issues and determine which have been RESOLVED +2. Identify which previous issues are still OUTSTANDING (not fixed) +3. Identify any NEW issues introduced in this revision + +Your response MUST include: +- resolvedIssues: Array of issues from the previous version that have been fixed +- outstandingIssues: Array of issues from the previous version that remain unfixed +- newIssues: Array of new issues not present in the previous version + +--- +""" + + async def analyze( + self, + images: List[Tuple[bytes, str]], + previous_review: Optional[PreviousReviewContext] = None, + ) -> SubReview: """ Analyze the proof for channel best practices and content strategy. Args: images: List of (file_data, mime_type) tuples representing the proof + previous_review: Optional context from previous version for revision-aware analysis Returns: SubReview with channel best practices assessment @@ -35,12 +68,17 @@ class ChannelBestPracticesAgent(BaseAgent): # Get the channel best practices specification best_practices_context = self.reference_docs.get_channel_best_practices_spec() + # Build revision context if available + revision_context = "" + if previous_review: + revision_context = self._build_revision_context(previous_review) + prompt = f"""You are a digital channel best practices specialist for Barclays Bank. Your role is to analyze marketing proofs for creative best practices, content strategy, and platform optimization. Here are the channel best practices guidelines to use for your analysis: {best_practices_context} - +{revision_context} --- Analyze the uploaded proof for adherence to channel best practices, checking: @@ -87,9 +125,16 @@ If the proof is nonsensical, not a marketing material, or cannot be analyzed, se - Example: "CTA placement below fold - move above fold for better visibility" """ + # Determine if revision fields should be included + include_revision_fields = previous_review is not None + # Use single-image or multi-image analysis depending on input if len(images) == 1: file_data, file_type = images[0] - return await self.gemini.analyze_with_image(prompt, file_data, file_type) + return await self.gemini.analyze_with_image( + prompt, file_data, file_type, include_revision_fields=include_revision_fields + ) else: - return await self.gemini.analyze_with_images(prompt, images) + return await self.gemini.analyze_with_images( + prompt, images, include_revision_fields=include_revision_fields + ) diff --git a/backend/app/agents/channel_tech_specs_agent.py b/backend/app/agents/channel_tech_specs_agent.py index 0a7cd86..4da7b29 100644 --- a/backend/app/agents/channel_tech_specs_agent.py +++ b/backend/app/agents/channel_tech_specs_agent.py @@ -1,7 +1,7 @@ -from typing import List, Tuple +from typing import List, Optional, Tuple from app.agents.base_agent import BaseAgent -from app.models.schemas import SubReview +from app.models.schemas import PreviousReviewContext, SubReview from app.services.gemini_service import GeminiService from app.services.reference_docs import ReferenceDocsService @@ -22,12 +22,45 @@ class ChannelTechSpecsAgent(BaseAgent): self.gemini = gemini_service self.reference_docs = reference_docs - async def analyze(self, images: List[Tuple[bytes, str]]) -> SubReview: + def _build_revision_context(self, previous_review: PreviousReviewContext) -> str: + """Build prompt section for revision-aware analysis.""" + issues_list = "\n".join(f" - {issue}" for issue in previous_review.issues) if previous_review.issues else " (No issues)" + return f""" +--- + +**REVISION CONTEXT** + +This is a revision of a previously reviewed proof. The previous version (Version {previous_review.version}) had the following channel tech specs review: + +- RAG Status: {previous_review.ragStatus} +- Feedback: {previous_review.feedback} +- Issues identified: +{issues_list} + +When analyzing this revision, you MUST: +1. Compare against the previous issues and determine which have been RESOLVED +2. Identify which previous issues are still OUTSTANDING (not fixed) +3. Identify any NEW issues introduced in this revision + +Your response MUST include: +- resolvedIssues: Array of issues from the previous version that have been fixed +- outstandingIssues: Array of issues from the previous version that remain unfixed +- newIssues: Array of new issues not present in the previous version + +--- +""" + + async def analyze( + self, + images: List[Tuple[bytes, str]], + previous_review: Optional[PreviousReviewContext] = None, + ) -> SubReview: """ Analyze the proof for technical specifications compliance. Args: images: List of (file_data, mime_type) tuples representing the proof + previous_review: Optional context from previous version for revision-aware analysis Returns: SubReview with technical specifications assessment @@ -35,12 +68,17 @@ class ChannelTechSpecsAgent(BaseAgent): # Get the channel tech specs specification tech_specs_context = self.reference_docs.get_channel_tech_specs_spec() + # Build revision context if available + revision_context = "" + if previous_review: + revision_context = self._build_revision_context(previous_review) + prompt = f"""You are a digital channel technical specifications specialist for Barclays Bank. Your role is to analyze marketing proofs for technical compliance with platform specifications, dimensions, file formats, and character limits. Here are the channel technical specifications to use for your analysis: {tech_specs_context} - +{revision_context} --- Analyze the uploaded proof for technical specification compliance, checking: @@ -93,9 +131,16 @@ If the proof is nonsensical, not a marketing material, or cannot be analyzed, se - Example: "Image resolution 72dpi - increase to minimum 150dpi for print quality" """ + # Determine if revision fields should be included + include_revision_fields = previous_review is not None + # Use single-image or multi-image analysis depending on input if len(images) == 1: file_data, file_type = images[0] - return await self.gemini.analyze_with_image(prompt, file_data, file_type) + return await self.gemini.analyze_with_image( + prompt, file_data, file_type, include_revision_fields=include_revision_fields + ) else: - return await self.gemini.analyze_with_images(prompt, images) + return await self.gemini.analyze_with_images( + prompt, images, include_revision_fields=include_revision_fields + ) diff --git a/backend/app/agents/lead_agent.py b/backend/app/agents/lead_agent.py index 251b13e..c317de1 100755 --- a/backend/app/agents/lead_agent.py +++ b/backend/app/agents/lead_agent.py @@ -1,3 +1,5 @@ +from typing import Optional + from app.models.schemas import SubReview, RagStatus, OverallStatus from app.services.gemini_service import GeminiService @@ -24,15 +26,64 @@ class LeadAgent: """ self.gemini = gemini_service + def _build_revision_context(self, previous_analysis: dict, reviews: dict[str, SubReview]) -> str: + """Build revision context section for the synthesis prompt.""" + previous_version = previous_analysis.get("version", "N/A") + + # Aggregate resolved/outstanding/new issues across all agents + all_resolved = [] + all_outstanding = [] + all_new = [] + + for agent_name, review in reviews.items(): + if review.resolvedIssues: + all_resolved.extend([f"[{agent_name}] {issue}" for issue in review.resolvedIssues]) + if review.outstandingIssues: + all_outstanding.extend([f"[{agent_name}] {issue}" for issue in review.outstandingIssues]) + if review.newIssues: + all_new.extend([f"[{agent_name}] {issue}" for issue in review.newIssues]) + + resolved_list = "\n".join(f" - {issue}" for issue in all_resolved) if all_resolved else " (None)" + outstanding_list = "\n".join(f" - {issue}" for issue in all_outstanding) if all_outstanding else " (None)" + new_list = "\n".join(f" - {issue}" for issue in all_new) if all_new else " (None)" + + return f""" +--- + +**REVISION CONTEXT** + +This is a revision (comparing against Version {previous_version}). The analysis has identified the following changes: + +**RESOLVED ISSUES** (fixed in this revision): +{resolved_list} + +**OUTSTANDING ISSUES** (still need attention): +{outstanding_list} + +**NEW ISSUES** (introduced in this revision): +{new_list} + +In your summary: +1. Acknowledge this is a revision and summarize the progress made +2. Highlight any resolved issues positively +3. Emphasize outstanding issues that still need to be addressed +4. Call out any regressions (new issues) that were introduced + +--- +""" + async def synthesize( self, reviews: dict[str, SubReview], + previous_analysis: Optional[dict] = None, ) -> tuple[OverallStatus, str, str | None]: """ Synthesize specialist reviews into final verdict and summary. Args: reviews: Dictionary mapping agent names to their SubReview results + previous_analysis: Optional dict containing the previous version's analysis + results. When provided, enables revision-aware summary. Returns: Tuple of (overall_status, summary, financial_promotion_reason) @@ -50,6 +101,11 @@ class LeadAgent: else None ) + # Build revision context if previous analysis is available + revision_context = "" + if previous_analysis and previous_analysis.get("version"): + revision_context = self._build_revision_context(previous_analysis, reviews) + # Build the prompt for Gemini to generate summary prompt = f""" You are a Lead Agent responsible for auditing a marketing proof. You have received feedback from specialist AI agents. @@ -74,7 +130,7 @@ Your summary should: - List key issues as bullet points (max 3-5 bullets) - Each bullet: one sentence, actionable - For 'Passed': briefly note any amber items in 1-2 bullets - +{revision_context} Here are the specialist reviews: {self._format_reviews(reviews)} diff --git a/backend/app/agents/legal_agent.py b/backend/app/agents/legal_agent.py index 8731ea8..65f46e5 100755 --- a/backend/app/agents/legal_agent.py +++ b/backend/app/agents/legal_agent.py @@ -1,7 +1,7 @@ -from typing import List, Tuple +from typing import List, Optional, Tuple from app.agents.base_agent import BaseAgent -from app.models.schemas import SubReview +from app.models.schemas import PreviousReviewContext, SubReview from app.services.gemini_service import GeminiService from app.services.reference_docs import ReferenceDocsService @@ -22,12 +22,45 @@ class LegalAgent(BaseAgent): self.gemini = gemini_service self.reference_docs = reference_docs - async def analyze(self, images: List[Tuple[bytes, str]]) -> SubReview: + def _build_revision_context(self, previous_review: PreviousReviewContext) -> str: + """Build prompt section for revision-aware analysis.""" + issues_list = "\n".join(f" - {issue}" for issue in previous_review.issues) if previous_review.issues else " (No issues)" + return f""" +--- + +**REVISION CONTEXT** + +This is a revision of a previously reviewed proof. The previous version (Version {previous_review.version}) had the following legal review: + +- RAG Status: {previous_review.ragStatus} +- Feedback: {previous_review.feedback} +- Issues identified: +{issues_list} + +When analyzing this revision, you MUST: +1. Compare against the previous issues and determine which have been RESOLVED +2. Identify which previous issues are still OUTSTANDING (not fixed) +3. Identify any NEW issues introduced in this revision + +Your response MUST include: +- resolvedIssues: Array of issues from the previous version that have been fixed +- outstandingIssues: Array of issues from the previous version that remain unfixed +- newIssues: Array of new issues not present in the previous version + +--- +""" + + async def analyze( + self, + images: List[Tuple[bytes, str]], + previous_review: Optional[PreviousReviewContext] = None, + ) -> SubReview: """ Analyze the proof for legal compliance. Args: images: List of (file_data, mime_type) tuples representing the proof + previous_review: Optional context from previous version for revision-aware analysis Returns: SubReview with legal compliance assessment @@ -35,12 +68,17 @@ class LegalAgent(BaseAgent): # Get the legal specification legal_context = self.reference_docs.get_legal_spec() + # Build revision context if available + revision_context = "" + if previous_review: + revision_context = self._build_revision_context(previous_review) + prompt = f"""You are a legal compliance specialist for Barclays Bank. Your role is to analyze marketing proofs for legal compliance, advertising standards, and regulatory requirements. Here are the legal guidelines to use for your analysis: {legal_context} - +{revision_context} --- Analyze the uploaded proof for legal compliance, checking: @@ -98,9 +136,16 @@ If the proof is nonsensical, not a marketing material, or cannot be analyzed, se - Example: "Missing APR disclaimer - add representative APR per FCA requirements" """ + # Determine if revision fields should be included + include_revision_fields = previous_review is not None + # Use single-image or multi-image analysis depending on input if len(images) == 1: file_data, file_type = images[0] - return await self.gemini.analyze_with_image(prompt, file_data, file_type) + return await self.gemini.analyze_with_image( + prompt, file_data, file_type, include_revision_fields=include_revision_fields + ) else: - return await self.gemini.analyze_with_images(prompt, images) + return await self.gemini.analyze_with_images( + prompt, images, include_revision_fields=include_revision_fields + ) diff --git a/backend/app/models/schemas.py b/backend/app/models/schemas.py index 35962c1..6aba542 100755 --- a/backend/app/models/schemas.py +++ b/backend/app/models/schemas.py @@ -26,6 +26,21 @@ class SubReview(BaseModel): issues: list[str] isFinancialPromotion: Optional[bool] = None financialPromotionReason: Optional[str] = None + # Revision-aware fields (populated when analyzing version N > 1) + resolvedIssues: Optional[list[str]] = None + outstandingIssues: Optional[list[str]] = None + newIssues: Optional[list[str]] = None + + class Config: + use_enum_values = True + + +class PreviousReviewContext(BaseModel): + """Context from a previous version's review for revision-aware analysis.""" + version: int + ragStatus: RagStatus + feedback: str + issues: list[str] class Config: use_enum_values = True diff --git a/backend/app/repositories/proof_repository.py b/backend/app/repositories/proof_repository.py index 9ac0f83..4f1af3a 100755 --- a/backend/app/repositories/proof_repository.py +++ b/backend/app/repositories/proof_repository.py @@ -148,6 +148,65 @@ class ProofRepository: version = result.scalar_one_or_none() return version if version else 0 + async def get_previous_version_review( + self, + proof_id: uuid.UUID, + current_version: int, + ) -> Optional[dict]: + """ + Get the agent_review from the previous version (N-1) of a proof. + + Args: + proof_id: The proof ID + current_version: The version being analyzed (will fetch N-1) + + Returns: + The agent_review dict from version N-1, or None if no previous version + """ + if current_version <= 1: + return None + + previous_version_number = current_version - 1 + result = await self.session.execute( + select(ProofVersion.agent_review, ProofVersion.version) + .where( + ProofVersion.proof_id == proof_id, + ProofVersion.version == previous_version_number, + ) + ) + row = result.first() + if row and row.agent_review: + review = row.agent_review + review["version"] = row.version + return review + return None + + async def get_latest_version_review( + self, + proof_id: uuid.UUID, + ) -> Optional[dict]: + """ + Get the agent_review from the latest version of a proof. + + Args: + proof_id: The proof ID + + Returns: + The agent_review dict with version number attached, or None + """ + result = await self.session.execute( + select(ProofVersion.agent_review, ProofVersion.version) + .where(ProofVersion.proof_id == proof_id) + .order_by(ProofVersion.version.desc()) + .limit(1) + ) + row = result.first() + if row and row.agent_review: + review = row.agent_review + review["version"] = row.version + return review + return None + async def get_or_create_proof( self, campaign_id: uuid.UUID, diff --git a/backend/app/services/analysis_service.py b/backend/app/services/analysis_service.py index 955f982..95d2ddf 100755 --- a/backend/app/services/analysis_service.py +++ b/backend/app/services/analysis_service.py @@ -2,7 +2,7 @@ import asyncio import logging from typing import Callable, Awaitable, List, Tuple, Optional -from app.models.schemas import SubReview, AgentReview, OverallStatus +from app.models.schemas import PreviousReviewContext, RagStatus, SubReview, AgentReview, OverallStatus logger = logging.getLogger(__name__) from app.agents.brand_agent import BrandAgent @@ -29,6 +29,14 @@ class AnalysisService: # Agent execution order AGENT_ORDER = ["Legal Agent", "Brand Agent", "Channel Best Practices Agent", "Channel Tech Specs Agent"] + # Mapping from agent name to the key in AgentReview/previous_analysis dict + AGENT_REVIEW_KEY_MAP = { + "Legal Agent": "legalAgentReview", + "Brand Agent": "brandAgentReview", + "Channel Best Practices Agent": "channelBestPracticesAgentReview", + "Channel Tech Specs Agent": "channelTechSpecsAgentReview", + } + def __init__( self, gemini_service: GeminiService, @@ -53,24 +61,62 @@ class AnalysisService: } self.lead_agent = LeadAgent(gemini_service) + def _extract_previous_review_context( + self, + agent_name: str, + previous_analysis: Optional[dict], + ) -> Optional[PreviousReviewContext]: + """ + Extract the previous review context for a specific agent. + + Args: + agent_name: Name of the agent + previous_analysis: Full previous analysis dict (or None) + + Returns: + PreviousReviewContext for the agent, or None if not available + """ + if not previous_analysis: + return None + + review_key = self.AGENT_REVIEW_KEY_MAP.get(agent_name) + if not review_key: + return None + + agent_review = previous_analysis.get(review_key) + if not agent_review: + return None + + version = previous_analysis.get("version", 0) + if version == 0: + return None + + return PreviousReviewContext( + version=version, + ragStatus=RagStatus(agent_review.get("ragStatus", "Error")), + feedback=agent_review.get("feedback", ""), + issues=agent_review.get("issues", []), + ) + async def _run_agent( self, agent_name: str, images: List[Tuple[bytes, str]], brand: str, on_agent_update: AgentCallback | None, + previous_review: Optional[PreviousReviewContext] = None, ) -> Tuple[str, SubReview]: """Run a single agent with callback notifications.""" agent = self.agents[agent_name] - logger.info(f"[ANALYSIS] Starting agent: {agent_name}") + logger.info(f"[ANALYSIS] Starting agent: {agent_name}, has_previous_review: {previous_review is not None}") if on_agent_update: await on_agent_update(agent_name, None) if agent_name == "Brand Agent": - review = await agent.analyze(images, brand=brand) + review = await agent.analyze(images, previous_review=previous_review, brand=brand) else: - review = await agent.analyze(images) + review = await agent.analyze(images, previous_review=previous_review) logger.info(f"[ANALYSIS] Agent completed: {agent_name} - ragStatus: {review.ragStatus}") if on_agent_update: @@ -85,6 +131,7 @@ class AnalysisService: on_agent_update: AgentCallback | None = None, is_wip: bool = False, brand: str = "Barclaycard", + previous_analysis: Optional[dict] = None, ) -> Tuple[AgentReview, Optional[List[Tuple[bytes, int, int]]]]: """ Analyze a proof using all agents in parallel. @@ -97,6 +144,9 @@ class AnalysisService: and (agent_name, review) when agent completes. is_wip: Whether this is a work-in-progress analysis brand: Brand to use for brand guidelines analysis ('Barclays' or 'Barclaycard') + previous_analysis: Optional dict containing the previous version's analysis + results. When provided, enables revision-aware analysis + that identifies resolved/outstanding/new issues. Returns: Tuple of: @@ -104,7 +154,8 @@ class AnalysisService: - List of rasterized PDF pages if input was PDF, else None Each page is (png_bytes, width, height) """ - logger.info(f"[ANALYSIS] Starting proof analysis - file_type: {file_type}, file_size: {len(file_data)} bytes, is_wip: {is_wip}, brand: {brand}") + previous_version = previous_analysis.get("version") if previous_analysis else None + logger.info(f"[ANALYSIS] Starting proof analysis - file_type: {file_type}, file_size: {len(file_data)} bytes, is_wip: {is_wip}, brand: {brand}, previous_version: {previous_version}") reviews: dict[str, SubReview] = {} # Prepare images for analysis @@ -139,9 +190,15 @@ class AnalysisService: # Single image/video - wrap in list images = [(file_data, file_type)] - # Run all agents in parallel + # Run all agents in parallel, passing previous review context to each tasks = [ - self._run_agent(agent_name, images, brand, on_agent_update) + self._run_agent( + agent_name, + images, + brand, + on_agent_update, + previous_review=self._extract_previous_review_context(agent_name, previous_analysis), + ) for agent_name in self.AGENT_ORDER ] results = await asyncio.gather(*tasks) @@ -152,7 +209,9 @@ class AnalysisService: if on_agent_update: await on_agent_update("Summary", None) - overall_status, summary, financial_promotion_reason = await self.lead_agent.synthesize(reviews) + overall_status, summary, financial_promotion_reason = await self.lead_agent.synthesize( + reviews, previous_analysis=previous_analysis + ) logger.info(f"[ANALYSIS] Analysis complete - overallStatus: {overall_status}") # Build the complete AgentReview diff --git a/backend/app/services/gemini_service.py b/backend/app/services/gemini_service.py index f08fc81..fd3575a 100755 --- a/backend/app/services/gemini_service.py +++ b/backend/app/services/gemini_service.py @@ -29,6 +29,7 @@ class GeminiService: prompt: str, file_data: bytes, file_type: str, + include_revision_fields: bool = False, ) -> SubReview: """ Analyze an image/file with Gemini and return a structured SubReview. @@ -37,12 +38,15 @@ class GeminiService: 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 + 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") + 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( @@ -77,6 +81,25 @@ class GeminiService: "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.client.aio.models.generate_content( @@ -102,12 +125,19 @@ class GeminiService: issues=[] ) - # Return successful analysis - return SubReview( - ragStatus=RagStatus(parsed_result["ragStatus"]), - feedback=parsed_result["feedback"], - issues=parsed_result["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)}") @@ -128,6 +158,7 @@ class GeminiService: self, prompt: str, images: List[Tuple[bytes, str]], + include_revision_fields: bool = False, ) -> SubReview: """ Analyze multiple images with Gemini and return a structured SubReview. @@ -137,12 +168,15 @@ class GeminiService: 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 + 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") + 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 = [] @@ -178,6 +212,25 @@ class GeminiService: "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] @@ -206,12 +259,19 @@ class GeminiService: issues=[] ) - # Return successful analysis - return SubReview( - ragStatus=RagStatus(parsed_result["ragStatus"]), - feedback=parsed_result["feedback"], - issues=parsed_result["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)}") diff --git a/backend/app/websocket/handlers.py b/backend/app/websocket/handlers.py index 2a71cd9..efbf6d8 100755 --- a/backend/app/websocket/handlers.py +++ b/backend/app/websocket/handlers.py @@ -81,6 +81,9 @@ async def handle_analyze_message( "issues": review.issues, "isFinancialPromotion": review.isFinancialPromotion, "financialPromotionReason": review.financialPromotionReason, + "resolvedIssues": review.resolvedIssues, + "outstandingIssues": review.outstandingIssues, + "newIssues": review.newIssues, } }) @@ -88,6 +91,31 @@ async def handle_analyze_message( brand = data.get("brand", "Barclaycard") # Default to Barclaycard if not specified logger.info(f"[WEBSOCKET] Brand selection: {brand}") + # Fetch previous analysis if this is a revision + previous_analysis = None + campaign_id = data.get("campaign_id") + proof_name = data.get("proof_name") + + if campaign_id and proof_name: + try: + logger.info(f"[WEBSOCKET] Checking for previous analysis - campaign: {campaign_id}, proof: {proof_name}") + async with async_session_factory() as session: + proof_repo = ProofRepository(session) + existing_proof = await proof_repo.get_by_campaign_and_name( + uuid.UUID(campaign_id), proof_name + ) + if existing_proof: + previous_analysis = await proof_repo.get_latest_version_review(existing_proof.id) + if previous_analysis: + logger.info(f"[WEBSOCKET] Found previous analysis version {previous_analysis.get('version')}") + else: + logger.info("[WEBSOCKET] No previous analysis found (new proof)") + else: + logger.info("[WEBSOCKET] No existing proof found (new proof)") + except Exception as e: + logger.warning(f"[WEBSOCKET] Failed to fetch previous analysis: {str(e)}") + # Continue without previous analysis - still run the current analysis + # Run the analysis logger.info("[WEBSOCKET] Starting analysis...") result, pdf_pages = await analysis_service.analyze_proof( @@ -96,6 +124,7 @@ async def handle_analyze_message( on_agent_update=on_agent_update, is_wip=is_wip, brand=brand, + previous_analysis=previous_analysis, ) # Build the result dict @@ -106,21 +135,33 @@ async def handle_analyze_message( "issues": result.legalAgentReview.issues, "isFinancialPromotion": result.legalAgentReview.isFinancialPromotion, "financialPromotionReason": result.legalAgentReview.financialPromotionReason, + "resolvedIssues": result.legalAgentReview.resolvedIssues, + "outstandingIssues": result.legalAgentReview.outstandingIssues, + "newIssues": result.legalAgentReview.newIssues, }, "brandAgentReview": { "ragStatus": result.brandAgentReview.ragStatus, "feedback": result.brandAgentReview.feedback, "issues": result.brandAgentReview.issues, + "resolvedIssues": result.brandAgentReview.resolvedIssues, + "outstandingIssues": result.brandAgentReview.outstandingIssues, + "newIssues": result.brandAgentReview.newIssues, }, "channelBestPracticesAgentReview": { "ragStatus": result.channelBestPracticesAgentReview.ragStatus, "feedback": result.channelBestPracticesAgentReview.feedback, "issues": result.channelBestPracticesAgentReview.issues, + "resolvedIssues": result.channelBestPracticesAgentReview.resolvedIssues, + "outstandingIssues": result.channelBestPracticesAgentReview.outstandingIssues, + "newIssues": result.channelBestPracticesAgentReview.newIssues, }, "channelTechSpecsAgentReview": { "ragStatus": result.channelTechSpecsAgentReview.ragStatus, "feedback": result.channelTechSpecsAgentReview.feedback, "issues": result.channelTechSpecsAgentReview.issues, + "resolvedIssues": result.channelTechSpecsAgentReview.resolvedIssues, + "outstandingIssues": result.channelTechSpecsAgentReview.outstandingIssues, + "newIssues": result.channelTechSpecsAgentReview.newIssues, }, "leadAgentSummary": result.leadAgentSummary, "overallStatus": result.overallStatus, @@ -130,8 +171,6 @@ async def handle_analyze_message( # Persist to database if campaign info provided proof_id: Optional[str] = None version_id: Optional[str] = None - campaign_id = data.get("campaign_id") - proof_name = data.get("proof_name") if campaign_id and proof_name: try: diff --git a/frontend/types.ts b/frontend/types.ts index 0e9065c..2e8eaf4 100755 --- a/frontend/types.ts +++ b/frontend/types.ts @@ -13,6 +13,10 @@ export interface SubReview { ragStatus: RagStatus; feedback: string; issues: string[]; + // Revision-aware fields (populated when analyzing version N > 1) + resolvedIssues?: string[]; + outstandingIssues?: string[]; + newIssues?: string[]; } export type OverallStatus = 'Passed' | 'Failed' | 'Analysis Error' | 'Requires Manual Legal Review';