diff --git a/backend/src/__init__.py b/backend/src/__init__.py index 86a451a..0eeb055 100644 --- a/backend/src/__init__.py +++ b/backend/src/__init__.py @@ -13,5 +13,8 @@ def create_app(): app.register_blueprint(gemini_bp, url_prefix='/api/gemini') from .routes.reports import reports_bp app.register_blueprint(reports_bp, url_prefix='/api/reports') + from .routes.incidents import incidents_bp + app.register_blueprint(incidents_bp, url_prefix='/api/incidents') return app + diff --git a/backend/src/gemini/__init__.py b/backend/src/gemini/__init__.py index 1ebcc42..6082a81 100644 --- a/backend/src/gemini/__init__.py +++ b/backend/src/gemini/__init__.py @@ -23,7 +23,33 @@ Based on the context provided, give a final verdict: def ask(prompt): client = genai.Client(api_key=os.environ.get("GOOGLE_API_KEY")) - return client.models.generate_content(model="gemini-2.0-flash", contents=prompt).text + return client.models.generate_content(model="gemini-3-flash-preview", contents=prompt).text + +def ask_gemini_with_rag(prompt, category=None): + """Ask Gemini with RAG context from the vector database.""" + # Get embedding for the prompt + query_embedding = get_embedding(prompt) + + # Search for relevant documents + results = search_documents(query_embedding, num_results=5) + + # Build context from results + context = "" + for res in results: + context += f"--- Document ---\n{res['text']}\n\n" + + # Create full prompt with context + full_prompt = f"""You are a helpful sustainability assistant. Use the following context to answer the user's question. +If the context doesn't contain relevant information, you can use your general knowledge but mention that. + +CONTEXT: +{context} + +USER QUESTION: {prompt} + +Please provide a helpful and concise response.""" + + return ask(full_prompt) def analyze(query, query_embedding, num_results=5, num_alternatives=3): try: diff --git a/backend/src/gemini/__main__.py b/backend/src/gemini/__main__.py deleted file mode 100644 index 14061b5..0000000 --- a/backend/src/gemini/__main__.py +++ /dev/null @@ -1,4 +0,0 @@ -from . import scan_and_analyze - -if __name__ == "__main__": - scan_and_analyze() diff --git a/backend/src/gemini/client.py b/backend/src/gemini/client.py index fef0733..29193a4 100644 --- a/backend/src/gemini/client.py +++ b/backend/src/gemini/client.py @@ -3,15 +3,9 @@ import os def generate_content(prompt, model_name="gemini-2.0-flash-exp"): api_key = os.environ.get("GOOGLE_API_KEY") - if not api_key: - return "Error: GOOGLE_API_KEY not found." - - try: - client = genai.Client(api_key=api_key) - response = client.models.generate_content( - model=model_name, - contents=prompt, - ) - return response.text - except Exception as e: - return f"Error interacting with Gemini API: {str(e)}" + client = genai.Client(api_key=api_key) + response = client.models.generate_content( + model=model_name, + contents=prompt, + ) + return response.text diff --git a/backend/src/gemini/reporting.py b/backend/src/gemini/reporting.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/src/routes/incidents.py b/backend/src/routes/incidents.py new file mode 100644 index 0000000..bbb1f48 --- /dev/null +++ b/backend/src/routes/incidents.py @@ -0,0 +1,405 @@ +""" +Incident Report API - Handles greenwashing report submissions +Uses structured outputs with Pydantic for reliable JSON responses +""" +import base64 +import os +from datetime import datetime +from flask import Blueprint, request, jsonify +from google import genai +from pydantic import BaseModel, Field +from typing import List, Optional, Literal + +from src.ollama.detector import OllamaLogoDetector +from src.chroma.vector_store import search_documents, insert_documents +from src.rag.embeddings import get_embedding +from src.mongo.connection import get_mongo_client + +incidents_bp = Blueprint('incidents', __name__) + +# Initialize detector lazily +_detector = None + +def get_detector(): + global _detector + if _detector is None: + _detector = OllamaLogoDetector() + return _detector + + +# ============= Pydantic Models for Structured Outputs ============= + +class GreenwashingAnalysis(BaseModel): + """Structured output for greenwashing analysis""" + is_greenwashing: bool = Field(description="Whether this is a case of greenwashing") + confidence: Literal["high", "medium", "low"] = Field(description="Confidence level of the analysis") + verdict: str = Field(description="Brief one-sentence verdict") + reasoning: str = Field(description="Detailed explanation of why this is or isn't greenwashing") + severity: Literal["high", "medium", "low"] = Field(description="Severity of the greenwashing if detected") + recommendations: str = Field(description="What consumers should know about this case") + key_claims: List[str] = Field(description="List of specific environmental claims made by the company") + red_flags: List[str] = Field(description="List of red flags or concerning practices identified") + + +class LogoDetection(BaseModel): + """Structured output for logo detection from Ollama""" + brand: str = Field(description="The company or brand name detected") + confidence: Literal["high", "medium", "low"] = Field(description="Confidence level of detection") + location: str = Field(description="Location in image (e.g., center, top-left)") + category: str = Field(description="Product category if identifiable") + + +class ImageAnalysis(BaseModel): + """Structured output for full image analysis""" + logos_detected: List[LogoDetection] = Field(description="List of logos/brands detected in the image") + total_count: int = Field(description="Total number of logos detected") + description: str = Field(description="Brief description of what's in the image") + environmental_claims: List[str] = Field(description="Any environmental or eco-friendly claims visible in the image") + packaging_description: str = Field(description="Description of the product packaging and design") + + +# ============= Analysis Functions ============= + +GREENWASHING_ANALYSIS_PROMPT = """You are an expert at detecting greenwashing - misleading environmental claims by companies. + +Analyze the following user-submitted report about a potential greenwashing incident: + +PRODUCT/COMPANY: {product_name} +USER COMPLAINT: {user_description} +DETECTED BRAND FROM IMAGE: {detected_brand} +IMAGE DESCRIPTION: {image_description} + +RELEVANT CONTEXT FROM OUR DATABASE: +{context} + +Based on this information, determine if this is a valid case of greenwashing. Consider: +1. Does the company have a history of misleading environmental claims? +2. Are their eco-friendly claims vague or unsubstantiated? +3. Is there a disconnect between their marketing and actual practices? +4. Are they using green imagery or terms without substance? + +Provide your analysis in the structured format requested.""" + + +def analyze_with_gemini(product_name: str, user_description: str, detected_brand: str, + image_description: str, context: str) -> GreenwashingAnalysis: + """Send analysis request to Gemini with structured output""" + api_key = os.environ.get("GOOGLE_API_KEY") + if not api_key: + raise ValueError("GOOGLE_API_KEY not set") + + prompt = GREENWASHING_ANALYSIS_PROMPT.format( + product_name=product_name, + user_description=user_description, + detected_brand=detected_brand, + image_description=image_description, + context=context + ) + + client = genai.Client(api_key=api_key) + + # Use structured output with Pydantic schema + response = client.models.generate_content( + model="gemini-3-flash-preview", + contents=prompt, + config={ + "response_mime_type": "application/json", + "response_json_schema": GreenwashingAnalysis.model_json_schema(), + } + ) + + # Validate and parse the response + analysis = GreenwashingAnalysis.model_validate_json(response.text) + return analysis + + +def analyze_image_with_ollama(image_bytes: bytes) -> ImageAnalysis: + """Analyze image using Ollama with structured output""" + try: + import ollama + + client = ollama.Client(host="https://ollama.sirblob.co") + + image_base64 = base64.b64encode(image_bytes).decode('utf-8') + + prompt = """Analyze this image for a greenwashing detection system. + +Identify: +1. All visible logos, brand names, and company names +2. Any environmental or eco-friendly claims (text, symbols, certifications) +3. Describe the packaging design and any "green" visual elements + +Respond with structured JSON matching the schema provided.""" + + response = client.chat( + model="ministral-3:latest", + messages=[{ + 'role': 'user', + 'content': prompt, + 'images': [image_base64], + }], + format=ImageAnalysis.model_json_schema(), + options={'temperature': 0.1} + ) + + # Validate and parse + analysis = ImageAnalysis.model_validate_json(response['message']['content']) + return analysis + + except Exception as e: + print(f"Ollama structured analysis failed: {e}") + # Fall back to basic detection + detector = get_detector() + result = detector.detect_from_bytes(image_bytes) + + # Convert to structured format + logos = [] + for logo in result.get('logos_detected', []): + logos.append(LogoDetection( + brand=logo.get('brand', 'Unknown'), + confidence=logo.get('confidence', 'low'), + location=logo.get('location', 'unknown'), + category=logo.get('category', 'unknown') + )) + + return ImageAnalysis( + logos_detected=logos, + total_count=result.get('total_count', 0), + description=result.get('description', 'No description available'), + environmental_claims=[], + packaging_description="" + ) + + +def save_to_mongodb(incident_data: dict) -> str: + """Save incident to MongoDB and return the ID""" + client = get_mongo_client() + db = client["ethix"] + collection = db["incidents"] + + result = collection.insert_one(incident_data) + return str(result.inserted_id) + + +def save_to_chromadb(incident_data: dict, incident_id: str): + """Save incident as context for the chatbot""" + analysis = incident_data['analysis'] + + # Create a rich text representation of the incident + red_flags = "\n".join(f"- {flag}" for flag in analysis.get('red_flags', [])) + key_claims = "\n".join(f"- {claim}" for claim in analysis.get('key_claims', [])) + + text = f"""GREENWASHING INCIDENT REPORT #{incident_id} +Date: {incident_data['created_at']} +Company/Product: {incident_data['product_name']} ({incident_data.get('detected_brand', 'Unknown brand')}) + +USER REPORT: {incident_data['user_description']} + +ANALYSIS VERDICT: {analysis['verdict']} +Confidence: {analysis['confidence']} +Severity: {analysis['severity']} + +DETAILED REASONING: +{analysis['reasoning']} + +KEY ENVIRONMENTAL CLAIMS MADE: +{key_claims} + +RED FLAGS IDENTIFIED: +{red_flags} + +CONSUMER RECOMMENDATIONS: +{analysis['recommendations']} +""" + + # Get embedding for the incident + embedding = get_embedding(text) + + # Store in ChromaDB with metadata + metadata = { + "type": "incident_report", + "source": f"incident_{incident_id}", + "product_name": incident_data['product_name'], + "brand": incident_data.get('detected_brand', 'Unknown'), + "severity": analysis['severity'], + "confidence": analysis['confidence'], + "is_greenwashing": True, + "created_at": incident_data['created_at'] + } + + insert_documents( + texts=[text], + embeddings=[embedding], + metadata_list=[metadata] + ) + + +# ============= API Endpoints ============= + +@incidents_bp.route('/submit', methods=['POST']) +def submit_incident(): + """ + Submit a greenwashing incident report + + Expects JSON with: + - product_name: Name of the product/company + - description: User's description of the misleading claim + - image: Base64 encoded image (optional, but recommended) + """ + data = request.json + + if not data: + return jsonify({"error": "No data provided"}), 400 + + product_name = data.get('product_name', '').strip() + user_description = data.get('description', '').strip() + image_base64 = data.get('image') # Base64 encoded image + + if not product_name: + return jsonify({"error": "Product name is required"}), 400 + + if not user_description: + return jsonify({"error": "Description is required"}), 400 + + try: + # Step 1: Analyze image with Ollama (structured output) + detected_brand = "Unknown" + image_description = "No image provided" + environmental_claims = [] + + if image_base64: + try: + # Remove data URL prefix if present + if ',' in image_base64: + image_base64 = image_base64.split(',')[1] + + image_bytes = base64.b64decode(image_base64) + + # Use structured image analysis + image_analysis = analyze_image_with_ollama(image_bytes) + + if image_analysis.logos_detected: + detected_brand = image_analysis.logos_detected[0].brand + + image_description = image_analysis.description + environmental_claims = image_analysis.environmental_claims + + except Exception as e: + print(f"Image analysis error: {e}") + # Continue without image analysis + + # Step 2: Get relevant context from vector database + search_query = f"{product_name} {detected_brand} environmental claims sustainability greenwashing" + query_embedding = get_embedding(search_query) + search_results = search_documents(query_embedding, num_results=5) + + context = "" + for res in search_results: + context += f"--- Document ---\n{res['text'][:500]}\n\n" + + if not context: + context = "No prior information found about this company in our database." + + # Add environmental claims from image to context + if environmental_claims: + context += "\n--- Claims visible in submitted image ---\n" + context += "\n".join(f"- {claim}" for claim in environmental_claims) + + # Step 3: Analyze with Gemini (structured output) + analysis = analyze_with_gemini( + product_name=product_name, + user_description=user_description, + detected_brand=detected_brand, + image_description=image_description, + context=context + ) + + # Convert Pydantic model to dict + analysis_dict = analysis.model_dump() + + # Step 4: Prepare incident data + incident_data = { + "product_name": product_name, + "user_description": user_description, + "detected_brand": detected_brand, + "image_description": image_description, + "environmental_claims": environmental_claims, + "analysis": analysis_dict, + "is_greenwashing": analysis.is_greenwashing, + "created_at": datetime.utcnow().isoformat(), + "status": "confirmed" if analysis.is_greenwashing else "dismissed" + } + + incident_id = None + + # Step 5: If greenwashing detected, save to databases + if analysis.is_greenwashing: + # Save to MongoDB + incident_id = save_to_mongodb(incident_data) + + # Save to ChromaDB for chatbot context + save_to_chromadb(incident_data, incident_id) + + return jsonify({ + "status": "success", + "is_greenwashing": analysis.is_greenwashing, + "incident_id": incident_id, + "analysis": analysis_dict, + "detected_brand": detected_brand, + "environmental_claims": environmental_claims + }) + + except Exception as e: + import traceback + traceback.print_exc() + return jsonify({ + "status": "error", + "message": str(e) + }), 500 + + +@incidents_bp.route('/list', methods=['GET']) +def list_incidents(): + """Get all confirmed greenwashing incidents""" + try: + client = get_mongo_client() + db = client["ethix"] + collection = db["incidents"] + + # Get recent incidents with full analysis details + incidents = list(collection.find( + {"is_greenwashing": True}, + {"_id": 1, "product_name": 1, "detected_brand": 1, + "user_description": 1, "analysis": 1, "created_at": 1} + ).sort("created_at", -1).limit(50)) + + # Convert ObjectId to string + for inc in incidents: + inc["_id"] = str(inc["_id"]) + + return jsonify(incidents) + + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +@incidents_bp.route('/', methods=['GET']) +def get_incident(incident_id): + """Get a specific incident by ID""" + try: + from bson import ObjectId + + client = get_mongo_client() + db = client["ethix"] + collection = db["incidents"] + + incident = collection.find_one({"_id": ObjectId(incident_id)}) + + if not incident: + return jsonify({"error": "Incident not found"}), 404 + + incident["_id"] = str(incident["_id"]) + return jsonify(incident) + + except Exception as e: + return jsonify({"error": str(e)}), 500 diff --git a/backend/src/routes/reports.py b/backend/src/routes/reports.py index fbd7ff6..4b80f66 100644 --- a/backend/src/routes/reports.py +++ b/backend/src/routes/reports.py @@ -17,6 +17,11 @@ def get_reports(): filename = meta.get('source') or meta.get('filename') if not filename: continue + + # Skip incident reports - these are user-submitted greenwashing reports + if meta.get('type') == 'incident_report' or filename.startswith('incident_'): + continue + if filename not in unique_reports: # Attempt to extract info from filename diff --git a/frontend/src/lib/components/catalogue/CatalogueHeader.svelte b/frontend/src/lib/components/catalogue/CatalogueHeader.svelte new file mode 100644 index 0000000..44477f3 --- /dev/null +++ b/frontend/src/lib/components/catalogue/CatalogueHeader.svelte @@ -0,0 +1,101 @@ + + +
+ +
+

+ Sustainability Database +

+

+ {#if viewMode === "company"} + Search within verified company reports and impact assessments + {:else} + Browse user-submitted greenwashing reports + {/if} +

+
+ + +
+ + + Company + + + + + User Reports + +
+ + {#if viewMode === "company"} +
+
+ +
+ +
+ +
+ {#each categories as category} + + {/each} +
+ {/if} +
diff --git a/frontend/src/lib/components/catalogue/CompanyModal.svelte b/frontend/src/lib/components/catalogue/CompanyModal.svelte new file mode 100644 index 0000000..a5fd451 --- /dev/null +++ b/frontend/src/lib/components/catalogue/CompanyModal.svelte @@ -0,0 +1,86 @@ + + +
e.key === "Escape" && onclose()} + transition:fade={{ duration: 200 }} + role="button" + tabindex="0" + aria-label="Close modal" +> +
e.stopPropagation()} + onkeydown={(e) => e.stopPropagation()} + transition:scale={{ duration: 300, start: 0.95 }} + role="document" + tabindex="0" + > +
+ + +
+ +
+ {#if report.filename + .toLowerCase() + .endsWith(".pdf") || report.filename + .toLowerCase() + .endsWith(".txt")} + + {:else} +
+ +

Preview not available for this file type.

+ + Download File + +
+ {/if} +
+
+
diff --git a/frontend/src/lib/components/catalogue/IncidentCard.svelte b/frontend/src/lib/components/catalogue/IncidentCard.svelte new file mode 100644 index 0000000..21412ac --- /dev/null +++ b/frontend/src/lib/components/catalogue/IncidentCard.svelte @@ -0,0 +1,86 @@ + + + diff --git a/frontend/src/lib/components/catalogue/IncidentModal.svelte b/frontend/src/lib/components/catalogue/IncidentModal.svelte new file mode 100644 index 0000000..d8b2423 --- /dev/null +++ b/frontend/src/lib/components/catalogue/IncidentModal.svelte @@ -0,0 +1,233 @@ + + +
e.key === "Escape" && onclose()} + transition:fade={{ duration: 200 }} + role="button" + tabindex="0" + aria-label="Close modal" +> +
e.stopPropagation()} + onkeydown={(e) => e.stopPropagation()} + transition:scale={{ duration: 300, start: 0.95 }} + role="document" + tabindex="0" + > +
+
+
+ +

+ {incident.product_name} +

+
+ {#if incident.detected_brand && incident.detected_brand !== "Unknown"} + Brand: {incident.detected_brand} + {/if} +
+ +
+ +
+ +
+ + + {incident.analysis?.severity || "UNKNOWN"} SEVERITY + + + + {incident.analysis?.confidence || "UNKNOWN"} CONFIDENCE + + + + {new Date(incident.created_at).toLocaleDateString()} + +
+ + +
+

+ + Verdict +

+

+ {incident.analysis?.verdict || "Greenwashing detected"} +

+
+ + +
+

+ + Detailed Analysis +

+

+ {incident.analysis?.reasoning || + "No detailed analysis available."} +

+
+ + + {#if incident.analysis?.red_flags && incident.analysis.red_flags.length > 0} +
+

+ + Red Flags Identified +

+
    + {#each incident.analysis.red_flags as flag} +
  • + + {flag} +
  • + {/each} +
+
+ {/if} + + + {#if incident.analysis?.key_claims && incident.analysis.key_claims.length > 0} +
+

+ + Environmental Claims Made +

+
    + {#each incident.analysis.key_claims as claim} +
  • + + {claim} +
  • + {/each} +
+
+ {/if} + + + {#if incident.analysis?.recommendations} +
+

+ + Consumer Recommendations +

+

+ {incident.analysis.recommendations} +

+
+ {/if} + + + {#if incident.user_description} +
+

+ + Original User Report +

+

+ "{incident.user_description}" +

+
+ {/if} +
+
+
+ + diff --git a/frontend/src/lib/components/catalogue/Pagination.svelte b/frontend/src/lib/components/catalogue/Pagination.svelte new file mode 100644 index 0000000..7516694 --- /dev/null +++ b/frontend/src/lib/components/catalogue/Pagination.svelte @@ -0,0 +1,53 @@ + + +{#if totalPages > 1} +
+ + +
+ {#if totalPages <= 7} + {#each Array(totalPages) as _, i} + + {/each} + {:else} + + Page {currentPage} of {totalPages} + + {/if} +
+ + +
+{/if} diff --git a/frontend/src/lib/components/catalogue/ReportCard.svelte b/frontend/src/lib/components/catalogue/ReportCard.svelte new file mode 100644 index 0000000..52313d4 --- /dev/null +++ b/frontend/src/lib/components/catalogue/ReportCard.svelte @@ -0,0 +1,120 @@ + + + diff --git a/frontend/src/routes/catalogue/+page.svelte b/frontend/src/routes/catalogue/+page.svelte index 612c4e2..0c8aa36 100644 --- a/frontend/src/routes/catalogue/+page.svelte +++ b/frontend/src/routes/catalogue/+page.svelte @@ -3,6 +3,17 @@ import Icon from "@iconify/svelte"; import { onMount } from "svelte"; + import CatalogueHeader from "$lib/components/catalogue/CatalogueHeader.svelte"; + import ReportCard from "$lib/components/catalogue/ReportCard.svelte"; + import IncidentCard from "$lib/components/catalogue/IncidentCard.svelte"; + import CompanyModal from "$lib/components/catalogue/CompanyModal.svelte"; + import IncidentModal from "$lib/components/catalogue/IncidentModal.svelte"; + import Pagination from "$lib/components/catalogue/Pagination.svelte"; + + // View mode toggle + type ViewMode = "company" | "user"; + let viewMode = $state("company"); + // Data Types interface Report { company_name: string; @@ -14,11 +25,30 @@ snippet?: string; } + interface Incident { + _id: string; + product_name: string; + detected_brand: string; + user_description?: string; + created_at: string; + analysis: { + verdict: string; + confidence: string; + severity: string; + reasoning: string; + is_greenwashing: boolean; + key_claims?: string[]; + red_flags?: string[]; + recommendations?: string; + }; + } + let reports = $state([]); + let incidents = $state([]); let searchQuery = $state(""); let isLoading = $state(false); - // Predefined categories for filtering (could be dynamic, but static is fine for now) + // Predefined categories const categories = [ "All", "Tech", @@ -31,15 +61,13 @@ ]; let selectedCategory = $state("All"); - // Initial fetch + // Fetching logic async function fetchReports() { isLoading = true; try { const res = await fetch("http://localhost:5000/api/reports/"); const data = await res.json(); - if (Array.isArray(data)) { - reports = data; - } + if (Array.isArray(data)) reports = data; } catch (e) { console.error("Failed to fetch reports", e); } finally { @@ -47,17 +75,30 @@ } } + async function fetchIncidents() { + isLoading = true; + try { + const res = await fetch("http://localhost:5000/api/incidents/list"); + const data = await res.json(); + if (Array.isArray(data)) incidents = data; + } catch (e) { + console.error("Failed to fetch incidents", e); + } finally { + isLoading = false; + } + } + onMount(() => { fetchReports(); + fetchIncidents(); }); - // Handle search + // Search async function handleSearch() { if (!searchQuery.trim()) { - fetchReports(); // Reset if empty + fetchReports(); return; } - isLoading = true; try { const res = await fetch( @@ -69,9 +110,7 @@ }, ); const data = await res.json(); - if (Array.isArray(data)) { - reports = data; // These will have 'snippet' - } + if (Array.isArray(data)) reports = data; } catch (e) { console.error("Search failed", e); } finally { @@ -79,44 +118,34 @@ } } - // Filter computed property - // If the user searches (backend search), 'reports' is already filtered by the backend. - // But if they just select a category, we filter client-side on the *initial* list. - // To support both properly, complex logic is needed. - // Simple approach: - // 1. Search Bar -> Backend Search (ignores category dropdown or resets it) - // 2. Category Dropdown -> Client-side filter of *currently loaded* reports OR fetch all and filter. - // Let's make "Search" dominant. If query is empty, allow category filtering. + let debounceTimer: ReturnType; + function onSearchInput() { + clearTimeout(debounceTimer); + debounceTimer = setTimeout(handleSearch, 600); + } - // Pagination + // Pagination & Filtering let currentPage = $state(1); const itemsPerPage = 10; - // Filtered list (all items that match criteria) let filteredReports = $derived.by(() => { - if (searchQuery.length > 0) { - return reports; - } else { - if (selectedCategory === "All") return reports; - return reports.filter((r) => { - const sector = r.sector?.toLowerCase() || "other"; - return sector.includes(selectedCategory.toLowerCase()); - }); - } + if (searchQuery.length > 0) return reports; + if (selectedCategory === "All") return reports; + return reports.filter((r) => + (r.sector?.toLowerCase() || "other").includes( + selectedCategory.toLowerCase(), + ), + ); }); - // Paginated slice let paginatedReports = $derived.by(() => { const start = (currentPage - 1) * itemsPerPage; - const end = start + itemsPerPage; - return filteredReports.slice(start, end); + return filteredReports.slice(start, start + itemsPerPage); }); let totalPages = $derived(Math.ceil(filteredReports.length / itemsPerPage)); - // Reset page when filter changes $effect(() => { - // subtle dependency tracking const _ = searchQuery; const __ = selectedCategory; currentPage = 1; @@ -129,54 +158,9 @@ } } - // Debounce search - let debounceTimer: ReturnType; - function onSearchInput() { - clearTimeout(debounceTimer); - debounceTimer = setTimeout(() => { - handleSearch(); - }, 600); - } - - function getScoreColor(score: any) { - const s = Number(score); - if (isNaN(s)) return "#9ca3af"; // grey - if (s < 30) return "#ef4444"; // red - if (s < 70) return "#f59b23"; // orange - return "#22c55e"; // green - } - - function getFileDetails(filename: string) { - const ext = filename.split(".").pop()?.toUpperCase() || "FILE"; - let icon = "ri:file-line"; - if (ext === "PDF") icon = "ri:file-pdf-line"; - else if (["XLS", "XLSX", "CSV"].includes(ext)) - icon = "ri:file-excel-line"; - else if (["TXT", "MD"].includes(ext)) icon = "ri:file-text-line"; - - return { type: ext, icon }; - } - - // Modal State + // Modals let selectedReport = $state(null); - let isModalOpen = $state(false); - - function openReport(report: Report) { - selectedReport = report; - isModalOpen = true; - } - - function closeModal() { - isModalOpen = false; - selectedReport = null; - } - - // Close on Escape key - function handleKeydown(e: KeyboardEvent) { - if (e.key === "Escape" && isModalOpen) { - closeModal(); - } - } + let selectedIncident = $state(null); @@ -187,255 +171,91 @@ /> - - -
- {#if isModalOpen && selectedReport} - +
+ + {#if selectedReport} + (selectedReport = null)} + /> {/if} -
+ {#if selectedIncident} + (selectedIncident = null)} + /> + {/if} + + - {#snippet paginationControls()} - {#if totalPages > 1} - - {/if} - {/snippet} - -
-
- -
-

Sustainability Database

-

- Search within verified company reports and impact - assessments -

-
- -
-
- -
- -
- -
- {#each categories as category} - - {/each} -
- - - {@render paginationControls()} -
+
+ {#if isLoading} -
- -

Syncing with database...

+
+ +

+ Syncing with database... +

- {:else if filteredReports.length === 0} -
-

No reports found matching your criteria.

+ {:else if viewMode === "company"} + {#if filteredReports.length === 0} +
+

+ No reports found matching your criteria. +

+
+ {:else} +
+ {#each paginatedReports as report} + (selectedReport = r)} + {searchQuery} + /> + {/each} +
+ + {/if} + {:else if incidents.length === 0} +
+ +

No user reports yet.

+

+ Be the first to report greenwashing! +

{:else} -
- {#each paginatedReports as report} - {@const fileDetails = getFileDetails(report.filename)} - +
+ {#each incidents as incident} + (selectedIncident = incident)} + /> {/each}
{/if} @@ -443,478 +263,8 @@
diff --git a/frontend/src/routes/report/+page.svelte b/frontend/src/routes/report/+page.svelte index 5c85108..35a5bbd 100644 --- a/frontend/src/routes/report/+page.svelte +++ b/frontend/src/routes/report/+page.svelte @@ -5,6 +5,9 @@ let description = $state(""); let image = $state(null); let submitted = $state(false); + let isLoading = $state(false); + let analysisResult = $state(null); + let error = $state(null); $effect(() => { const initialName = new URLSearchParams(window.location.search).get( @@ -37,12 +40,75 @@ input.click(); } - function handleSubmit() { - if (!isValid) return; - submitted = true; - setTimeout(() => { - window.history.back(); - }, 2000); + const progressSteps = [ + { id: 1, label: "Scanning image...", icon: "ri:camera-lens-line" }, + { + id: 2, + label: "Detecting brand logos...", + icon: "ri:search-eye-line", + }, + { id: 3, label: "Searching database...", icon: "ri:database-2-line" }, + { id: 4, label: "AI analysis in progress...", icon: "ri:robot-2-line" }, + { + id: 5, + label: "Generating verdict...", + icon: "ri:file-shield-2-line", + }, + ]; + + let currentStep = $state(0); + + async function handleSubmit() { + if (!isValid || isLoading) return; + + isLoading = true; + error = null; + currentStep = 1; + + // Simulate progress steps + const stepInterval = setInterval(() => { + if (currentStep < progressSteps.length) { + currentStep++; + } + }, 1500); + + try { + const response = await fetch( + "http://localhost:5000/api/incidents/submit", + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + product_name: productName, + description: description, + image: image, // Base64 encoded + }), + }, + ); + + clearInterval(stepInterval); + currentStep = progressSteps.length; // Complete all steps + + const data = await response.json(); + + if (data.status === "success") { + // Brief pause to show completion + await new Promise((r) => setTimeout(r, 500)); + analysisResult = data; + submitted = true; + } else { + error = data.message || "Failed to submit report"; + } + } catch (e) { + clearInterval(stepInterval); + error = "Failed to connect to server. Please try again."; + console.error("Submit error:", e); + } finally { + isLoading = false; + currentStep = 0; + } } @@ -52,19 +118,79 @@
- {#if submitted} + {#if submitted && analysisResult}
-
- + {#if analysisResult.is_greenwashing} +
+ +
+

+ Greenwashing Detected! +

+ {:else} +
+ +
+

Report Analyzed

+ {/if} + +
+
+ Verdict: + {analysisResult.analysis?.verdict || "N/A"} +
+
+ Confidence: + + {analysisResult.analysis?.confidence || "unknown"} + +
+ {#if analysisResult.is_greenwashing} +
+ Severity: + + {analysisResult.analysis?.severity || "unknown"} + +
+ {/if} +
+ Analysis: +

+ {analysisResult.analysis?.reasoning || + "No details available"} +

+
+ {#if analysisResult.detected_brand && analysisResult.detected_brand !== "Unknown"} +
+ Detected Brand: + {analysisResult.detected_brand} +
+ {/if}
-

Report Submitted!

-

- Thank you for keeping companies honest. -

+ +
{:else}
@@ -143,17 +269,80 @@
- + {#if error} +
+ + {error} +
+ {/if} + + {#if isLoading} +
+
+ + Analyzing Report +
+
+ {#each progressSteps as step} +
= step.id} + class:current={currentStep === step.id} + > +
+ {#if currentStep > step.id} + + {:else if currentStep === step.id} + + {:else} + + {/if} +
+ {step.label} +
+ {/each} +
+
+
+
+
+ {:else} + + {/if}
{/if} @@ -175,22 +364,22 @@ .content-container { position: relative; z-index: 10; - padding: 100px 24px 120px; - max-width: 600px; + padding: 80px 20px 40px; + max-width: 500px; margin: 0 auto; } .header-section { text-align: center; - margin-bottom: 32px; + margin-bottom: 20px; } .page-title { color: white; - font-size: 36px; + font-size: 28px; font-weight: 900; margin: 0; - letter-spacing: -1px; + letter-spacing: -0.5px; } .subtitle { @@ -212,19 +401,19 @@ } .form-card { - padding: 36px; + padding: 24px; } .form-content { display: flex; flex-direction: column; - gap: 24px; + gap: 16px; } .form-group { display: flex; flex-direction: column; - gap: 10px; + gap: 6px; } .label { @@ -412,6 +601,239 @@ margin: 0; } + .warning-text { + color: #f59e0b !important; + } + + .icon-circle.warning { + background: rgba(245, 158, 11, 0.15); + } + + .analysis-result { + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 20px; + text-align: left; + width: 100%; + } + + .result-item { + display: flex; + align-items: flex-start; + gap: 8px; + } + + .result-item.full-width { + flex-direction: column; + } + + .result-label { + color: rgba(255, 255, 255, 0.5); + font-size: 13px; + font-weight: 600; + min-width: 80px; + } + + .result-value { + color: white; + font-size: 14px; + } + + .result-text { + color: rgba(255, 255, 255, 0.8); + font-size: 14px; + line-height: 1.5; + margin: 4px 0 0 0; + } + + .badge { + padding: 2px 10px; + border-radius: 12px; + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + } + + .badge.high { + background: #ef4444; + color: white; + } + .badge.medium { + background: #f59e0b; + color: #000; + } + .badge.low { + background: #22c55e; + color: #000; + } + + .severity-high { + background: #ef4444 !important; + } + .severity-medium { + background: #f59e0b !important; + } + .severity-low { + background: #22c55e !important; + } + + .back-btn { + margin-top: 24px; + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + color: white; + padding: 12px 24px; + border-radius: 50px; + font-weight: 600; + cursor: pointer; + display: flex; + align-items: center; + gap: 8px; + transition: all 0.2s; + } + + .back-btn:hover { + background: rgba(255, 255, 255, 0.2); + } + + .error-message { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 16px; + background: rgba(239, 68, 68, 0.15); + border: 1px solid rgba(239, 68, 68, 0.3); + border-radius: 12px; + color: #fca5a5; + font-size: 14px; + } + + @keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } + } + + :global(.spin) { + animation: spin 1s linear infinite; + } + + :global(.spin-slow) { + animation: spin 2s linear infinite; + } + + @keyframes pulse { + 0%, + 100% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.7; + transform: scale(1.1); + } + } + + :global(.pulse) { + animation: pulse 1.5s ease-in-out infinite; + } + + .progress-container { + background: rgba(34, 197, 94, 0.08); + border: 1px solid rgba(34, 197, 94, 0.2); + border-radius: 16px; + padding: 20px; + } + + .progress-header { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + color: #4ade80; + font-weight: 700; + font-size: 16px; + margin-bottom: 20px; + } + + .progress-steps { + display: flex; + flex-direction: column; + gap: 12px; + margin-bottom: 20px; + } + + .progress-step { + display: flex; + align-items: center; + gap: 12px; + opacity: 0.4; + transition: all 0.3s ease; + } + + .progress-step.active { + opacity: 1; + } + + .progress-step.current { + opacity: 1; + } + + .progress-step.current .step-label { + color: #4ade80; + } + + .step-icon { + width: 28px; + height: 28px; + background: rgba(255, 255, 255, 0.1); + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + color: rgba(255, 255, 255, 0.6); + transition: all 0.3s ease; + } + + .progress-step.active .step-icon { + background: rgba(34, 197, 94, 0.2); + color: #4ade80; + } + + .progress-step.current .step-icon { + background: #4ade80; + color: #051f18; + } + + .step-label { + color: rgba(255, 255, 255, 0.6); + font-size: 13px; + font-weight: 500; + transition: all 0.3s ease; + } + + .progress-step.active .step-label { + color: white; + } + + .progress-bar { + height: 4px; + background: rgba(255, 255, 255, 0.1); + border-radius: 2px; + overflow: hidden; + } + + .progress-fill { + height: 100%; + background: linear-gradient(90deg, #22c55e, #4ade80); + border-radius: 2px; + transition: width 0.5s ease; + } + @media (min-width: 768px) { .desktop-bg { display: block;