commit e97d0e935c074b43a853b4bad4fafec39d4a6c8d Author: michael Date: Fri Dec 12 09:03:17 2025 -0600 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f628062 --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +# Environment files with secrets +.env +.env.local +.env.*.local + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +venv/ +ENV/ +env/ +.venv/ + +# Node +node_modules/ +dist/ +build/ +*.log +npm-debug.log* + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +.DS_Store + +# Build outputs +*.egg-info/ +.eggs/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..66c15d1 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,63 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Mod Comms is an AI-powered proof review tool for Barclays marketing materials. It uses multiple AI agents to analyze uploaded proofs (marketing assets) for legal compliance, brand adherence, tone of voice, and channel suitability. + +## Development Commands + +```bash +cd frontend +npm install # Install dependencies +npm run dev # Start dev server on port 3000 +npm run build # Production build +npm run preview # Preview production build +``` + +## Environment Setup + +Create `frontend/.env.local` with: +``` +GEMINI_API_KEY=your_api_key_here +``` + +## Architecture + +### Frontend Structure (`frontend/`) +- **App.tsx** - Main application component, handles routing between views and manages global state (campaigns, proofs, flagged/resolved/error items) +- **components/** - React components for each view and UI element +- **services/geminiService.ts** - Google Gemini AI integration for proof analysis + +### Multi-Agent System +Four specialist AI agents analyze each proof: +- **Legal Agent** - Checks advertising standards, disclaimers, financial promotion detection +- **Brand Agent** - Verifies brand guidelines (logos, colors, branding) +- **Tone Agent** - Analyzes copy clarity, brand personality, grammar +- **Channel Agent** - Assesses technical suitability for digital channels + +A **Lead Agent** mediates specialist feedback and determines overall RAG status: +- Green: All pass, max 1 amber issue per agent (except Legal amber triggers overall Amber) +- Amber: >1 actionable issue per agent, or any Legal amber +- Red: Failure from any agent + +### Key Data Types (`types.ts`) +- `AgentReview` - Complete analysis result with all agent reviews +- `RagStatus` - 'Red' | 'Amber' | 'Green' | 'Error' +- `OverallStatus` - 'Passed' | 'Failed' | 'Analysis Error' | 'Requires Manual Legal Review' + +### State Persistence +Application state persists to localStorage with `barclays_modcomms_*_v3` keys. Large preview URLs are stripped before saving to avoid quota errors. + +### User Roles +- **Admin** - Full access to all campaigns, Settings, Analytics, Auditing +- **Basic User** - Limited to own agency's campaigns + +## Reference Documentation + +Brand and channel guidelines are in `reference_docs/`: +- `brand/` - Barclays/Barclaycard brand guidelines, tone of voice, design principles +- `channel/` - Digital and social media guidelines + +Product requirements specification: `specs/Barclays_ModComms_Req_v5.md` diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..79342ad --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,15 @@ +# Gemini API Configuration +# Get your API key from: https://aistudio.google.com/app/apikey +GEMINI_API_KEY=your_gemini_api_key_here + +# Reference Documents Path (optional) +# Defaults to ../reference_docs relative to backend/ +# REFERENCE_DOCS_PATH=/path/to/reference_docs + +# CORS Configuration +# Comma-separated list of allowed origins +CORS_ORIGINS=http://localhost:3000 + +# Server Configuration +HOST=0.0.0.0 +PORT=8000 diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..309ad57 --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1 @@ +# ModComms Backend Application diff --git a/backend/app/agents/__init__.py b/backend/app/agents/__init__.py new file mode 100644 index 0000000..0afd633 --- /dev/null +++ b/backend/app/agents/__init__.py @@ -0,0 +1,6 @@ +from .base_agent import BaseAgent +from .brand_agent import BrandAgent +from .channel_agent import ChannelAgent +from .legal_agent import LegalAgent +from .tone_agent import ToneAgent +from .lead_agent import LeadAgent diff --git a/backend/app/agents/base_agent.py b/backend/app/agents/base_agent.py new file mode 100644 index 0000000..4d22491 --- /dev/null +++ b/backend/app/agents/base_agent.py @@ -0,0 +1,22 @@ +from abc import ABC, abstractmethod +from app.models.schemas import SubReview + + +class BaseAgent(ABC): + """Abstract base class for all review agents.""" + + name: str = "Base Agent" + + @abstractmethod + async def analyze(self, file_data: bytes, file_type: str) -> SubReview: + """ + Analyze the proof and return a SubReview. + + Args: + file_data: Raw bytes of the file to analyze + file_type: MIME type of the file + + Returns: + SubReview containing ragStatus, feedback, and issues + """ + pass diff --git a/backend/app/agents/brand_agent.py b/backend/app/agents/brand_agent.py new file mode 100644 index 0000000..5a534bd --- /dev/null +++ b/backend/app/agents/brand_agent.py @@ -0,0 +1,66 @@ +from app.agents.base_agent import BaseAgent +from app.models.schemas import SubReview +from app.services.gemini_service import GeminiService +from app.services.reference_docs import ReferenceDocsService + + +class BrandAgent(BaseAgent): + """Brand Agent - analyzes proofs against Barclays brand guidelines using Gemini.""" + + name = "Brand Agent" + + def __init__(self, gemini_service: GeminiService, reference_docs: ReferenceDocsService): + """ + Initialize the Brand Agent. + + Args: + gemini_service: Service for making Gemini API calls + reference_docs: Service for loading reference documents + """ + self.gemini = gemini_service + self.brand_context = reference_docs.get_brand_context() + + async def analyze(self, file_data: bytes, file_type: str) -> SubReview: + """ + Analyze the proof for brand guideline adherence. + + Args: + file_data: Raw bytes of the file to analyze + file_type: MIME type of the file + + Returns: + SubReview with brand compliance assessment + """ + prompt = f"""You are a brand expert for Barclays Bank. Your role is to analyze marketing proofs for adherence to Barclays UK brand guidelines. + +Here are the brand guidelines to use for your analysis: + +{self.brand_context} + +--- + +Analyze the uploaded proof against these Barclays brand guidelines, checking for: + +1. **Logo Usage**: Is the Barclays Eagle/logo used correctly? Check sizing, placement (should be top-right or as specified), clear space requirements, and that it hasn't been altered. + +2. **Card Portal**: If the Card Portal asset is present, verify it follows the sizing rules (1/12 of shortest side for print, responsive for digital), proper cyan color, and corner visibility. + +3. **Color Palette**: Are only approved brand colors used? Check for proper color pairings and that cyan is used appropriately as a sacred asset. + +4. **Typography**: Is Barclays Effra (or approved fallback) used correctly? Check font weights, sizes, and hierarchy. + +5. **Design Principles**: Does the overall design reflect the brand principles of Bold, Purposeful, and Expressive? + +6. **Sacred Assets**: Verify that sacred assets (Cyan, Logo/Eagle, Portal) are not altered or misused. + +Provide your analysis as a JSON object. Be specific about any issues found and reference the relevant guideline sections. + +RAG Status Guidelines: +- **Green**: Fully compliant with brand guidelines, no issues +- **Amber**: Minor deviations that should be addressed but don't severely impact brand integrity +- **Red**: Significant brand guideline violations that must be fixed before use + +If the proof is nonsensical, not a marketing material, or cannot be analyzed, set analysisStatus to 'low_confidence'. +""" + + return await self.gemini.analyze_with_image(prompt, file_data, file_type) diff --git a/backend/app/agents/channel_agent.py b/backend/app/agents/channel_agent.py new file mode 100644 index 0000000..5c1d211 --- /dev/null +++ b/backend/app/agents/channel_agent.py @@ -0,0 +1,84 @@ +from app.agents.base_agent import BaseAgent +from app.models.schemas import SubReview +from app.services.gemini_service import GeminiService +from app.services.reference_docs import ReferenceDocsService + + +class ChannelAgent(BaseAgent): + """Channel Agent - analyzes proofs for digital channel suitability using Gemini.""" + + name = "Channel Agent" + + def __init__(self, gemini_service: GeminiService, reference_docs: ReferenceDocsService): + """ + Initialize the Channel Agent. + + Args: + gemini_service: Service for making Gemini API calls + reference_docs: Service for loading reference documents + """ + self.gemini = gemini_service + self.channel_context = reference_docs.get_channel_context() + + async def analyze(self, file_data: bytes, file_type: str) -> SubReview: + """ + Analyze the proof for channel suitability. + + Args: + file_data: Raw bytes of the file to analyze + file_type: MIME type of the file + + Returns: + SubReview with channel suitability assessment + """ + prompt = f"""You are a digital channel specialist for Barclays Bank. Your role is to analyze marketing proofs for technical suitability across digital and social media channels. + +Here are the channel guidelines to use for your analysis: + +{self.channel_context} + +--- + +Analyze the uploaded proof for technical suitability for its intended digital channel, checking: + +1. **Social Media Compliance** (if applicable): + - Logo placement (should be top-right corner, 40px from edges on social) + - Portal sizing for different platforms (8px thin, 16px thin, or standard as appropriate) + - Format specifications (proper dimensions for the target platform) + +2. **Digital Grid System**: + - Desktop: 12-column grid + - Tablet: 12-column grid + - Mobile: 6-column grid + - 8px baseline grid adherence + +3. **Typography Scale**: + - Check if text sizing follows the 8-level scale (Supersize 80px down to X Small 12px) + - Responsive type considerations for different breakpoints (640px mobile threshold) + +4. **Accessibility**: + - Color contrast meets accessibility requirements + - Only documented color pairings are used + - Text is readable at the intended display size + +5. **Platform-Specific Requirements**: + - Hashtag usage guidelines (if social media) + - Emoji usage (appropriate vs. inappropriate as per guidelines) + - Character limits (Headlines: 65 chars, Body: 300 chars, Quotations: 250 chars) + +6. **Motion/Video** (if applicable): + - Start and end frame compliance + - Subtitle formatting + - Frame rate and format requirements + +Provide your analysis as a JSON object. Be specific about any technical issues and reference the relevant platform or guideline. + +RAG Status Guidelines: +- **Green**: Fully suitable for the intended channel, all specs met +- **Amber**: Minor technical adjustments needed for optimal display +- **Red**: Significant technical issues that will impact display or accessibility + +If the proof is nonsensical, not a marketing material, or cannot be analyzed, set analysisStatus to 'low_confidence'. +""" + + return await self.gemini.analyze_with_image(prompt, file_data, file_type) diff --git a/backend/app/agents/lead_agent.py b/backend/app/agents/lead_agent.py new file mode 100644 index 0000000..19ee5e1 --- /dev/null +++ b/backend/app/agents/lead_agent.py @@ -0,0 +1,124 @@ +from app.models.schemas import SubReview, RagStatus, OverallStatus +from app.services.gemini_service import GeminiService + + +class LeadAgent: + """ + Lead Agent - synthesizes specialist agent reviews into final verdict. + + Applies the decision logic: + - Financial promotion detected → Requires Manual Legal Review + - Any Error status → Analysis Error + - Any Red status → Failed + - Otherwise → Passed + """ + + name = "Lead Agent" + + def __init__(self, gemini_service: GeminiService): + """ + Initialize the Lead Agent. + + Args: + gemini_service: Service for making Gemini API calls (for summary generation) + """ + self.gemini = gemini_service + + async def synthesize( + self, + reviews: dict[str, SubReview], + ) -> tuple[OverallStatus, str, str | None]: + """ + Synthesize specialist reviews into final verdict and summary. + + Args: + reviews: Dictionary mapping agent names to their SubReview results + + Returns: + Tuple of (overall_status, summary, financial_promotion_reason) + """ + legal_review = reviews.get("Legal Agent") + + # Check for financial promotion (from Legal Agent) + is_financial_promotion = ( + legal_review is not None + and legal_review.isFinancialPromotion is True + ) + financial_promotion_reason = ( + legal_review.financialPromotionReason + if legal_review and legal_review.financialPromotionReason + else None + ) + + # 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. +Your task is to provide a final verdict and write a concise, professional summary to the user. + +Here is the logic you must follow: +1. The Legal Agent has determined if this is a financial promotion: {is_financial_promotion}. +2. If it IS a financial promotion, the final verdict MUST be 'Requires Manual Legal Review'. Your summary should state this clearly, explain that a separate manual legal review is required, and then summarize any other issues found by the other agents. +3. If it is NOT a financial promotion, follow the standard logic: + a. If ANY specialist agent reports a 'ragStatus' of 'Error', the final verdict MUST be 'Analysis Error'. + b. If ANY specialist agent reports a 'Red' status (and there are no 'Error' statuses), the final verdict MUST be 'Failed'. + c. If there are NO 'Red' or 'Error' statuses, the final verdict is 'Passed'. + +Your summary should: +- For a 'Requires Manual Legal Review' verdict, start by stating this. Then, consolidate feedback from all agents, highlighting critical issues ('Red' items) or suggestions ('Amber' items). +- For an 'Analysis Error' verdict, explain that the proof could not be reliably processed and has been logged for human review. Advise the user to try again with a revised proof. +- For a 'Failed' status, highlight the critical 'Red' issues that must be addressed. +- For a 'Passed' status, mention any 'Amber' areas for consideration, if they exist, while maintaining an encouraging tone. +- Be professional, clear, and constructive. + +Here are the specialist reviews: +{self._format_reviews(reviews)} + +Now, provide your final verdict and summary as a JSON object. +""" + + result = await self.gemini.generate_summary(prompt) + + overall_status = OverallStatus(result.get("overallStatus", "Analysis Error")) + summary = result.get("summary", "Unable to generate summary.") + + # Override with financial promotion logic if applicable + if is_financial_promotion: + overall_status = OverallStatus.REQUIRES_MANUAL_LEGAL_REVIEW + + return overall_status, summary, financial_promotion_reason + + def _format_reviews(self, reviews: dict[str, SubReview]) -> str: + """Format reviews as a readable string for the prompt.""" + formatted = [] + for agent_name, review in reviews.items(): + formatted.append(f""" +{agent_name}: + RAG Status: {review.ragStatus} + Feedback: {review.feedback} + Issues: {review.issues if review.issues else 'None'} +""") + return "\n".join(formatted) + + def determine_status_locally(self, reviews: dict[str, SubReview]) -> OverallStatus: + """ + Determine overall status using local logic (without Gemini). + + This can be used as a fallback or for faster processing. + """ + legal_review = reviews.get("Legal Agent") + + # Check for financial promotion + if legal_review and legal_review.isFinancialPromotion: + return OverallStatus.REQUIRES_MANUAL_LEGAL_REVIEW + + # Check for Error status + for review in reviews.values(): + if review.ragStatus == RagStatus.ERROR: + return OverallStatus.ANALYSIS_ERROR + + # Check for Red status + for review in reviews.values(): + if review.ragStatus == RagStatus.RED: + return OverallStatus.FAILED + + return OverallStatus.PASSED diff --git a/backend/app/agents/legal_agent.py b/backend/app/agents/legal_agent.py new file mode 100644 index 0000000..e8cdf0f --- /dev/null +++ b/backend/app/agents/legal_agent.py @@ -0,0 +1,35 @@ +import asyncio +from app.agents.base_agent import BaseAgent +from app.models.schemas import SubReview, RagStatus + + +class LegalAgent(BaseAgent): + """ + Legal Agent - STUB implementation. + + Returns mock Green status. Full legal review requires manual verification. + """ + + name = "Legal Agent" + + async def analyze(self, file_data: bytes, file_type: str) -> SubReview: + """ + Stub implementation that returns mock Green status. + + Args: + file_data: Raw bytes of the file (not used in stub) + file_type: MIME type of the file (not used in stub) + + Returns: + SubReview with Green status and stub notice + """ + # Simulate some processing time for realistic UX + await asyncio.sleep(0.5) + + return SubReview( + ragStatus=RagStatus.GREEN, + feedback="[STUB] Legal compliance check passed. This is a placeholder response - the Legal Agent has not been implemented for this POC. Full legal review, including financial promotion detection and regulatory compliance, requires the complete implementation.", + issues=[], + isFinancialPromotion=False, + financialPromotionReason="" + ) diff --git a/backend/app/agents/tone_agent.py b/backend/app/agents/tone_agent.py new file mode 100644 index 0000000..c552517 --- /dev/null +++ b/backend/app/agents/tone_agent.py @@ -0,0 +1,33 @@ +import asyncio +from app.agents.base_agent import BaseAgent +from app.models.schemas import SubReview, RagStatus + + +class ToneAgent(BaseAgent): + """ + Tone Agent - STUB implementation. + + Returns mock Green status. Full tone analysis requires implementation. + """ + + name = "Tone Agent" + + async def analyze(self, file_data: bytes, file_type: str) -> SubReview: + """ + Stub implementation that returns mock Green status. + + Args: + file_data: Raw bytes of the file (not used in stub) + file_type: MIME type of the file (not used in stub) + + Returns: + SubReview with Green status and stub notice + """ + # Simulate some processing time for realistic UX + await asyncio.sleep(0.5) + + return SubReview( + ragStatus=RagStatus.GREEN, + feedback="[STUB] Tone of voice analysis passed. This is a placeholder response - the Tone Agent has not been implemented for this POC. The copy appears to demonstrate appropriate brand personality traits: Pioneering, Connected, Optimistic, and Professional. Full analysis of clarity, grammar, and brand voice alignment requires the complete implementation.", + issues=[] + ) diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..f7775bb --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,27 @@ +import os +from pathlib import Path +from dotenv import load_dotenv + +# Load environment variables from .env file +load_dotenv() + + +class Settings: + """Application settings loaded from environment variables.""" + + GEMINI_API_KEY: str = os.getenv("GEMINI_API_KEY", "") + CORS_ORIGINS: str = os.getenv("CORS_ORIGINS", "http://localhost:3000") + HOST: str = os.getenv("HOST", "0.0.0.0") + PORT: int = int(os.getenv("PORT", "8000")) + + # Reference docs path - defaults to ../reference_docs relative to backend/ + _default_ref_docs = Path(__file__).parent.parent.parent / "reference_docs" + REFERENCE_DOCS_PATH: str = os.getenv("REFERENCE_DOCS_PATH", str(_default_ref_docs)) + + def validate(self) -> None: + """Validate required settings are present.""" + if not self.GEMINI_API_KEY: + raise ValueError("GEMINI_API_KEY environment variable is required") + + +settings = Settings() diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..9e70d6f --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,157 @@ +import logging +import uuid +from contextlib import asynccontextmanager + +from fastapi import FastAPI, WebSocket, WebSocketDisconnect +from fastapi.middleware.cors import CORSMiddleware + +from app.config import settings + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + datefmt="%Y-%m-%d %H:%M:%S" +) +logger = logging.getLogger(__name__) +from app.websocket.manager import ConnectionManager +from app.websocket.handlers import handle_analyze_message +from app.services.gemini_service import GeminiService +from app.services.reference_docs import ReferenceDocsService +from app.services.analysis_service import AnalysisService + + +# Global services - initialized at startup +manager = ConnectionManager() +analysis_service: AnalysisService | None = None + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """ + Initialize services on startup and cleanup on shutdown. + + Loads reference documents and initializes the analysis service. + """ + global analysis_service + + # Validate settings + settings.validate() + + # Initialize services + print("Loading reference documents...") + reference_docs = ReferenceDocsService(settings.REFERENCE_DOCS_PATH) + + # Log document info + doc_summary = reference_docs.get_context_summary() + print(f" Brand documents: {len(doc_summary['brand_files'])} files ({doc_summary['brand_context_length']} chars)") + print(f" Channel documents: {len(doc_summary['channel_files'])} files ({doc_summary['channel_context_length']} chars)") + + print("Initializing Gemini service...") + gemini_service = GeminiService(settings.GEMINI_API_KEY) + + print("Initializing analysis service...") + analysis_service = AnalysisService(gemini_service, reference_docs) + + print("Backend ready!") + + yield + + # Cleanup on shutdown (if needed) + print("Shutting down...") + + +# Create FastAPI app +app = FastAPI( + title="ModComms Proof Review API", + description="AI-powered proof review backend for Barclays marketing materials", + version="1.0.0", + lifespan=lifespan, +) + +# CORS middleware - allow frontend to connect +app.add_middleware( + CORSMiddleware, + allow_origins=settings.CORS_ORIGINS.split(","), + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +@app.get("/health") +async def health_check(): + """Health check endpoint.""" + return {"status": "healthy", "service": "modcomms-backend"} + + +@app.get("/info") +async def info(): + """Get backend information.""" + if analysis_service: + ref_docs = analysis_service.reference_docs + doc_summary = ref_docs.get_context_summary() + return { + "status": "ready", + "agents": ["Legal Agent", "Brand Agent", "Tone Agent", "Channel Agent"], + "reference_docs": doc_summary, + } + return {"status": "initializing"} + + +@app.websocket("/ws/analyze") +async def websocket_analyze(websocket: WebSocket): + """ + WebSocket endpoint for proof analysis with real-time updates. + + Protocol: + - Client sends: {"type": "analyze", "file_data": "", "file_type": "image/png", "is_wip": false} + - Server sends: {"type": "agent_started", "agent_name": "..."} + - Server sends: {"type": "agent_completed", "agent_name": "...", "review": {...}} + - Server sends: {"type": "complete", "result": {...}} + - On error: {"type": "error", "message": "..."} + """ + client_id = str(uuid.uuid4()) + logger.info(f"[MAIN] WebSocket connection established - client_id: {client_id}") + await manager.connect(websocket, client_id) + + try: + while True: + # Wait for a message from the client + data = await websocket.receive_json() + logger.info(f"[MAIN] Received message from client {client_id} - type: {data.get('type')}") + + if data.get("type") == "analyze": + if analysis_service is None: + logger.error("[MAIN] Analysis service not ready") + await manager.send_message(client_id, { + "type": "error", + "message": "Backend not ready. Please wait for initialization." + }) + continue + + # Handle the analysis request + await handle_analyze_message( + websocket=websocket, + client_id=client_id, + data=data, + manager=manager, + analysis_service=analysis_service, + ) + else: + logger.warning(f"[MAIN] Unknown message type: {data.get('type')}") + await manager.send_message(client_id, { + "type": "error", + "message": f"Unknown message type: {data.get('type')}" + }) + + except WebSocketDisconnect: + logger.info(f"[MAIN] Client {client_id} disconnected") + manager.disconnect(client_id) + except Exception as e: + logger.error(f"[MAIN] Error for client {client_id}: {str(e)}") + await manager.send_message(client_id, { + "type": "error", + "message": str(e) + }) + manager.disconnect(client_id) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..21a3b0a --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1 @@ +from .schemas import RagStatus, OverallStatus, SubReview, AgentReview diff --git a/backend/app/models/schemas.py b/backend/app/models/schemas.py new file mode 100644 index 0000000..a13f527 --- /dev/null +++ b/backend/app/models/schemas.py @@ -0,0 +1,96 @@ +from enum import Enum +from typing import Optional +from pydantic import BaseModel + + +class RagStatus(str, Enum): + """RAG status for agent reviews.""" + RED = "Red" + AMBER = "Amber" + GREEN = "Green" + ERROR = "Error" + + +class OverallStatus(str, Enum): + """Overall status for the proof review.""" + PASSED = "Passed" + FAILED = "Failed" + ANALYSIS_ERROR = "Analysis Error" + REQUIRES_MANUAL_LEGAL_REVIEW = "Requires Manual Legal Review" + + +class SubReview(BaseModel): + """Individual agent review result.""" + ragStatus: RagStatus + feedback: str + issues: list[str] + isFinancialPromotion: Optional[bool] = None + financialPromotionReason: Optional[str] = None + + class Config: + use_enum_values = True + + +class AgentReview(BaseModel): + """Complete review from all agents.""" + legalAgentReview: SubReview + brandAgentReview: SubReview + toneAgentReview: SubReview + channelAgentReview: SubReview + leadAgentSummary: str + overallStatus: OverallStatus + financialPromotionReason: Optional[str] = None + + class Config: + use_enum_values = True + + +# WebSocket message types +class AnalyzeRequest(BaseModel): + """Request to analyze a proof via WebSocket.""" + type: str # Should be "analyze" + file_data: str # Base64 encoded file + file_type: str # MIME type + is_wip: bool = False + + +class AgentStartedMessage(BaseModel): + """Message sent when an agent starts processing.""" + type: str = "agent_started" + agent_name: str + + +class AgentCompletedMessage(BaseModel): + """Message sent when an agent completes.""" + type: str = "agent_completed" + agent_name: str + review: SubReview + + class Config: + use_enum_values = True + + +class SummaryMessage(BaseModel): + """Message sent when summary is ready.""" + type: str = "summary" + lead_agent_summary: str + overall_status: OverallStatus + financial_promotion_reason: Optional[str] = None + + class Config: + use_enum_values = True + + +class CompleteMessage(BaseModel): + """Message sent when analysis is complete.""" + type: str = "complete" + result: AgentReview + + class Config: + use_enum_values = True + + +class ErrorMessage(BaseModel): + """Message sent when an error occurs.""" + type: str = "error" + message: str diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..6720235 --- /dev/null +++ b/backend/app/services/__init__.py @@ -0,0 +1,3 @@ +from .gemini_service import GeminiService +from .reference_docs import ReferenceDocsService +from .analysis_service import AnalysisService diff --git a/backend/app/services/analysis_service.py b/backend/app/services/analysis_service.py new file mode 100644 index 0000000..eab83ad --- /dev/null +++ b/backend/app/services/analysis_service.py @@ -0,0 +1,115 @@ +import logging +from typing import Callable, Awaitable + +from app.models.schemas import SubReview, AgentReview, OverallStatus + +logger = logging.getLogger(__name__) +from app.agents.brand_agent import BrandAgent +from app.agents.channel_agent import ChannelAgent +from app.agents.legal_agent import LegalAgent +from app.agents.tone_agent import ToneAgent +from app.agents.lead_agent import LeadAgent +from app.services.gemini_service import GeminiService +from app.services.reference_docs import ReferenceDocsService + + +# Type alias for the callback function +AgentCallback = Callable[[str, SubReview | None], Awaitable[None]] + + +class AnalysisService: + """ + Orchestrates the multi-agent proof analysis. + + Runs agents sequentially and provides callbacks for real-time updates. + """ + + # Agent execution order + AGENT_ORDER = ["Legal Agent", "Brand Agent", "Tone Agent", "Channel Agent"] + + def __init__( + self, + gemini_service: GeminiService, + reference_docs: ReferenceDocsService, + ): + """ + Initialize the analysis service with all required agents. + + Args: + gemini_service: Service for Gemini API calls + reference_docs: Service for loading reference documents + """ + self.gemini_service = gemini_service + self.reference_docs = reference_docs + + # Initialize agents + self.agents = { + "Legal Agent": LegalAgent(), + "Brand Agent": BrandAgent(gemini_service, reference_docs), + "Tone Agent": ToneAgent(), + "Channel Agent": ChannelAgent(gemini_service, reference_docs), + } + self.lead_agent = LeadAgent(gemini_service) + + async def analyze_proof( + self, + file_data: bytes, + file_type: str, + on_agent_update: AgentCallback | None = None, + is_wip: bool = False, + ) -> AgentReview: + """ + Analyze a proof using all agents sequentially. + + Args: + file_data: Raw bytes of the file to analyze + file_type: MIME type of the file + on_agent_update: Optional callback for real-time agent updates. + Called with (agent_name, None) when agent starts, + and (agent_name, review) when agent completes. + is_wip: Whether this is a work-in-progress analysis + + Returns: + Complete AgentReview with all agent results and overall verdict + """ + logger.info(f"[ANALYSIS] Starting proof analysis - file_type: {file_type}, file_size: {len(file_data)} bytes, is_wip: {is_wip}") + reviews: dict[str, SubReview] = {} + + # Run each agent sequentially + for agent_name in self.AGENT_ORDER: + agent = self.agents[agent_name] + + logger.info(f"[ANALYSIS] Starting agent: {agent_name}") + + # Notify that agent is starting + if on_agent_update: + await on_agent_update(agent_name, None) + + # Run the agent + review = await agent.analyze(file_data, file_type) + reviews[agent_name] = review + + logger.info(f"[ANALYSIS] Agent completed: {agent_name} - ragStatus: {review.ragStatus}") + + # Notify that agent completed + if on_agent_update: + await on_agent_update(agent_name, review) + + # Get lead agent synthesis + logger.info("[ANALYSIS] Starting lead agent synthesis") + if on_agent_update: + await on_agent_update("Summary", None) + + overall_status, summary, financial_promotion_reason = await self.lead_agent.synthesize(reviews) + logger.info(f"[ANALYSIS] Analysis complete - overallStatus: {overall_status}") + + # Build the complete AgentReview + return AgentReview( + legalAgentReview=reviews["Legal Agent"], + brandAgentReview=reviews["Brand Agent"], + toneAgentReview=reviews["Tone Agent"], + channelAgentReview=reviews["Channel Agent"], + leadAgentSummary=summary, + overallStatus=overall_status, + financialPromotionReason=financial_promotion_reason, + ) diff --git a/backend/app/services/gemini_service.py b/backend/app/services/gemini_service.py new file mode 100644 index 0000000..78515d4 --- /dev/null +++ b/backend/app/services/gemini_service.py @@ -0,0 +1,173 @@ +import json +import logging +from google import genai +from google.genai import types + +from app.models.schemas import SubReview, RagStatus + +# Configure logging +logger = logging.getLogger(__name__) + + +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 + """ + self.client = genai.Client(api_key=api_key) + self.model = "gemini-2.5-flash" + + async def analyze_with_image( + self, + prompt: str, + file_data: bytes, + file_type: str, + ) -> 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") + + Returns: + SubReview with ragStatus, feedback, and issues + """ + try: + logger.info(f"[GEMINI API] Starting image analysis - file_type: {file_type}, file_size: {len(file_data)} bytes") + + # 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": "Constructive, professional feedback explaining the RAG status and highlighting both positive aspects and areas for improvement." + }, + "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"] + } + + # Make the API call + logger.info(f"[GEMINI API] Calling Gemini model: {self.model}") + response = await self.client.aio.models.generate_content( + model=self.model, + contents=[file_part, prompt], + config=types.GenerateContentConfig( + response_mime_type="application/json", + response_schema=response_schema + ) + ) + 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=[] + ) + + # Return successful analysis + return SubReview( + ragStatus=RagStatus(parsed_result["ragStatus"]), + feedback=parsed_result["feedback"], + issues=parsed_result["issues"] + ) + + 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 generate_summary( + self, + prompt: str, + ) -> 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": "A concise, professional summary explaining the overall status, based on the specialist reviews." + } + }, + "required": ["overallStatus", "summary"] + } + + response = await self.client.aio.models.generate_content( + model=self.model, + contents=prompt, + config=types.GenerateContentConfig( + response_mime_type="application/json", + response_schema=response_schema + ) + ) + + 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)}" + } diff --git a/backend/app/services/reference_docs.py b/backend/app/services/reference_docs.py new file mode 100644 index 0000000..041e33f --- /dev/null +++ b/backend/app/services/reference_docs.py @@ -0,0 +1,77 @@ +from pathlib import Path + + +class ReferenceDocsService: + """Service to load and provide reference documents for agents.""" + + def __init__(self, base_path: str | None = None): + """ + Initialize the reference docs service. + + Args: + base_path: Path to the reference_docs directory. + Defaults to ../reference_docs relative to backend/ + """ + if base_path is None: + # Default to reference_docs at project root (sibling to backend/) + base_path = Path(__file__).parent.parent.parent.parent / "reference_docs" + self.base_path = Path(base_path) + + # Cache loaded documents + self._brand_context: str | None = None + self._channel_context: str | None = None + + def get_brand_context(self) -> str: + """Load and return all brand guideline documents as a single context string.""" + if self._brand_context is None: + brand_path = self.base_path / "brand" + self._brand_context = self._load_all_markdown_files(brand_path) + return self._brand_context + + def get_channel_context(self) -> str: + """Load and return all channel guideline documents as a single context string.""" + if self._channel_context is None: + channel_path = self.base_path / "channel" + self._channel_context = self._load_all_markdown_files(channel_path) + return self._channel_context + + def _load_all_markdown_files(self, directory: Path) -> str: + """ + Load all .md files from a directory and concatenate them. + + Args: + directory: Path to the directory containing markdown files + + Returns: + Concatenated content of all markdown files with section headers + """ + contents = [] + if directory.exists(): + # Sort files for consistent ordering + for md_file in sorted(directory.glob("*.md")): + try: + content = md_file.read_text(encoding="utf-8") + # Add file name as section header + contents.append(f"## {md_file.stem}\n\n{content}") + except Exception as e: + print(f"Warning: Could not read {md_file}: {e}") + + if not contents: + return "No reference documents found." + + return "\n\n---\n\n".join(contents) + + def get_context_summary(self) -> dict: + """Return summary info about loaded documents.""" + brand_path = self.base_path / "brand" + channel_path = self.base_path / "channel" + + brand_files = list(brand_path.glob("*.md")) if brand_path.exists() else [] + channel_files = list(channel_path.glob("*.md")) if channel_path.exists() else [] + + return { + "brand_files": [f.name for f in brand_files], + "channel_files": [f.name for f in channel_files], + "brand_context_length": len(self.get_brand_context()), + "channel_context_length": len(self.get_channel_context()), + } diff --git a/backend/app/websocket/__init__.py b/backend/app/websocket/__init__.py new file mode 100644 index 0000000..3726d82 --- /dev/null +++ b/backend/app/websocket/__init__.py @@ -0,0 +1,2 @@ +from .manager import ConnectionManager +from .handlers import handle_analyze_message diff --git a/backend/app/websocket/handlers.py b/backend/app/websocket/handlers.py new file mode 100644 index 0000000..3b1425f --- /dev/null +++ b/backend/app/websocket/handlers.py @@ -0,0 +1,132 @@ +import base64 +import logging +from fastapi import WebSocket + +from app.websocket.manager import ConnectionManager +from app.services.analysis_service import AnalysisService +from app.models.schemas import SubReview + +logger = logging.getLogger(__name__) + + +async def handle_analyze_message( + websocket: WebSocket, + client_id: str, + data: dict, + manager: ConnectionManager, + analysis_service: AnalysisService, +) -> None: + """ + Handle an 'analyze' message from the client. + + Runs the proof analysis and sends real-time updates via WebSocket. + + Args: + websocket: The WebSocket connection + client_id: Unique client identifier + data: The message data containing file_data, file_type, is_wip + manager: Connection manager for sending messages + analysis_service: Service to run the analysis + """ + try: + logger.info(f"[WEBSOCKET] Received analyze request from client: {client_id}") + + # Extract and decode the file data + file_data_b64 = data.get("file_data", "") + file_type = data.get("file_type", "image/png") + is_wip = data.get("is_wip", False) + + logger.info(f"[WEBSOCKET] File type: {file_type}, is_wip: {is_wip}, base64 length: {len(file_data_b64)}") + + # Decode base64 file data + try: + file_data = base64.b64decode(file_data_b64) + logger.info(f"[WEBSOCKET] Decoded file size: {len(file_data)} bytes") + except Exception as e: + logger.error(f"[WEBSOCKET] Failed to decode file data: {str(e)}") + await manager.send_message(client_id, { + "type": "error", + "message": f"Failed to decode file data: {str(e)}" + }) + return + + # Create callback for real-time updates + async def on_agent_update(agent_name: str, review: SubReview | None) -> None: + if not manager.is_connected(client_id): + logger.warning(f"[WEBSOCKET] Client {client_id} disconnected, skipping update") + return + + if review is None: + # Agent is starting + logger.info(f"[WEBSOCKET] Sending agent_started: {agent_name}") + await manager.send_message(client_id, { + "type": "agent_started", + "agent_name": agent_name + }) + else: + # Agent completed + logger.info(f"[WEBSOCKET] Sending agent_completed: {agent_name} - ragStatus: {review.ragStatus}") + await manager.send_message(client_id, { + "type": "agent_completed", + "agent_name": agent_name, + "review": { + "ragStatus": review.ragStatus, + "feedback": review.feedback, + "issues": review.issues, + "isFinancialPromotion": review.isFinancialPromotion, + "financialPromotionReason": review.financialPromotionReason, + } + }) + + # Run the analysis + logger.info("[WEBSOCKET] Starting analysis...") + result = await analysis_service.analyze_proof( + file_data=file_data, + file_type=file_type, + on_agent_update=on_agent_update, + is_wip=is_wip, + ) + + # Send the complete result + logger.info(f"[WEBSOCKET] Analysis complete, sending result - overallStatus: {result.overallStatus}") + if manager.is_connected(client_id): + await manager.send_message(client_id, { + "type": "complete", + "result": { + "legalAgentReview": { + "ragStatus": result.legalAgentReview.ragStatus, + "feedback": result.legalAgentReview.feedback, + "issues": result.legalAgentReview.issues, + "isFinancialPromotion": result.legalAgentReview.isFinancialPromotion, + "financialPromotionReason": result.legalAgentReview.financialPromotionReason, + }, + "brandAgentReview": { + "ragStatus": result.brandAgentReview.ragStatus, + "feedback": result.brandAgentReview.feedback, + "issues": result.brandAgentReview.issues, + }, + "toneAgentReview": { + "ragStatus": result.toneAgentReview.ragStatus, + "feedback": result.toneAgentReview.feedback, + "issues": result.toneAgentReview.issues, + }, + "channelAgentReview": { + "ragStatus": result.channelAgentReview.ragStatus, + "feedback": result.channelAgentReview.feedback, + "issues": result.channelAgentReview.issues, + }, + "leadAgentSummary": result.leadAgentSummary, + "overallStatus": result.overallStatus, + "financialPromotionReason": result.financialPromotionReason, + } + }) + logger.info(f"[WEBSOCKET] Result sent to client: {client_id}") + + except Exception as e: + # Send error message + logger.error(f"[WEBSOCKET] Analysis failed for client {client_id}: {str(e)}") + if manager.is_connected(client_id): + await manager.send_message(client_id, { + "type": "error", + "message": f"Analysis failed: {str(e)}" + }) diff --git a/backend/app/websocket/manager.py b/backend/app/websocket/manager.py new file mode 100644 index 0000000..18190b5 --- /dev/null +++ b/backend/app/websocket/manager.py @@ -0,0 +1,49 @@ +from fastapi import WebSocket + + +class ConnectionManager: + """Manages WebSocket connections for real-time updates.""" + + def __init__(self): + """Initialize the connection manager.""" + self.active_connections: dict[str, WebSocket] = {} + + async def connect(self, websocket: WebSocket, client_id: str) -> None: + """ + Accept a new WebSocket connection. + + Args: + websocket: The WebSocket connection + client_id: Unique identifier for this client + """ + await websocket.accept() + self.active_connections[client_id] = websocket + + def disconnect(self, client_id: str) -> None: + """ + Remove a client connection. + + Args: + client_id: The client to disconnect + """ + if client_id in self.active_connections: + del self.active_connections[client_id] + + async def send_message(self, client_id: str, message: dict) -> None: + """ + Send a JSON message to a specific client. + + Args: + client_id: The target client + message: Dictionary to send as JSON + """ + if client_id in self.active_connections: + try: + await self.active_connections[client_id].send_json(message) + except Exception: + # Client may have disconnected + self.disconnect(client_id) + + def is_connected(self, client_id: str) -> bool: + """Check if a client is still connected.""" + return client_id in self.active_connections diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..8e716f3 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,8 @@ +fastapi>=0.109.0 +uvicorn[standard]>=0.27.0 +python-dotenv>=1.0.0 +google-genai>=1.5.4 +pydantic>=2.5.0 +python-multipart>=0.0.9 +aiofiles>=23.2.1 +websockets>=12.0 diff --git a/frontend/App.tsx b/frontend/App.tsx new file mode 100644 index 0000000..185170d --- /dev/null +++ b/frontend/App.tsx @@ -0,0 +1,813 @@ + +import React, { useState, useEffect } from 'react'; +import { Hero } from './components/Hero'; +import { analyzeProof } from './services/geminiService'; +import type { AgentReview, AgentName, FlaggedItem, ResolvedItem, ErrorItem } from './types'; +import { AGENT_NAMES } from './constants'; +import { Sidebar } from './components/Sidebar'; +import { ChecksOverview } from './components/ChecksOverview'; +import { Analytics } from './components/Analytics'; +import { Profile } from './components/Profile'; +import { CopyGenAI } from './components/CopyGenAI'; +import { Settings } from './components/Settings'; +import { Campaigns, initialCampaigns, initialCampaignProofs } from './components/Campaigns'; +import { Auditing } from './components/Auditing'; +import { Login } from './components/Login'; +import { WIPReviewer } from './components/WIPReviewer'; + +type View = 'Home' | 'Analytics' | 'Campaigns' | 'WIP Reviewer' | 'CopyGenAI' | 'Settings' | 'Profile' | 'Auditing'; + +export interface DropdownOptions { + campaigns: string[]; + // Hierarchy: Channel -> SubChannel -> ProofType[] + channels: Record>; +} + +const App: React.FC = () => { + const [isLoggedIn, setIsLoggedIn] = useState(false); + const [currentView, setCurrentView] = useState('Home'); + const [selectedCampaign, setSelectedCampaign] = useState(null); + const [selectedProof, setSelectedProof] = useState(null); + const [error, setError] = useState(null); + + const [dropdownOptions, setDropdownOptions] = useState(() => { + try { + const storedOptions = localStorage.getItem('barclays_modcomms_dropdown_options_v3'); + if (storedOptions) { + return JSON.parse(storedOptions); + } + } catch (error) { + console.error('Error reading dropdown options from localStorage', error); + } + return { + campaigns: ["Barclays Q4 Social"], + channels: { + "Social": { + "Meta": ["In-feed 1x1", "In-feed 4x5", "Reels static 9x16", "Stories Static 9x16", "In-feed 1x1 video", "9x16 reels", "9x16 stories"] + }, + "YouTube - (online video)": { + "Video": ["1x1", "16x9"] + }, + "Google - Performance Max": { + "Video": ["1x1", "9x16", "16x9"], + "Static image": ["1080x1080", "4x5 (1080x1350)", "1.91x1 (1200x628)", "Logo 1x1 (1080x1080)"] + }, + "Display": { + "Banner": ["300x600", "160x600", "970x250", "300x250"] + }, + ".co.uk Banner": { + "Web Banner Design + Static": ["720x540", "1316x740"] + }, + "Ad Copy": { + "Copy Document": ["Text"] + } + } + }; + }); + + useEffect(() => { + try { + localStorage.setItem('barclays_modcomms_dropdown_options_v3', JSON.stringify(dropdownOptions)); + } catch (error) { + console.error('Error saving dropdown options to localStorage', error); + } + }, [dropdownOptions]); + + + const [campaigns, setCampaigns] = useState(() => { + try { + const storedCampaigns = localStorage.getItem('barclays_modcomms_campaigns_v3'); + return storedCampaigns ? JSON.parse(storedCampaigns) : initialCampaigns; + } catch (error) { + console.error('Error reading campaigns from localStorage', error); + return initialCampaigns; + } + }); + + const [campaignProofs, setCampaignProofs] = useState(() => { + try { + const storedProofs = localStorage.getItem('barclays_modcomms_campaign_proofs_v3'); + return storedProofs ? JSON.parse(storedProofs) : initialCampaignProofs; + } catch (error) { + console.error('Error reading campaign proofs from localStorage', error); + return initialCampaignProofs; + } + }); + + const [flaggedItems, setFlaggedItems] = useState(() => { + try { + const storedFlags = localStorage.getItem('barclays_modcomms_flagged_items_v3'); + return storedFlags ? JSON.parse(storedFlags) : []; + } catch (error) { + console.error('Error reading flagged items from localStorage', error); + return []; + } + }); + + const [resolvedItems, setResolvedItems] = useState(() => { + try { + const storedResolutions = localStorage.getItem('barclays_modcomms_resolved_items_v3'); + return storedResolutions ? JSON.parse(storedResolutions) : []; + } catch (error) { + console.error('Error reading resolved items from localStorage', error); + return []; + } + }); + + const [errorItems, setErrorItems] = useState(() => { + try { + const storedErrors = localStorage.getItem('barclays_modcomms_error_items_v3'); + return storedErrors ? JSON.parse(storedErrors) : []; + } catch (error) { + console.error('Error reading error items from localStorage', error); + return []; + } + }); + + useEffect(() => { + try { + if (!localStorage.getItem('barclays_modcomms_campaigns_v3')) { + localStorage.setItem('barclays_modcomms_campaigns_v3', JSON.stringify(initialCampaigns)); + } + if (!localStorage.getItem('barclays_modcomms_campaign_proofs_v3')) { + localStorage.setItem('barclays_modcomms_campaign_proofs_v3', JSON.stringify(initialCampaignProofs)); + } + } catch (error) { + console.error('Error saving initial data to localStorage', error); + } + }, []); + + useEffect(() => { + // Keep selectedProof in sync with the master list in campaignProofs. + // This ensures that when a new version is added, the detail view refreshes. + if (selectedCampaign && selectedProof && campaignProofs[selectedCampaign]) { + const currentCampaignProofs = campaignProofs[selectedCampaign]; + const freshProof = currentCampaignProofs.find(a => !a.tempId && a.proofName === selectedProof.proofName); + + // A simple stringify check to prevent re-renders if the object is the same. + if (freshProof && JSON.stringify(freshProof) !== JSON.stringify(selectedProof)) { + setSelectedProof(freshProof); + } + } + }, [campaignProofs, selectedCampaign, selectedProof]); + + // New function to handle saving campaign proofs to avoid QuotaExceededError + const saveCampaignProofs = (proofs: any) => { + try { + // Deep clone to avoid mutating the state object used by React + const proofsToSave = JSON.parse(JSON.stringify(proofs)); + + // Iterate over all campaigns and their proofs + for (const campaignName in proofsToSave) { + if (Object.prototype.hasOwnProperty.call(proofsToSave, campaignName)) { + proofsToSave[campaignName].forEach((proof: any) => { + // For temporary placeholder proofs, remove the file object and progress + // as it cannot be stringified and is not needed for persistence. + if (proof.tempId) { + if ('file' in proof) delete proof.file; + if ('analysisProgress' in proof) delete proof.analysisProgress; + } + + // For versioned proofs, remove large non-SVG preview URLs + if (proof.versions) { + proof.versions.forEach((version: any) => { + if (version.proofPreviewUrl && !version.proofPreviewUrl.startsWith('data:image/svg+xml')) { + delete version.proofPreviewUrl; + } + }); + } + }); + } + } + + localStorage.setItem('barclays_modcomms_campaign_proofs_v3', JSON.stringify(proofsToSave)); + } catch (error) { + if ((error as DOMException).name === 'QuotaExceededError') { + console.error('LocalStorage quota exceeded. Could not save campaign proofs. The app might not persist data correctly across sessions until storage is cleared.', error); + setError('Could not save campaign changes, storage is full.'); + } else { + console.error('Error saving campaign proofs to localStorage', error); + } + } + }; + + const handleAddNewCampaign = (campaignData: { name: string; workfrontId: string; clientLead: string; brandGuidelines: string; }) => { + const newCampaign = { + ...campaignData, + agency: "OLIVER Agency", + agencyLead: "Steve O'Donoghue", + proofs: 0, + status: 'In Progress', + lastModified: new Date().toISOString().split('T')[0], + }; + + const updatedCampaigns = [...campaigns, newCampaign]; + const updatedCampaignProofs = { ...campaignProofs, [newCampaign.name]: [] }; + + setCampaigns(updatedCampaigns); + setCampaignProofs(updatedCampaignProofs); + + try { + localStorage.setItem('barclays_modcomms_campaigns_v3', JSON.stringify(updatedCampaigns)); + saveCampaignProofs(updatedCampaignProofs); + } catch (error) { + console.error('Error saving new campaign to localStorage', error); + } + }; + + const fileToDataUrl = (file: File): Promise => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result as string); + reader.onerror = reject; + reader.readAsDataURL(file); + }); + }; + + const handleAddNewError = (errorData: Omit) => { + const newError: ErrorItem = { + ...errorData, + id: `err_${Date.now()}`, + timestamp: new Date().toISOString(), + submitter: "Steve O'Donoghue", // Hardcoded for prototype + submitAgency: "OLIVER Agency", // Hardcoded for prototype + }; + + setErrorItems(prevItems => { + const updatedErrors = [newError, ...prevItems]; + try { + localStorage.setItem('barclays_modcomms_error_items_v3', JSON.stringify(updatedErrors)); + } catch (error) { + console.error('Error saving error items to localStorage', error); + setError('Could not save the analysis error log due to storage limitations.'); + } + return updatedErrors; + }); + }; + + const handleProofUploadForCampaign = async ( + campaignName: string, + file: File, + proofName: string, + channel: string, + subChannel: string, + proofType?: string + ) => { + setError(null); + const tempId = `temp_${Date.now()}`; + const newProofPlaceholder = { + tempId, + proofName, + channel, + subChannel, + proofType, + status: 'analyzing', + analysisProgress: { completed: 0, total: AGENT_NAMES.length + 1 }, + file, + versions: [], + }; + + setCampaignProofs(prevProofs => ({ + ...prevProofs, + [campaignName]: [newProofPlaceholder, ...(prevProofs[campaignName] || [])] + })); + + const handleAgentUpdate = (agentName: AgentName | 'Summary') => { + setCampaignProofs(prevProofs => { + const currentProofs = prevProofs[campaignName] || []; + const updatedProofs = currentProofs.map(proof => { + if (proof.tempId === tempId && proof.status === 'analyzing') { + const newCompleted = (proof.analysisProgress?.completed ?? 0) + 1; + return { ...proof, analysisProgress: { ...proof.analysisProgress, completed: newCompleted } }; + } + return proof; + }); + return { ...prevProofs, [campaignName]: updatedProofs }; + }); + }; + + try { + const feedback = await analyzeProof(file, handleAgentUpdate); + const previewUrl = await fileToDataUrl(file); + + if (feedback.overallStatus === 'Analysis Error') { + const currentCampaignProofsList = campaignProofs[campaignName] || []; + const existingProof = currentCampaignProofsList.find(a => a.proofName === proofName && !a.tempId); + const version = existingProof ? (existingProof.versions[0]?.version || 0) + 1 : 1; + + handleAddNewError({ + campaignName, + proofName, + version, + errorSummary: feedback.leadAgentSummary, + }); + } + + setCampaignProofs(prevCampaignProofs => { + const currentCampaignProofsList = prevCampaignProofs[campaignName] || []; + const existingProof = currentCampaignProofsList.find(a => a.proofName === proofName && !a.tempId); + + if (existingProof) { + // UPDATE PROOF (NEW VERSION) + const latestVersionNumber = existingProof.versions[0]?.version || 0; + const newVersionNumber = latestVersionNumber + 1; + const baseWorkfrontId = existingProof.versions.length > 0 + ? existingProof.versions[existingProof.versions.length - 1].workfrontId.split('-V')[0] + : `#WF_${Math.floor(10000 + Math.random() * 90000)}`; + + const newVersion = { + version: newVersionNumber, + timestamp: new Date().toISOString().split('T')[0], + workfrontId: `${baseWorkfrontId}-V${newVersionNumber}`, + proofPreviewUrl: previewUrl, + feedback: feedback, + overallStatus: feedback.overallStatus, + }; + + const updatedProof = { + ...existingProof, + overallStatus: feedback.overallStatus, + versions: [newVersion, ...existingProof.versions] + }; + + const updatedProofsList = currentCampaignProofsList + .filter(proof => proof.tempId !== tempId) + .map(proof => proof.proofName === proofName ? updatedProof : proof); + + const finalCampaignProofs = { ...prevCampaignProofs, [campaignName]: updatedProofsList }; + + setCampaigns(prevCampaigns => { + const updatedCampaigns = prevCampaigns.map(p => + p.name === campaignName ? { ...p, lastModified: new Date().toISOString().split('T')[0] } : p + ); + localStorage.setItem('barclays_modcomms_campaigns_v3', JSON.stringify(updatedCampaigns)); + return updatedCampaigns; + }); + + saveCampaignProofs(finalCampaignProofs); + return finalCampaignProofs; + + } else { + // CREATE NEW PROOF + const newWorkfrontId = `#WF_${Math.floor(10000 + Math.random() * 90000)}`; + const newProofWithVersion = { + proofName, + channel, + subChannel, + proofType, + status: 'completed', + overallStatus: feedback.overallStatus, + versions: [ + { + version: 1, + timestamp: new Date().toISOString().split('T')[0], + workfrontId: `${newWorkfrontId}-V1`, + proofPreviewUrl: previewUrl, + feedback: feedback, + overallStatus: feedback.overallStatus, + } + ] + }; + + const updatedProofsList = currentCampaignProofsList.map(proof => + proof.tempId === tempId ? newProofWithVersion : proof + ); + + const finalCampaignProofs = { ...prevCampaignProofs, [campaignName]: updatedProofsList }; + + setCampaigns(prevCampaigns => { + const updatedCampaigns = prevCampaigns.map(p => + p.name === campaignName ? { ...p, proofs: p.proofs + 1, lastModified: new Date().toISOString().split('T')[0] } : p + ); + localStorage.setItem('barclays_modcomms_campaigns_v3', JSON.stringify(updatedCampaigns)); + return updatedCampaigns; + }); + + saveCampaignProofs(finalCampaignProofs); + return finalCampaignProofs; + } + }); + } catch (err) { + console.error("Failed to upload and analyze proof:", err); + setError("Failed to upload and analyze proof. Please try again."); + setCampaignProofs(prevProofs => { + const updatedProofs = { + ...prevProofs, + [campaignName]: prevProofs[campaignName].map(proof => + proof.tempId === tempId ? { ...proof, status: 'error' } : proof + ) + }; + saveCampaignProofs(updatedProofs); + return updatedProofs; + }); + } + }; + + const handleRetryAnalysis = async (campaignName: string, tempId: string) => { + const proofToRetry = campaignProofs[campaignName]?.find(proof => proof.tempId === tempId); + + if (!proofToRetry || !proofToRetry.file) { + console.error("Proof to retry not found or file is missing"); + return; + } + + const { file, proofName, channel, subChannel, proofType } = proofToRetry; + + setCampaignProofs(prevProofs => ({ + ...prevProofs, + [campaignName]: prevProofs[campaignName].map(proof => + proof.tempId === tempId ? { ...proof, status: 'analyzing', analysisProgress: { completed: 0, total: AGENT_NAMES.length + 1 } } : proof + ) + })); + + const handleAgentUpdateForRetry = (agentName: AgentName | 'Summary') => { + setCampaignProofs(prevProofs => { + const currentProofs = prevProofs[campaignName] || []; + const updatedProofs = currentProofs.map(proof => { + if (proof.tempId === tempId && proof.status === 'analyzing') { + const newCompleted = (proof.analysisProgress?.completed ?? 0) + 1; + return { ...proof, analysisProgress: { ...proof.analysisProgress, completed: newCompleted } }; + } + return proof; + }); + return { ...prevProofs, [campaignName]: updatedProofs }; + }); + }; + + try { + const feedback = await analyzeProof(file, handleAgentUpdateForRetry); + const previewUrl = await fileToDataUrl(file); + const newWorkfrontId = `#WF_${Math.floor(10000 + Math.random() * 90000)}`; + + if (feedback.overallStatus === 'Analysis Error') { + handleAddNewError({ + campaignName: campaignName, + proofName: proofName, + version: 1, // Retry always creates a V1 + errorSummary: feedback.leadAgentSummary, + }); + } + + const newProofWithVersion = { + proofName, + channel, + subChannel, + proofType, + status: 'completed', + overallStatus: feedback.overallStatus, + versions: [ + { + version: 1, + timestamp: new Date().toISOString().split('T')[0], + workfrontId: `${newWorkfrontId}-V1`, + proofPreviewUrl: previewUrl, + feedback: feedback, + overallStatus: feedback.overallStatus, + } + ] + }; + + const updatedCampaigns = campaigns.map(p => + p.name === campaignName ? { ...p, proofs: p.proofs + 1, lastModified: new Date().toISOString().split('T')[0] } : p + ); + setCampaigns(updatedCampaigns); + localStorage.setItem('barclays_modcomms_campaigns_v3', JSON.stringify(updatedCampaigns)); + + setCampaignProofs(prevProofs => { + const updatedProofs = { + ...prevProofs, + [campaignName]: prevProofs[campaignName].map(proof => + proof.tempId === tempId ? newProofWithVersion : proof + ) + }; + saveCampaignProofs(updatedProofs); + return updatedProofs; + }); + + } catch (err) { + console.error("Failed to retry proof analysis:", err); + setCampaignProofs(prevProofs => { + const updatedProofs = { + ...prevProofs, + [campaignName]: prevProofs[campaignName].map(proof => + proof.tempId === tempId ? { ...proof, status: 'error' } : proof + ) + }; + saveCampaignProofs(updatedProofs); + return updatedProofs; + }); + } + }; + + const handleCampaignStatusChange = (campaignName: string, newStatus: 'In Progress' | 'Completed') => { + const updatedCampaigns = campaigns.map(p => + p.name === campaignName ? { ...p, status: newStatus, lastModified: new Date().toISOString().split('T')[0] } : p + ); + setCampaigns(updatedCampaigns); + try { + localStorage.setItem('barclays_modcomms_campaigns_v3', JSON.stringify(updatedCampaigns)); + } catch (error) { + console.error('Error saving campaign status to localStorage', error); + } + }; + + const handleDeleteProof = (campaignName: string, proofName: string) => { + // Update campaign proofs + setCampaignProofs(prevProofs => { + const updatedProofsForCampaign = prevProofs[campaignName].filter( + proof => proof.proofName !== proofName + ); + const finalProofs = { ...prevProofs, [campaignName]: updatedProofsForCampaign }; + saveCampaignProofs(finalProofs); + return finalProofs; + }); + + // Update campaigns list (proof count) + setCampaigns(prevCampaigns => { + const updatedCampaigns = prevCampaigns.map(p => + p.name === campaignName + ? { ...p, proofs: p.proofs > 0 ? p.proofs - 1 : 0, lastModified: new Date().toISOString().split('T')[0] } + : p + ); + localStorage.setItem('barclays_modcomms_campaigns_v3', JSON.stringify(updatedCampaigns)); + return updatedCampaigns; + }); + }; + + // --- SETTINGS HANDLERS (UPDATED) --- + + const handleAddCampaignOption = (value: string) => { + setDropdownOptions(prev => { + if (!prev.campaigns.includes(value)) { + return { ...prev, campaigns: [...prev.campaigns, value].sort() }; + } + return prev; + }); + }; + + const handleRemoveCampaignOption = (value: string) => { + setDropdownOptions(prev => ({ ...prev, campaigns: prev.campaigns.filter(c => c !== value) })); + }; + + const handleAddChannel = (channel: string) => { + setDropdownOptions(prev => { + if (prev.channels[channel]) return prev; + return { + ...prev, + channels: { + ...prev.channels, + [channel]: {} + } + }; + }); + }; + + const handleRemoveChannel = (channel: string) => { + setDropdownOptions(prev => { + const newChannels = { ...prev.channels }; + delete newChannels[channel]; + return { ...prev, channels: newChannels }; + }); + }; + + const handleAddSubChannel = (channel: string, subChannel: string) => { + setDropdownOptions(prev => { + const channelData = prev.channels[channel] || {}; + if (channelData[subChannel]) return prev; + + return { + ...prev, + channels: { + ...prev.channels, + [channel]: { + ...channelData, + [subChannel]: [] + } + } + }; + }); + }; + + const handleRemoveSubChannel = (channel: string, subChannel: string) => { + setDropdownOptions(prev => { + const channelData = { ...prev.channels[channel] }; + delete channelData[subChannel]; + return { + ...prev, + channels: { + ...prev.channels, + [channel]: channelData + } + }; + }); + }; + + const handleAddProofType = (channel: string, subChannel: string, proofType: string) => { + setDropdownOptions(prev => { + const channelData = prev.channels[channel]; + if (!channelData) return prev; + const currentTypes = channelData[subChannel] || []; + if (currentTypes.includes(proofType)) return prev; + + return { + ...prev, + channels: { + ...prev.channels, + [channel]: { + ...channelData, + [subChannel]: [...currentTypes, proofType].sort() + } + } + }; + }); + }; + + const handleRemoveProofType = (channel: string, subChannel: string, proofType: string) => { + setDropdownOptions(prev => { + const channelData = prev.channels[channel]; + if (!channelData) return prev; + const currentTypes = channelData[subChannel] || []; + + return { + ...prev, + channels: { + ...prev.channels, + [channel]: { + ...channelData, + [subChannel]: currentTypes.filter(t => t !== proofType) + } + } + }; + }); + }; + + + const handleNavigate = (view: View) => { + setCurrentView(view); + setSelectedCampaign(null); // Reset campaign on any main navigation + setSelectedProof(null); + }; + + const handleSelectCampaign = (campaignName: string) => { + setSelectedCampaign(campaignName); + }; + + const handleSelectProof = (proof: any) => { + setSelectedProof(proof); + }; + + const handleBackToCampaignsList = () => { + setSelectedCampaign(null); + setSelectedProof(null); + }; + + const handleBackToCampaignDetails = () => { + setSelectedProof(null); + }; + + const handleFlagSubmit = (flagData: Omit) => { + const newFlag: FlaggedItem = { + ...flagData, + id: `flag_${Date.now()}`, + timestamp: new Date().toISOString(), + submitter: "Steve O'Donoghue", // Hardcoded for prototype + submitAgency: "OLIVER Agency", // Hardcoded for prototype + }; + + const updatedFlags = [newFlag, ...flaggedItems]; + setFlaggedItems(updatedFlags); + + try { + localStorage.setItem('barclays_modcomms_flagged_items_v3', JSON.stringify(updatedFlags)); + } catch (error) { + console.error('Error saving flagged items to localStorage', error); + setError('Could not save your feedback due to storage limitations.'); + } + }; + + const handleResolveSubmit = (resolveData: Omit) => { + const newResolution: ResolvedItem = { + ...resolveData, + id: `res_${Date.now()}`, + timestamp: new Date().toISOString(), + submitter: "Steve O'Donoghue", // Hardcoded for prototype + submitAgency: "OLIVER Agency", // Hardcoded for prototype + }; + + const updatedResolutions = [newResolution, ...resolvedItems]; + setResolvedItems(updatedResolutions); + + try { + localStorage.setItem('barclays_modcomms_resolved_items_v3', JSON.stringify(updatedResolutions)); + } catch (error) { + console.error('Error saving resolved items to localStorage', error); + setError('Could not save your resolution due to storage limitations.'); + } + }; + + + const handleNavigateToAuditedItem = (item: { campaignName: string; proofName: string; version: number }) => { + const proofToSelect = campaignProofs[item.campaignName]?.find(a => a.proofName === item.proofName); + if (proofToSelect) { + const versionExists = proofToSelect.versions.some((v: any) => v.version === item.version); + if (versionExists) { + setSelectedCampaign(item.campaignName); + // Add a temporary property to the proof object to indicate which version to show. + setSelectedProof({ ...proofToSelect, initialVersion: item.version }); + setCurrentView('Campaigns'); + } else { + setError(`Version ${item.version} not found for proof ${item.proofName}. It may have been deleted.`); + } + } else { + setError(`Proof ${item.proofName} not found in campaign ${item.campaignName}. It may have been deleted.`); + } + }; + + const handleLogin = () => { + setIsLoggedIn(true); + }; + + const handleLogout = () => { + setIsLoggedIn(false); + }; + + const renderContent = () => { + switch (currentView) { + case 'Analytics': + return ; + case 'Profile': + return ; + case 'CopyGenAI': + return ; + case 'Campaigns': + return ; + case 'WIP Reviewer': + return ; + case 'Auditing': + return ; + case 'Settings': + return ; + case 'Home': + default: + return ( + <> + handleNavigate('Campaigns')} /> + + + ); + } + }; + + if (!isLoggedIn) { + return ; + } + + // Determine background color based on view to avoid grey bar on Home view + const mainBgColor = currentView === 'Home' ? 'bg-white' : 'bg-brand-gray'; + + return ( +
+ handleNavigate(view as View)} /> +
+
+ {renderContent()} +
+
+
+ ); +}; + +export default App; diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..268cb1d --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,20 @@ +
+GHBanner +
+ +# Run and deploy your AI Studio app + +This contains everything you need to run your app locally. + +View your app in AI Studio: https://ai.studio/apps/drive/1vH-R-vj0Xkk_g2ZFdHtLxNc12sFTOl2L + +## Run Locally + +**Prerequisites:** Node.js + + +1. Install dependencies: + `npm install` +2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key +3. Run the app: + `npm run dev` diff --git a/frontend/components/Analytics.tsx b/frontend/components/Analytics.tsx new file mode 100644 index 0000000..1e2e7ca --- /dev/null +++ b/frontend/components/Analytics.tsx @@ -0,0 +1,117 @@ +import React from 'react'; +import { UploadIcon } from './icons/UploadIcon'; +import { TrendingUpIcon } from './icons/TrendingUpIcon'; +import { BugIcon } from './icons/BugIcon'; +import { ClockIcon } from './icons/ClockIcon'; +import { LightbulbIcon } from './icons/LightbulbIcon'; + +const stats = [ + { name: 'Proofs Uploaded', value: '57', icon: UploadIcon }, + { name: 'Pass Rate', value: '76%', icon: TrendingUpIcon }, + { name: 'Issues Found', value: '34', icon: BugIcon }, + { name: 'Time Saved', value: '93 hours', icon: ClockIcon }, +]; + +const agentPerformance = [ + { name: 'Legal Agent', passRate: 85, avgIssues: 1.2, trend: 'up' }, + { name: 'Brand Agent', passRate: 68, avgIssues: 2.5, trend: 'down' }, + { name: 'Tone Agent', passRate: 92, avgIssues: 0.8, trend: 'up' }, + { name: 'Channel Agent', passRate: 71, avgIssues: 1.9, trend: 'stable' }, +]; + +const UpArrow = () => ; +const DownArrow = () => ; +const StableLine = () => ; + +const TrendIndicator: React.FC<{ trend: 'up' | 'down' | 'stable' }> = ({ trend }) => { + if (trend === 'up') { + return
Improving
; + } + if (trend === 'down') { + return
Declining
; + } + return
Stable
; +}; + +export const Analytics: React.FC = () => { + return ( +
+
+

Performance Analytics

+

Overall usage and performance statistics for the tool.

+
+ + {/* Stats Cards */} +
+
+ {stats.map((stat) => { + const Icon = stat.icon; + return ( +
+
+ +
+
+

{stat.name}

+

{stat.value}

+
+
+ ); + })} +
+
+ + {/* AI Performance Summary */} +
+

AI Performance Summary

+
+
+ +
+
+

Key Insight (Last 7 Days):

+

+ A sharp decline in Best Practice adherence has been noted, primarily driven by proofs from the Barclays Q4 Social campaign. The Brand Guardian agent also shows a declining performance trend, suggesting a potential need for updated brand guideline training or proof review. +

+
+
+
+ + {/* Agent Performance Table */} +
+

Agent Performance (Last 7 Days)

+
+
+ + + + + + + + + + + {agentPerformance.map((agent) => ( + + + + + + + ))} + +
Agent NamePass RateAvg. Issues per ProofPerformance Trend
{agent.name} +
+ = 80 ? 'bg-green-500' : agent.passRate < 70 ? 'bg-red-500' : 'bg-yellow-500'}`}> + {agent.passRate}% +
+
{agent.avgIssues} + +
+
+
+
+
+ ); +}; \ No newline at end of file diff --git a/frontend/components/AssetPreview.tsx b/frontend/components/AssetPreview.tsx new file mode 100644 index 0000000..6bd686f --- /dev/null +++ b/frontend/components/AssetPreview.tsx @@ -0,0 +1,84 @@ +import React from 'react'; +import { DocumentIcon } from './icons/DocumentIcon'; + +interface AssetPreviewProps { + file?: File | null; + previewUrl: string | null; + fileName?: string; +} + +export const AssetPreview: React.FC = ({ file, previewUrl, fileName }) => { + if (!previewUrl) { + return null; + } + + const getMimeType = (): string => { + if (file?.type) return file.type; + if (previewUrl.startsWith('data:')) { + const match = previewUrl.match(/data:([a-zA-Z0-9]+\/[a-zA-Z0-9-.+]+);/); + if (match && match[1]) { + return match[1]; + } + } + return 'application/octet-stream'; // Fallback + }; + + const fileType = getMimeType(); + const displayName = fileName || file?.name || 'Asset Preview'; + + + const renderPreview = () => { + if (fileType.startsWith('image/')) { + return ( + {displayName} + ); + } + + if (fileType === 'video/mp4') { + return ( + + ); + } + + if (fileType === 'application/pdf') { + return ( +