Database Viewer Update

This commit is contained in:
2026-01-25 00:16:48 +00:00
parent d37d925150
commit bae861c71f
15 changed files with 1726 additions and 846 deletions

View File

@@ -13,5 +13,8 @@ def create_app():
app.register_blueprint(gemini_bp, url_prefix='/api/gemini') app.register_blueprint(gemini_bp, url_prefix='/api/gemini')
from .routes.reports import reports_bp from .routes.reports import reports_bp
app.register_blueprint(reports_bp, url_prefix='/api/reports') 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 return app

View File

@@ -23,7 +23,33 @@ Based on the context provided, give a final verdict:
def ask(prompt): def ask(prompt):
client = genai.Client(api_key=os.environ.get("GOOGLE_API_KEY")) 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): def analyze(query, query_embedding, num_results=5, num_alternatives=3):
try: try:

View File

@@ -1,4 +0,0 @@
from . import scan_and_analyze
if __name__ == "__main__":
scan_and_analyze()

View File

@@ -3,15 +3,9 @@ import os
def generate_content(prompt, model_name="gemini-2.0-flash-exp"): def generate_content(prompt, model_name="gemini-2.0-flash-exp"):
api_key = os.environ.get("GOOGLE_API_KEY") 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) client = genai.Client(api_key=api_key)
response = client.models.generate_content( response = client.models.generate_content(
model=model_name, model=model_name,
contents=prompt, contents=prompt,
) )
return response.text return response.text
except Exception as e:
return f"Error interacting with Gemini API: {str(e)}"

View File

@@ -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('/<incident_id>', 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

View File

@@ -18,6 +18,11 @@ def get_reports():
if not filename: if not filename:
continue 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: if filename not in unique_reports:
# Attempt to extract info from filename # Attempt to extract info from filename
# Common patterns: # Common patterns:

View File

@@ -0,0 +1,101 @@
<script lang="ts">
import Icon from "@iconify/svelte";
let {
viewMode = $bindable(),
searchQuery = $bindable(),
selectedCategory = $bindable(),
categories,
onSearchInput,
}: {
viewMode: "company" | "user";
searchQuery: string;
selectedCategory: string;
categories: string[];
onSearchInput: () => void;
} = $props();
</script>
<div
class="bg-black/80 backdrop-blur-[30px] border border-white/20 rounded-[32px] p-10 mb-10 shadow-[0_32px_64px_rgba(0,0,0,0.4)]"
>
<!-- Header content -->
<div class="mb-8 px-3 text-center">
<h1 class="text-white text-[42px] font-black m-0 tracking-[-2px]">
Sustainability Database
</h1>
<p class="text-white/70 text-base mt-2 font-medium">
{#if viewMode === "company"}
Search within verified company reports and impact assessments
{:else}
Browse user-submitted greenwashing reports
{/if}
</p>
</div>
<!-- View Mode Toggle Switch -->
<div class="flex items-center justify-center gap-4 my-6">
<span
class="flex items-center gap-1.5 text-sm font-semibold transition-all duration-300 {viewMode ===
'company'
? 'text-emerald-400'
: 'text-white/40'}"
>
<Icon icon="ri:building-2-line" width="16" />
Company
</span>
<button
class="relative w-14 h-7 bg-white/15 border-none rounded-full cursor-pointer transition-all duration-300 p-0 hover:bg-white/20"
onclick={() =>
(viewMode = viewMode === "company" ? "user" : "company")}
aria-label="Toggle between company and user reports"
>
<span
class="absolute top-1/2 -translate-y-1/2 w-[22px] h-[22px] bg-emerald-600 rounded-full transition-all duration-300 shadow-[0_2px_8px_rgba(34,197,94,0.4)] {viewMode ===
'user'
? 'left-[calc(100%-25px)]'
: 'left-[3px]'}"
></span>
</button>
<span
class="flex items-center gap-1.5 text-sm font-semibold transition-all duration-300 {viewMode ===
'user'
? 'text-emerald-400'
: 'text-white/40'}"
>
<Icon icon="ri:user-voice-line" width="16" />
User Reports
</span>
</div>
{#if viewMode === "company"}
<div class="relative max-w-[600px] mx-auto mb-8">
<div
class="absolute left-5 top-1/2 -translate-y-1/2 text-white/60 flex items-center pointer-events-none"
>
<Icon icon="ri:search-line" width="20" />
</div>
<input
type="text"
class="w-full bg-white/5 border border-white/10 rounded-full py-4 pl-[52px] pr-5 text-white text-base font-medium outline-none transition-all duration-200 focus:bg-white/10 focus:border-emerald-400 placeholder:text-white/40"
placeholder="Search for companies, topics (e.g., 'emissions')..."
bind:value={searchQuery}
oninput={onSearchInput}
/>
</div>
<div class="flex justify-center flex-wrap gap-3 mb-5">
{#each categories as category}
<button
class="px-6 py-2.5 rounded-full text-sm font-semibold cursor-pointer transition-all duration-200 border {selectedCategory ===
category
? 'bg-emerald-500 border-emerald-500 text-emerald-950 shadow-[0_4px_15px_rgba(34,197,94,0.3)]'
: 'bg-white/5 border-white/10 text-white/70 hover:bg-white/10 hover:text-white hover:-translate-y-0.5'}"
onclick={() => (selectedCategory = category)}
>
{category}
</button>
{/each}
</div>
{/if}
</div>

View File

@@ -0,0 +1,86 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import { fade, scale } from "svelte/transition";
interface Report {
company_name: string;
year: string | number;
sector: string;
greenwashing_score: number | string;
filename: string;
title?: string;
snippet?: string;
}
let { report, onclose }: { report: Report; onclose: () => void } = $props();
</script>
<div
class="fixed inset-0 bg-black/80 backdrop-blur-md z-1000 flex justify-center items-center p-5 outline-none"
onclick={onclose}
onkeydown={(e) => e.key === "Escape" && onclose()}
transition:fade={{ duration: 200 }}
role="button"
tabindex="0"
aria-label="Close modal"
>
<div
class="bg-slate-900 border border-slate-700 rounded-[24px] w-full max-w-[1000px] h-[90vh] flex flex-col shadow-[0_24px_64px_rgba(0,0,0,0.5)] overflow-hidden outline-none"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
transition:scale={{ duration: 300, start: 0.95 }}
role="document"
tabindex="0"
>
<div
class="px-6 py-5 bg-slate-800 border-b border-slate-700 flex justify-between items-center shrink-0"
>
<div class="modal-title-group">
<h2 class="m-0 text-white text-xl font-bold">
{report.company_name}
</h2>
<span class="block mt-1 text-slate-400 text-sm"
>{report.title || report.filename}</span
>
</div>
<button
class="bg-transparent border-none text-slate-400 cursor-pointer p-1 flex transition-colors duration-200 hover:text-white"
onclick={onclose}
>
<Icon icon="ri:close-line" width="24" />
</button>
</div>
<div class="flex-1 relative overflow-hidden bg-black flex flex-col">
{#if report.filename
.toLowerCase()
.endsWith(".pdf") || report.filename
.toLowerCase()
.endsWith(".txt")}
<iframe
src="http://localhost:5000/api/reports/view/{report.filename}"
class="w-full h-full border-none"
title="Report Viewer"
></iframe>
{:else}
<div
class="flex-1 flex flex-col items-center justify-center text-slate-400 gap-5"
>
<Icon
icon="ri:file-warning-line"
width="64"
class="text-slate-500"
/>
<p>Preview not available for this file type.</p>
<a
href="http://localhost:5000/api/reports/view/{report.filename}"
download
class="bg-emerald-400 text-slate-900 px-6 py-3 rounded-full no-underline font-bold transition-transform hover:scale-105"
>
Download File
</a>
</div>
{/if}
</div>
</div>
</div>

View File

@@ -0,0 +1,86 @@
<script lang="ts">
import Icon from "@iconify/svelte";
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;
};
}
let { incident, onclick }: { incident: Incident; onclick: () => void } =
$props();
</script>
<button
class="flex items-center gap-6 p-6 bg-black/80 backdrop-blur-[30px] border border-red-500/30 rounded-[24px] transition-all duration-300 shadow-[0_16px_48px_rgba(0,0,0,0.5)] w-full text-left cursor-pointer hover:bg-black/95 hover:border-red-500/60 hover:-translate-y-1 hover:scale-[1.01] hover:shadow-[0_24px_64px_rgba(0,0,0,0.6)] outline-none"
{onclick}
>
<div
class="w-[52px] h-[52px] rounded-xe flex items-center justify-center shrink-0 rounded-[14px]
{incident.analysis?.severity === 'high'
? 'bg-red-500/20 text-red-500'
: 'bg-amber-500/20 text-amber-500'}"
>
<Icon icon="ri:alert-fill" width="28" />
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-3 mb-1.5">
<h3 class="text-white text-[18px] font-bold m-0 italic">
{incident.product_name}
</h3>
{#if incident.detected_brand && incident.detected_brand !== "Unknown"}
<span
class="bg-white/10 text-white/70 px-2.5 py-0.5 rounded-full text-[12px] font-semibold"
>{incident.detected_brand}</span
>
{/if}
</div>
<p class="text-white/70 text-sm m-0 mb-2.5 leading-tight italic">
{incident.analysis?.verdict || "Greenwashing detected"}
</p>
<div class="flex flex-wrap gap-2">
<span
class="flex items-center gap-1 px-2.5 py-1 rounded-full text-[11px] font-semibold capitalize
{incident.analysis?.severity === 'high'
? 'bg-red-500/20 text-red-400'
: incident.analysis?.severity === 'medium'
? 'bg-amber-500/20 text-amber-400'
: 'bg-emerald-500/20 text-emerald-400'}"
>
<Icon icon="ri:error-warning-fill" width="14" />
{incident.analysis?.severity || "unknown"} severity
</span>
<span
class="flex items-center gap-1 px-2.5 py-1 rounded-full text-[11px] font-semibold bg-indigo-500/20 text-indigo-300"
>
<Icon icon="ri:shield-check-fill" width="14" />
{incident.analysis?.confidence || "unknown"} confidence
</span>
<span
class="flex items-center gap-1 px-2.5 py-1 rounded-full text-[11px] font-semibold bg-white/10 text-white/60"
>
<Icon icon="ri:calendar-line" width="14" />
{new Date(incident.created_at).toLocaleDateString()}
</span>
</div>
</div>
<div
class="flex flex-col items-center gap-1 text-[11px] font-bold uppercase text-red-500"
>
<Icon icon="ri:spam-2-fill" width="24" class="text-red-500" />
<span>Confirmed</span>
</div>
</button>

View File

@@ -0,0 +1,233 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import { fade, scale } from "svelte/transition";
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 { incident, onclose }: { incident: Incident; onclose: () => void } =
$props();
</script>
<div
class="fixed inset-0 bg-black/60 backdrop-blur-md z-1000 flex justify-center items-center p-5 outline-none"
onclick={onclose}
onkeydown={(e) => e.key === "Escape" && onclose()}
transition:fade={{ duration: 200 }}
role="button"
tabindex="0"
aria-label="Close modal"
>
<div
class="max-w-[700px] max-h-[85vh] w-full overflow-y-auto bg-black/85 backdrop-blur-[40px] border border-white/20 rounded-[32px] flex flex-col shadow-[0_32px_128px_rgba(0,0,0,0.7)] scrollbar-hide outline-none"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
transition:scale={{ duration: 300, start: 0.95 }}
role="document"
tabindex="0"
>
<div
class="px-10 py-[30px] bg-white/3 border-b border-red-500/20 flex justify-between items-center shrink-0"
>
<div class="flex flex-col">
<div class="flex items-center gap-3">
<Icon
icon="ri:alert-fill"
width="28"
class="text-red-500"
/>
<h2 class="m-0 text-white text-[28px] font-extrabold">
{incident.product_name}
</h2>
</div>
{#if incident.detected_brand && incident.detected_brand !== "Unknown"}
<span class="mt-1 text-white/50 text-sm font-medium"
>Brand: {incident.detected_brand}</span
>
{/if}
</div>
<button
class="bg-white/5 border border-white/10 text-white w-10 h-10 rounded-xl flex items-center justify-center cursor-pointer transition-all duration-200 hover:bg-white/10 hover:rotate-90"
onclick={onclose}
>
<Icon icon="ri:close-line" width="24" />
</button>
</div>
<div class="p-10 flex flex-col gap-[30px]">
<!-- Status Badges -->
<div class="flex flex-wrap gap-3">
<span
class="flex items-center gap-2 px-5 py-3 rounded-[14px] text-[11px] font-extrabold tracking-wider
{incident.analysis?.severity === 'high'
? 'bg-red-500/20 text-red-400 border border-red-500/30'
: incident.analysis?.severity === 'medium'
? 'bg-amber-500/20 text-amber-400 border border-amber-500/30'
: 'bg-emerald-500/20 text-emerald-400 border border-emerald-500/30'} uppercase"
>
<Icon icon="ri:error-warning-fill" width="18" />
{incident.analysis?.severity || "UNKNOWN"} SEVERITY
</span>
<span
class="flex items-center gap-2 px-5 py-3 rounded-[14px] text-[11px] font-extrabold tracking-wider bg-indigo-500/20 text-indigo-300 border border-indigo-500/30 uppercase"
>
<Icon icon="ri:shield-check-fill" width="18" />
{incident.analysis?.confidence || "UNKNOWN"} CONFIDENCE
</span>
<span
class="flex items-center gap-2 px-5 py-3 rounded-[14px] text-[11px] font-extrabold tracking-wider bg-white/10 text-white/70 border border-white/10 uppercase"
>
<Icon icon="ri:calendar-check-fill" width="18" />
{new Date(incident.created_at).toLocaleDateString()}
</span>
</div>
<!-- Verdict -->
<div class="bg-white/4 border border-white/6 rounded-[20px] p-6">
<h3
class="flex items-center gap-[10px] text-white text-base font-bold mb-4"
>
<Icon icon="ri:scales-3-fill" width="20" />
Verdict
</h3>
<p
class="text-amber-400 text-[18px] font-semibold m-0 leading-normal"
>
{incident.analysis?.verdict || "Greenwashing detected"}
</p>
</div>
<!-- Detailed Analysis -->
<div class="bg-white/4 border border-white/6 rounded-[20px] p-6">
<h3
class="flex items-center gap-[10px] text-white text-base font-bold mb-4"
>
<Icon icon="ri:file-text-fill" width="20" />
Detailed Analysis
</h3>
<p class="text-white/85 text-[15px] leading-[1.7] m-0">
{incident.analysis?.reasoning ||
"No detailed analysis available."}
</p>
</div>
<!-- Red Flags -->
{#if incident.analysis?.red_flags && incident.analysis.red_flags.length > 0}
<div
class="bg-white/4 border border-white/[0.06] rounded-[20px] p-6"
>
<h3
class="flex items-center gap-[10px] text-red-400 text-base font-bold mb-4"
>
<Icon icon="ri:flag-fill" width="20" />
Red Flags Identified
</h3>
<ul class="list-none p-0 m-0 flex flex-col gap-3">
{#each incident.analysis.red_flags as flag}
<li
class="flex items-start gap-3 text-red-300/80 text-sm leading-[1.6]"
>
<Icon
icon="ri:error-warning-line"
width="16"
class="mt-0.5 shrink-0"
/>
{flag}
</li>
{/each}
</ul>
</div>
{/if}
<!-- Key Claims -->
{#if incident.analysis?.key_claims && incident.analysis.key_claims.length > 0}
<div
class="bg-white/4 border border-white/[0.06] rounded-[20px] p-6"
>
<h3
class="flex items-center gap-[10px] text-white text-base font-bold mb-4"
>
<Icon icon="ri:chat-quote-fill" width="20" />
Environmental Claims Made
</h3>
<ul class="list-none p-0 m-0 flex flex-col gap-3">
{#each incident.analysis.key_claims as claim}
<li
class="flex items-start gap-3 text-white/70 text-sm italic leading-[1.6]"
>
<Icon
icon="ri:double-quotes-l"
width="16"
class="mt-0.5 shrink-0"
/>
{claim}
</li>
{/each}
</ul>
</div>
{/if}
<!-- Recommendations -->
{#if incident.analysis?.recommendations}
<div
class="bg-emerald-500/8 border border-emerald-500/20 rounded-[20px] p-6"
>
<h3
class="flex items-center gap-[10px] text-emerald-400 text-base font-bold mb-4"
>
<Icon icon="ri:lightbulb-fill" width="20" />
Consumer Recommendations
</h3>
<p class="text-emerald-300 text-[15px] leading-[1.6] m-0">
{incident.analysis.recommendations}
</p>
</div>
{/if}
<!-- User's Original Report -->
{#if incident.user_description}
<div
class="bg-indigo-500/8 border border-indigo-500/20 rounded-[20px] p-6"
>
<h3
class="flex items-center gap-[10px] text-indigo-400 text-base font-bold mb-4"
>
<Icon icon="ri:user-voice-fill" width="20" />
Original User Report
</h3>
<p
class="text-indigo-200 text-[15px] italic leading-[1.6] m-0"
>
"{incident.user_description}"
</p>
</div>
{/if}
</div>
</div>
</div>
<style>
/* Custom utility to hide scrollbar if tailwind plugin not present */
.scrollbar-hide {
scrollbar-width: none;
-ms-overflow-style: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
</style>

View File

@@ -0,0 +1,53 @@
<script lang="ts">
import Icon from "@iconify/svelte";
let {
currentPage = $bindable(),
totalPages,
goToPage,
}: {
currentPage: number;
totalPages: number;
goToPage: (p: number) => void;
} = $props();
</script>
{#if totalPages > 1}
<div class="flex items-center justify-center gap-4 mt-6">
<button
class="w-12 h-12 flex items-center justify-center rounded-xl bg-white/5 border border-white/10 text-white cursor-pointer transition-all duration-200 hover:enabled:bg-white/15 hover:enabled:scale-105 disabled:opacity-30 disabled:cursor-not-allowed"
disabled={currentPage === 1}
onclick={() => goToPage(currentPage - 1)}
>
<Icon icon="ri:arrow-left-s-line" width="24" />
</button>
<div class="flex gap-2">
{#if totalPages <= 7}
{#each Array(totalPages) as _, i}
<button
class="px-[18px] h-12 flex items-center justify-center rounded-xl border font-bold text-sm cursor-pointer transition-all duration-200 hover:enabled:bg-white/15 hover:enabled:scale-105 disabled:opacity-30 disabled:cursor-not-allowed
{currentPage === i + 1
? 'bg-emerald-500 border-emerald-500 text-emerald-950'
: 'bg-white/5 border-white/10 text-white'}"
onclick={() => goToPage(i + 1)}
>
{i + 1}
</button>
{/each}
{:else}
<span class="text-white/50 text-sm font-semibold self-center">
Page {currentPage} of {totalPages}
</span>
{/if}
</div>
<button
class="w-12 h-12 flex items-center justify-center rounded-xl bg-white/5 border border-white/10 text-white cursor-pointer transition-all duration-200 hover:enabled:bg-white/15 hover:enabled:scale-105 disabled:opacity-30 disabled:cursor-not-allowed"
disabled={currentPage === totalPages}
onclick={() => goToPage(currentPage + 1)}
>
<Icon icon="ri:arrow-right-s-line" width="24" />
</button>
</div>
{/if}

View File

@@ -0,0 +1,120 @@
<script lang="ts">
import Icon from "@iconify/svelte";
interface Report {
company_name: string;
year: string | number;
sector: string;
greenwashing_score: number | string;
filename: string;
title?: string;
snippet?: string;
}
let {
report,
openReport,
searchQuery,
}: {
report: Report;
openReport: (r: Report) => void;
searchQuery: string;
} = $props();
function getFileDetails(filename: string) {
const ext = filename.split(".").pop()?.toLowerCase();
if (ext === "pdf")
return { type: "PDF Document", icon: "ri:file-pdf-2-line" };
if (ext === "xlsx" || ext === "csv")
return { type: "Data Spreadsheet", icon: "ri:file-excel-2-line" };
if (ext === "txt")
return { type: "Text Report", icon: "ri:file-text-line" };
return { type: "Impact Report", icon: "ri:file-info-line" };
}
function getScoreColor(score: string | number) {
const s = Number(score);
if (s >= 80) return "bg-emerald-500";
if (s >= 50) return "bg-amber-500";
return "bg-red-500";
}
const fileDetails = $derived(getFileDetails(report.filename));
</script>
<button
class="group flex items-center gap-6 bg-black/80 backdrop-blur-[30px] border border-white/20 rounded-3xl p-8 w-full text-left cursor-pointer transition-all duration-300 hover:bg-black/90 hover:border-emerald-500/60 hover:-translate-y-1 hover:scale-[1.01] hover:shadow-[0_24px_48px_rgba(0,0,0,0.5)] relative overflow-hidden outline-none"
onclick={() => openReport(report)}
>
<div
class="w-16 h-16 bg-emerald-500/10 rounded-[18px] flex items-center justify-center shrink-0 transition-all duration-300 group-hover:bg-emerald-500 group-hover:-rotate-6"
>
<Icon icon={fileDetails.icon} width="32" class="text-white" />
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-3 mb-2">
<h3 class="text-white text-xl font-extrabold m-0">
{report.company_name}
</h3>
<span
class="text-emerald-400 bg-emerald-500/10 px-2 py-0.5 rounded-md text-[12px] font-bold"
>{report.year}</span
>
</div>
{#if report.snippet}
<p
class="text-white/60 text-sm leading-relaxed mb-4 line-clamp-2 m-0"
>
{@html report.snippet.replace(
new RegExp(searchQuery || "", "gi"),
(match) =>
`<span class="text-emerald-400 bg-emerald-500/20 px-0.5 rounded font-semibold">${match}</span>`,
)}
</p>
{:else}
<p class="text-white/40 text-sm mb-4 m-0">
{report.sector} Sector • Impact Report
</p>
{/if}
<div class="flex flex-wrap gap-2">
<span
class="flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-[11px] font-bold tracking-tight bg-white/5 text-white/60 max-w-[200px] truncate"
title={report.filename}
>
<Icon icon={fileDetails.icon} width="14" />
{fileDetails.type}
</span>
<span
class="flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-[11px] font-bold tracking-tight bg-emerald-500/10 text-emerald-400"
>
<Icon
icon="ri:checkbox-circle-fill"
width="14"
class="text-emerald-500"
/>
Analyzed
</span>
</div>
</div>
{#if report.greenwashing_score}
<div class="text-center ml-5">
<div
class="w-[52px] h-[52px] rounded-xe flex items-center justify-center mb-1 rounded-[14px] {getScoreColor(
report.greenwashing_score,
)}"
>
<span class="text-emerald-950 text-[18px] font-black"
>{Math.round(Number(report.greenwashing_score))}</span
>
</div>
<span
class="text-white/40 text-[10px] font-extrabold uppercase tracking-widest"
>Trust Score</span
>
</div>
{/if}
</button>

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,9 @@
let description = $state(""); let description = $state("");
let image = $state<string | null>(null); let image = $state<string | null>(null);
let submitted = $state(false); let submitted = $state(false);
let isLoading = $state(false);
let analysisResult = $state<any>(null);
let error = $state<string | null>(null);
$effect(() => { $effect(() => {
const initialName = new URLSearchParams(window.location.search).get( const initialName = new URLSearchParams(window.location.search).get(
@@ -37,12 +40,75 @@
input.click(); input.click();
} }
function handleSubmit() { const progressSteps = [
if (!isValid) return; { 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; submitted = true;
setTimeout(() => { } else {
window.history.back(); error = data.message || "Failed to submit report";
}, 2000); }
} catch (e) {
clearInterval(stepInterval);
error = "Failed to connect to server. Please try again.";
console.error("Submit error:", e);
} finally {
isLoading = false;
currentStep = 0;
}
} }
</script> </script>
@@ -52,8 +118,20 @@
</div> </div>
<div class="content-container"> <div class="content-container">
{#if submitted} {#if submitted && analysisResult}
<div class="glass-card success-card"> <div class="glass-card success-card">
{#if analysisResult.is_greenwashing}
<div class="icon-circle warning">
<iconify-icon
icon="ri:alert-fill"
width="60"
style="color: #f59e0b;"
></iconify-icon>
</div>
<h2 class="success-title warning-text">
Greenwashing Detected!
</h2>
{:else}
<div class="icon-circle success"> <div class="icon-circle success">
<iconify-icon <iconify-icon
icon="ri:checkbox-circle-fill" icon="ri:checkbox-circle-fill"
@@ -61,11 +139,59 @@
style="color: #4ade80;" style="color: #4ade80;"
></iconify-icon> ></iconify-icon>
</div> </div>
<h2 class="success-title">Report Submitted!</h2> <h2 class="success-title">Report Analyzed</h2>
<p class="success-subtitle"> {/if}
Thank you for keeping companies honest.
<div class="analysis-result">
<div class="result-item">
<span class="result-label">Verdict:</span>
<span class="result-value"
>{analysisResult.analysis?.verdict || "N/A"}</span
>
</div>
<div class="result-item">
<span class="result-label">Confidence:</span>
<span
class="result-value badge {analysisResult.analysis
?.confidence}"
>
{analysisResult.analysis?.confidence || "unknown"}
</span>
</div>
{#if analysisResult.is_greenwashing}
<div class="result-item">
<span class="result-label">Severity:</span>
<span
class="result-value badge severity-{analysisResult
.analysis?.severity}"
>
{analysisResult.analysis?.severity || "unknown"}
</span>
</div>
{/if}
<div class="result-item full-width">
<span class="result-label">Analysis:</span>
<p class="result-text">
{analysisResult.analysis?.reasoning ||
"No details available"}
</p> </p>
</div> </div>
{#if analysisResult.detected_brand && analysisResult.detected_brand !== "Unknown"}
<div class="result-item">
<span class="result-label">Detected Brand:</span>
<span class="result-value"
>{analysisResult.detected_brand}</span
>
</div>
{/if}
</div>
<button class="back-btn" onclick={() => window.history.back()}>
<iconify-icon icon="ri:arrow-left-line" width="20"
></iconify-icon>
Go Back
</button>
</div>
{:else} {:else}
<div class="header-section"> <div class="header-section">
<h1 class="page-title">Report Greenwashing</h1> <h1 class="page-title">Report Greenwashing</h1>
@@ -143,6 +269,68 @@
</button> </button>
</div> </div>
{#if error}
<div class="error-message">
<iconify-icon
icon="ri:error-warning-line"
width="18"
></iconify-icon>
{error}
</div>
{/if}
{#if isLoading}
<div class="progress-container">
<div class="progress-header">
<iconify-icon
icon="ri:shield-check-line"
width="24"
class="pulse"
></iconify-icon>
<span>Analyzing Report</span>
</div>
<div class="progress-steps">
{#each progressSteps as step}
<div
class="progress-step"
class:active={currentStep >= step.id}
class:current={currentStep === step.id}
>
<div class="step-icon">
{#if currentStep > step.id}
<iconify-icon
icon="ri:check-line"
width="16"
></iconify-icon>
{:else if currentStep === step.id}
<iconify-icon
icon={step.icon}
width="16"
class="spin-slow"
></iconify-icon>
{:else}
<iconify-icon
icon={step.icon}
width="16"
></iconify-icon>
{/if}
</div>
<span class="step-label"
>{step.label}</span
>
</div>
{/each}
</div>
<div class="progress-bar">
<div
class="progress-fill"
style="width: {(currentStep /
progressSteps.length) *
100}%"
></div>
</div>
</div>
{:else}
<button <button
class="submit-button" class="submit-button"
class:disabled={!isValid} class:disabled={!isValid}
@@ -150,10 +338,11 @@
onclick={handleSubmit} onclick={handleSubmit}
type="button" type="button"
> >
<iconify-icon icon="ri:alert-fill" width="20" <iconify-icon icon="ri:shield-flash-line" width="20"
></iconify-icon> ></iconify-icon>
Submit Report Analyze for Greenwashing
</button> </button>
{/if}
</div> </div>
</div> </div>
{/if} {/if}
@@ -175,22 +364,22 @@
.content-container { .content-container {
position: relative; position: relative;
z-index: 10; z-index: 10;
padding: 100px 24px 120px; padding: 80px 20px 40px;
max-width: 600px; max-width: 500px;
margin: 0 auto; margin: 0 auto;
} }
.header-section { .header-section {
text-align: center; text-align: center;
margin-bottom: 32px; margin-bottom: 20px;
} }
.page-title { .page-title {
color: white; color: white;
font-size: 36px; font-size: 28px;
font-weight: 900; font-weight: 900;
margin: 0; margin: 0;
letter-spacing: -1px; letter-spacing: -0.5px;
} }
.subtitle { .subtitle {
@@ -212,19 +401,19 @@
} }
.form-card { .form-card {
padding: 36px; padding: 24px;
} }
.form-content { .form-content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 24px; gap: 16px;
} }
.form-group { .form-group {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px; gap: 6px;
} }
.label { .label {
@@ -412,6 +601,239 @@
margin: 0; 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) { @media (min-width: 768px) {
.desktop-bg { .desktop-bg {
display: block; display: block;