Database and Reports Update

This commit is contained in:
2026-01-25 15:31:41 +00:00
parent 5ce0b4d278
commit 34eba52461
38 changed files with 3820 additions and 2797 deletions

120
README.md
View File

@@ -0,0 +1,120 @@
# Ethix
A sustainability platform that helps users detect and report greenwashing - misleading environmental claims made by companies and products. Powered by AI analysis using Google Gemini and Ollama.
## About
Ethix enables users to:
- **Scan Products**: Use camera to detect brand logos and analyze environmental claims
- **Report Greenwashing**: Submit incidents with evidence (images or PDF reports)
- **Browse Reports**: Explore company sustainability reports and user-submitted incidents
- **Chat with AI**: Get answers about sustainability, recycling, and eco-friendly alternatives
## Technology Overview
### Frontend
- SvelteKit with Svelte 5
- TypeScript
- TailwindCSS 4
- Tauri (desktop builds)
- Three.js (3D effects)
### Backend
- Flask (Python)
- Google Gemini AI
- Ollama (embeddings and vision)
- ChromaDB (vector database)
- MongoDB (document storage)
## Quick Start
### Prerequisites
- Node.js 18+ or Bun
- Python 3.10+
- MongoDB instance
- Google API Key
### Backend Setup
```bash
cd backend
python -m venv venv
source venv/bin/activate
pip install -r requirements.txt
python app.py
```
### Frontend Setup
```bash
cd frontend
bun install # or npm install
bun run dev # or npm run dev
```
### Using Docker Compose
```bash
docker-compose up
```
## Services
| Service | Port | Description |
|---------|------|-------------|
| Frontend | 5173 | SvelteKit development server |
| Backend | 5000 | Flask API server |
## External Dependencies
The application requires access to:
| Service | Purpose |
|---------|---------|
| ChromaDB | Vector storage for RAG |
| Ollama | Embeddings and vision models |
| MongoDB | Incident and metadata storage |
| Google Gemini | AI analysis and chat |
## Key Features
### Greenwashing Detection
Submit product photos or company PDF reports for AI-powered analysis. The system:
1. Detects brand logos using vision AI
2. Extracts text from documents
3. Searches for relevant context in the database
4. Provides structured verdicts with confidence levels
### RAG-Powered Chat
The AI assistant uses Retrieval-Augmented Generation to provide accurate, context-aware responses about sustainability topics.
### Catalogue System
Browse and search:
- Company sustainability reports with semantic search
- User-submitted greenwashing incidents
- Filter by category and view detailed analysis
## Environment Variables
### Backend (.env)
```env
GOOGLE_API_KEY=your_google_api_key
MONGO_URI=your_mongodb_connection_string
CHROMA_HOST=http://your-chromadb-host
OLLAMA_HOST=https://your-ollama-host
```
## Documentation
- [Backend Documentation](./backend/README.md)
- [Frontend Documentation](./frontend/README.md)

151
backend/README.md Normal file
View File

@@ -0,0 +1,151 @@
# Ethix Backend
A Flask-based API server for the Ethix greenwashing detection platform. This backend provides AI-powered analysis of products and companies to identify misleading environmental claims.
## Technology Stack
| Component | Technology |
|-----------|------------|
| Framework | Flask |
| AI/LLM | Google Gemini, Ollama |
| Vector Database | ChromaDB |
| Document Store | MongoDB |
| Embeddings | Ollama (nomic-embed-text) |
| Vision AI | Ollama (ministral-3) |
| Computer Vision | OpenCV, Ultralytics (YOLO) |
| Document Processing | PyPDF, openpyxl, pandas |
## Prerequisites
- Python 3.10+
- MongoDB instance
- Access to ChromaDB server
- Access to Ollama server
- Google API Key (for Gemini)
## Environment Variables
Create a `.env` file in the backend directory:
```env
GOOGLE_API_KEY=your_google_api_key
MONGO_URI=your_mongodb_connection_string
CHROMA_HOST=http://your-chromadb-host
OLLAMA_HOST=https://your-ollama-host
```
| Variable | Description | Default |
|----------|-------------|---------|
| `GOOGLE_API_KEY` | Google Gemini API key | (required) |
| `MONGO_URI` | MongoDB connection string | (required) |
| `CHROMA_HOST` | ChromaDB server URL | `http://chroma.sirblob.co` |
| `OLLAMA_HOST` | Ollama server URL | `https://ollama.sirblob.co` |
## Installation
1. Create and activate a virtual environment:
```bash
python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
```
2. Install dependencies:
```bash
pip install -r requirements.txt
```
## Running the Server
### Development
```bash
python app.py
```
The server will start on `http://localhost:5000`.
### Production
```bash
gunicorn -w 4 -b 0.0.0.0:5000 app:app
```
## API Endpoints
### Gemini AI
| Method | Endpoint | Description |
|--------|----------|-------------|
| POST | `/api/gemini/ask` | Chat with AI using RAG context |
| POST | `/api/gemini/rag` | Query with category filtering |
| POST | `/api/gemini/vision` | Vision analysis (not implemented) |
### Incidents
| Method | Endpoint | Description |
|--------|----------|-------------|
| POST | `/api/incidents/submit` | Submit a greenwashing report |
| GET | `/api/incidents/list` | Get all confirmed incidents |
| GET | `/api/incidents/<id>` | Get specific incident details |
### Reports
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/reports/` | List all company reports |
| POST | `/api/reports/search` | Semantic search for reports |
| GET | `/api/reports/view/<filename>` | Download a report file |
### RAG
| Method | Endpoint | Description |
|--------|----------|-------------|
| POST | `/api/rag/ingest` | Ingest document chunks |
| POST | `/api/rag/search` | Search vector database |
## External Services
The backend integrates with the following external services:
| Service | URL | Purpose |
|---------|-----|---------|
| ChromaDB | `http://chroma.sirblob.co` | Vector storage and similarity search |
| Ollama | `https://ollama.sirblob.co` | Embeddings and vision analysis |
## Docker
Build and run using Docker:
```bash
docker build -t ethix-backend .
docker run -p 5000:5000 --env-file .env ethix-backend
```
Or use Docker Compose from the project root:
```bash
docker-compose up backend
```
## Core Features
### Greenwashing Detection
The incident submission pipeline:
1. User uploads product image or company PDF
2. Vision model detects brand logos (for products)
3. PDF text extraction (for company reports)
4. Embedding generation for semantic search
5. RAG context retrieval from ChromaDB
6. Gemini analysis with structured output
7. Results stored in MongoDB and ChromaDB
### RAG (Retrieval-Augmented Generation)
- Supports CSV, PDF, TXT, and XLSX file ingestion
- Documents are chunked and batched for embedding
- Prevents duplicate ingestion of processed files
- Semantic search using cosine similarity

View File

@@ -12,7 +12,7 @@ from src .rag .ingest import process_file
from src .rag .store import ingest_documents from src .rag .store import ingest_documents
from src .mongo .metadata import is_file_processed ,log_processed_file from src .mongo .metadata import is_file_processed ,log_processed_file
def populate_from_dataset (dataset_dir ,category =None ): def populate_from_dataset (dataset_dir ,category =None ,force =False ):
dataset_path =Path (dataset_dir ) dataset_path =Path (dataset_dir )
if not dataset_path .exists (): if not dataset_path .exists ():
print (f"Dataset directory not found: {dataset_dir }") print (f"Dataset directory not found: {dataset_dir }")
@@ -27,7 +27,7 @@ def populate_from_dataset (dataset_dir ,category =None ):
for file_path in dataset_path .glob ('*'): for file_path in dataset_path .glob ('*'):
if file_path .is_file ()and file_path .suffix .lower ()in ['.csv','.pdf','.txt','.xlsx']: if file_path .is_file ()and file_path .suffix .lower ()in ['.csv','.pdf','.txt','.xlsx']:
if is_file_processed (file_path .name ): if not force and is_file_processed (file_path .name ):
print (f"Skipping {file_path .name } (already processed)") print (f"Skipping {file_path .name } (already processed)")
continue continue
@@ -48,15 +48,23 @@ def populate_from_dataset (dataset_dir ,category =None ):
print (f"\nFinished! Processed {files_processed } files. Total chunks ingested: {total_chunks }") print (f"\nFinished! Processed {files_processed } files. Total chunks ingested: {total_chunks }")
if __name__ =="__main__": if __name__ == "__main__":
parser =argparse .ArgumentParser (description ="Populate vector database from dataset files") parser = argparse.ArgumentParser(description="Populate vector database from dataset files")
parser .add_argument ("--category","-c",type =str ,help ="Category to assign to ingested documents") parser.add_argument("--category", "-c", type=str, help="Category to assign to ingested documents")
parser .add_argument ("--dir","-d",type =str ,default =None ,help ="Dataset directory path") parser.add_argument("--dir", "-d", type=str, default=None, help="Dataset directory path")
args =parser .parse_args () parser.add_argument("--force", "-f", action="store_true", help="Force re-processing of files even if marked as processed")
args = parser.parse_args()
if args .dir : # Check vector store mode
dataset_dir =args .dir use_atlas = os.environ.get("ATLAS_VECTORS", "false").lower() == "true"
else : store_name = "MongoDB Atlas Vector Search" if use_atlas else "ChromaDB"
dataset_dir =os .path .join (os .path .dirname (__file__ ),'../dataset') print(f"--- Vector Store Mode: {store_name} ---")
populate_from_dataset (dataset_dir ,category =args .category ) if args.dir:
dataset_dir = args.dir
else:
dataset_dir = os.path.join(os.path.dirname(__file__), '../dataset')
# Note: We need to pass force flag to populate_from_dataset ideally,
# but the function signature doesn't have it. I'll modify the function signature too.
populate_from_dataset(dataset_dir, category=args.category, force=args.force)

View File

@@ -0,0 +1,81 @@
import chromadb
import os
CHROMA_HOST = os.environ.get("CHROMA_HOST", "http://chroma.sirblob.co")
COLLECTION_NAME = "rag_documents"
_client =None
def get_chroma_client ():
global _client
if _client is None :
_client =chromadb .HttpClient (host =CHROMA_HOST )
return _client
def get_collection (collection_name =COLLECTION_NAME ):
client =get_chroma_client ()
return client .get_or_create_collection (name =collection_name )
def insert_documents (texts ,embeddings ,collection_name =COLLECTION_NAME ,metadata_list =None ):
collection =get_collection (collection_name )
ids =[f"doc_{i }_{hash (text )}"for i ,text in enumerate (texts )]
if metadata_list :
collection .add (
ids =ids ,
embeddings =embeddings ,
documents =texts ,
metadatas =metadata_list
)
else :
collection .add (
ids =ids ,
embeddings =embeddings ,
documents =texts
)
return len (texts )
def search_documents (query_embedding ,collection_name =COLLECTION_NAME ,num_results =5 ,filter_metadata =None ):
collection =get_collection (collection_name )
query_params ={
"query_embeddings":[query_embedding ],
"n_results":num_results
}
if filter_metadata :
query_params ["where"]=filter_metadata
results =collection .query (**query_params )
output =[]
if results and results ["documents"]:
for i ,doc in enumerate (results ["documents"][0 ]):
score =results ["distances"][0 ][i ]if "distances"in results else None
meta =results ["metadatas"][0 ][i ]if "metadatas"in results else {}
output .append ({
"text":doc ,
"score":score ,
"metadata":meta
})
return output
def delete_documents_by_source (source_file ,collection_name =COLLECTION_NAME ):
collection =get_collection (collection_name )
results =collection .get (where ={"source":source_file })
if results ["ids"]:
collection .delete (ids =results ["ids"])
return len (results ["ids"])
return 0
def get_all_metadatas (collection_name =COLLECTION_NAME ,limit =None ):
collection =get_collection (collection_name )
if limit :
results =collection .get (include =["metadatas"],limit =limit )
else :
results =collection .get (include =["metadatas"])
return results ["metadatas"]if results and "metadatas"in results else []

View File

@@ -1,80 +1,35 @@
import chromadb import os
from . import chroma_store
from ..mongo import vector_store as mongo_store
CHROMA_HOST ="http://chroma.sirblob.co" def _use_atlas():
COLLECTION_NAME ="rag_documents" return os.environ.get("ATLAS_VECTORS", "false").lower() == "true"
_client =None def get_chroma_client():
# Only used by chroma-specific things if external
return chroma_store.get_chroma_client()
def get_chroma_client (): def get_collection(collection_name=chroma_store.COLLECTION_NAME):
global _client if _use_atlas():
if _client is None : return mongo_store.get_collection(collection_name)
_client =chromadb .HttpClient (host =CHROMA_HOST ) return chroma_store.get_collection(collection_name)
return _client
def get_collection (collection_name =COLLECTION_NAME ): def insert_documents(texts, embeddings, collection_name=chroma_store.COLLECTION_NAME, metadata_list=None):
client =get_chroma_client () if _use_atlas():
return client .get_or_create_collection (name =collection_name ) return mongo_store.insert_documents(texts, embeddings, collection_name, metadata_list)
return chroma_store.insert_documents(texts, embeddings, collection_name, metadata_list)
def insert_documents (texts ,embeddings ,collection_name =COLLECTION_NAME ,metadata_list =None ): def search_documents(query_embedding, collection_name=chroma_store.COLLECTION_NAME, num_results=5, filter_metadata=None):
collection =get_collection (collection_name ) if _use_atlas():
return mongo_store.search_documents(query_embedding, collection_name, num_results, filter_metadata)
return chroma_store.search_documents(query_embedding, collection_name, num_results, filter_metadata)
ids =[f"doc_{i }_{hash (text )}"for i ,text in enumerate (texts )] def delete_documents_by_source(source_file, collection_name=chroma_store.COLLECTION_NAME):
if _use_atlas():
return mongo_store.delete_documents_by_source(source_file, collection_name)
return chroma_store.delete_documents_by_source(source_file, collection_name)
if metadata_list : def get_all_metadatas(collection_name=chroma_store.COLLECTION_NAME, limit=None):
collection .add ( if _use_atlas():
ids =ids , return mongo_store.get_all_metadatas(collection_name, limit)
embeddings =embeddings , return chroma_store.get_all_metadatas(collection_name, limit)
documents =texts ,
metadatas =metadata_list
)
else :
collection .add (
ids =ids ,
embeddings =embeddings ,
documents =texts
)
return len (texts )
def search_documents (query_embedding ,collection_name =COLLECTION_NAME ,num_results =5 ,filter_metadata =None ):
collection =get_collection (collection_name )
query_params ={
"query_embeddings":[query_embedding ],
"n_results":num_results
}
if filter_metadata :
query_params ["where"]=filter_metadata
results =collection .query (**query_params )
output =[]
if results and results ["documents"]:
for i ,doc in enumerate (results ["documents"][0 ]):
score =results ["distances"][0 ][i ]if "distances"in results else None
meta =results ["metadatas"][0 ][i ]if "metadatas"in results else {}
output .append ({
"text":doc ,
"score":score ,
"metadata":meta
})
return output
def delete_documents_by_source (source_file ,collection_name =COLLECTION_NAME ):
collection =get_collection (collection_name )
results =collection .get (where ={"source":source_file })
if results ["ids"]:
collection .delete (ids =results ["ids"])
return len (results ["ids"])
return 0
def get_all_metadatas (collection_name =COLLECTION_NAME ,limit =None ):
collection =get_collection (collection_name )
if limit :
results =collection .get (include =["metadatas"],limit =limit )
else :
results =collection .get (include =["metadatas"])
return results ["metadatas"]if results and "metadatas"in results else []

View File

@@ -1,49 +1,142 @@
import os
from .connection import get_mongo_client from .connection import get_mongo_client
def insert_rag_documents (documents ,collection_name ="rag_documents",db_name ="vectors_db"): DB_NAME = "ethix_vectors"
client =get_mongo_client () # Default collection name to match Chroma's default
db =client .get_database (db_name ) DEFAULT_COLLECTION_NAME = "rag_documents"
collection =db [collection_name ]
if documents : def get_collection(collection_name=DEFAULT_COLLECTION_NAME):
result =collection .insert_many (documents ) client = get_mongo_client()
return len (result .inserted_ids ) db = client[DB_NAME]
return db[collection_name]
def insert_documents(texts, embeddings, collection_name=DEFAULT_COLLECTION_NAME, metadata_list=None):
collection = get_collection(collection_name)
docs = []
for i, text in enumerate(texts):
doc = {
"text": text,
"embedding": embeddings[i],
"metadata": metadata_list[i] if metadata_list else {}
}
# Flatten metadata for easier filtering if needed, or keep in 'metadata' subdoc
# Atlas Vector Search usually suggests keeping it cleaner, but 'metadata' field is fine
# provided index is on metadata.field
# Add source file to top level for easier deletion
if metadata_list and "source" in metadata_list[i]:
doc["source"] = metadata_list[i]["source"]
docs.append(doc)
if docs:
result = collection.insert_many(docs)
return len(result.inserted_ids)
return 0 return 0
def search_rag_documents (query_embedding ,collection_name ="rag_documents",db_name ="vectors_db",num_results =5 ): def search_documents(query_embedding, collection_name=DEFAULT_COLLECTION_NAME, num_results=5, filter_metadata=None):
client =get_mongo_client () collection = get_collection(collection_name)
db =client .get_database (db_name )
collection =db [collection_name ]
pipeline =[ # Construct Atlas Search Aggregation
{ # Note: 'index' name depends on what user created. Default is often 'default' or 'vector_index'.
"$vectorSearch":{ # We'll use 'vector_index' as a convention.
"index":"vector_index",
"path":"embedding", # Filter construction
"queryVector":query_embedding , pre_filter = None
"numCandidates":num_results *10 , if filter_metadata:
"limit":num_results # Atlas Search filter syntax is different from simple match
# It requires MQL (Mongo Query Language) match or compound operators
# For simplicity in $vectorSearch, strict filters are passed in 'filter' field (if serverless/v2)
# or separate match stage.
# Modern Atlas Vector Search ($vectorSearch) supports 'filter' inside the operator
must_clauses = []
for k, v in filter_metadata.items():
# Assuming filter_metadata matches metadata fields inside 'metadata' object
# or we map specific known fields.
# In Chroma, filters are flat. In insert above, we put them in 'metadata'.
must_clauses.append({
"term": {
"path": f"metadata.{k}",
"value": v
} }
}, })
if must_clauses:
if len(must_clauses) == 1:
pre_filter = must_clauses[0]
else:
pre_filter = {
"compound": {
"filter": must_clauses
}
}
pipeline = [
{ {
"$project":{ "$vectorSearch": {
"_id":0 , "index": "vector_index",
"text":1 , "path": "embedding",
"score":{"$meta":"vectorSearchScore"} "queryVector": query_embedding,
"numCandidates": num_results * 10,
"limit": num_results
} }
} }
] ]
return list (collection .aggregate (pipeline )) # Apply filter if exists
if pre_filter:
pipeline[0]["$vectorSearch"]["filter"] = pre_filter
def is_file_processed (filename ,log_collection ="ingested_files",db_name ="vectors_db"): pipeline.append({
client =get_mongo_client () "$project": {
db =client .get_database (db_name ) "_id": 0,
collection =db [log_collection ] "text": 1,
return collection .find_one ({"filename":filename })is not None "metadata": 1,
"score": {"$meta": "vectorSearchScore"}
}
})
def log_processed_file (filename ,log_collection ="ingested_files",db_name ="vectors_db"): try:
client =get_mongo_client () results = list(collection.aggregate(pipeline))
db =client .get_database (db_name )
collection =db [log_collection ] # Format to match Chroma output style
collection .insert_one ({"filename":filename ,"processed_at":1 }) # Chroma: [{'text': ..., 'score': ..., 'metadata': ...}]
output = []
for doc in results:
output.append({
"text": doc.get("text", ""),
"score": doc.get("score", 0),
"metadata": doc.get("metadata", {})
})
return output
except Exception as e:
print(f"Atlas Vector Search Error: {e}")
# Fallback to empty if index doesn't exist etc
return []
def delete_documents_by_source(source_file, collection_name=DEFAULT_COLLECTION_NAME):
collection = get_collection(collection_name)
# We added 'source' to top level in insert_documents for exactly this efficiency
result = collection.delete_many({"source": source_file})
return result.deleted_count
def get_all_metadatas(collection_name=DEFAULT_COLLECTION_NAME, limit=None):
collection = get_collection(collection_name)
cursor = collection.find({}, {"metadata": 1, "source": 1, "_id": 0})
if limit:
cursor = cursor.limit(limit)
metadatas = []
for doc in cursor:
meta = doc.get("metadata", {})
# Ensure 'source' is in metadata if we pulled it out
if "source" in doc and "source" not in meta:
meta["source"] = doc["source"]
metadatas.append(meta)
return metadatas

View File

@@ -1,5 +1,6 @@
import base64 import base64
import json import json
import os
import re import re
from pathlib import Path from pathlib import Path
from typing import Dict ,List ,Optional ,Union from typing import Dict ,List ,Optional ,Union
@@ -11,8 +12,8 @@ except ImportError :
OLLAMA_AVAILABLE =False OLLAMA_AVAILABLE =False
print ("Ollama not installed. Run: pip install ollama") print ("Ollama not installed. Run: pip install ollama")
DEFAULT_HOST ="https://ollama.sirblob.co" DEFAULT_HOST = os.environ.get("OLLAMA_HOST", "https://ollama.sirblob.co")
DEFAULT_MODEL ="ministral-3:latest" DEFAULT_MODEL = "ministral-3:latest"
DEFAULT_PROMPT ="""Analyze this image and identify ALL logos, brand names, and company names visible. DEFAULT_PROMPT ="""Analyze this image and identify ALL logos, brand names, and company names visible.

View File

@@ -1,8 +1,9 @@
import ollama import ollama
import os import os
client =ollama .Client (host ="https://ollama.sirblob.co") OLLAMA_HOST = os.environ.get("OLLAMA_HOST", "https://ollama.sirblob.co")
DEFAULT_MODEL ="nomic-embed-text:latest" client = ollama.Client(host=OLLAMA_HOST)
DEFAULT_MODEL = "nomic-embed-text:latest"
def get_embedding (text ,model =DEFAULT_MODEL ): def get_embedding (text ,model =DEFAULT_MODEL ):
try : try :

View File

@@ -1,37 +1,69 @@
from flask import Blueprint ,request ,jsonify from flask import Blueprint, request, jsonify
from src .rag .gemeni import GeminiClient from src.rag.gemeni import GeminiClient
from src .gemini import ask_gemini_with_rag from src.gemini import ask_gemini_with_rag
from src.chroma.vector_store import search_documents
from src.rag.embeddings import get_embedding
gemini_bp =Blueprint ('gemini',__name__ ) gemini_bp = Blueprint('gemini', __name__)
brain =None brain = None
def get_brain (): def get_brain():
global brain global brain
if brain is None : if brain is None:
brain =GeminiClient () brain = GeminiClient()
return brain return brain
@gemini_bp .route ('/ask',methods =['POST']) @gemini_bp.route('/ask', methods=['POST'])
def ask (): def ask():
data =request .json data = request.json
prompt =data .get ("prompt") prompt = data.get("prompt")
context =data .get ("context","") context = data.get("context", "")
if not prompt : if not prompt:
return jsonify ({"error":"No prompt provided"}),400 return jsonify({"error": "No prompt provided"}), 400
try : try:
client =get_brain () # Step 1: Retrieve relevant context from ChromaDB (RAG)
response =client .ask (prompt ,context ) print(f"Generating embedding for prompt: {prompt}")
return jsonify ({ query_embedding = get_embedding(prompt)
"status":"success",
"reply":response print("Searching ChromaDB for context...")
search_results = search_documents(query_embedding, num_results=15)
retrieved_context = ""
if search_results:
print(f"Found {len(search_results)} documents.")
retrieved_context = "RELEVANT INFORMATION FROM DATABASE:\n"
for res in search_results:
# Include metadata if useful, e.g. brand name or date
meta = res.get('metadata', {})
source_info = f"[Source: {meta.get('type', 'doc')} - {meta.get('product_name', 'Unknown')}]"
retrieved_context += f"{source_info}\n{res['text']}\n\n"
else:
print("No relevant documents found.")
# Step 2: Combine manual context (if any) with retrieved context
full_context = context
if retrieved_context:
if full_context:
full_context += "\n\n" + retrieved_context
else:
full_context = retrieved_context
# Step 3: Ask Gemini
client = get_brain()
response = client.ask(prompt, full_context)
return jsonify({
"status": "success",
"reply": response
}) })
except Exception as e : except Exception as e:
return jsonify ({ print(f"Error in RAG flow: {e}")
"status":"error", return jsonify({
"message":str (e ) "status": "error",
}),500 "message": str(e)
}), 500
@gemini_bp .route ('/rag',methods =['POST']) @gemini_bp .route ('/rag',methods =['POST'])
def rag (): def rag ():

View File

@@ -306,7 +306,7 @@ This incident has been documented for future reference and to help inform sustai
metadata_list =[metadata ] metadata_list =[metadata ]
) )
print (f"✓ Incident #{incident_id } saved to ChromaDB for AI chat context") print (f"✓ Incident #{incident_id } saved to Vector Store for AI chat context")

View File

@@ -1,14 +1,220 @@
from flask import Blueprint ,jsonify ,request from flask import Blueprint ,jsonify ,request
from src .chroma .vector_store import get_all_metadatas ,search_documents from src .chroma .vector_store import get_all_metadatas ,search_documents, insert_documents
from src .rag .embeddings import get_embedding from src .rag .embeddings import get_embedding, get_embeddings_batch
from src.rag.ingest import load_pdf, chunk_text
import os
import base64
import re
from google import genai
reports_bp =Blueprint ('reports',__name__ ) reports_bp =Blueprint ('reports',__name__ )
# Validation prompt for checking if PDF is a legitimate environmental report
REPORT_VALIDATION_PROMPT = """You are an expert document classifier. Analyze the following text extracted from a PDF and determine if it is a legitimate corporate environmental/sustainability report.
A legitimate report should contain:
1. Company name and branding
2. Environmental metrics (emissions, energy usage, waste, water)
3. Sustainability goals or targets
4. Time periods/years covered
5. Professional report structure
Analyze this text and respond in JSON format:
{
"is_valid_report": true/false,
"confidence": "high"/"medium"/"low",
"company_name": "extracted company name or null",
"report_year": "extracted year or null",
"report_type": "sustainability/environmental/impact/ESG/unknown",
"sector": "Tech/Energy/Automotive/Aerospace/Retail/Manufacturing/Other",
"key_topics": ["list of main topics covered"],
"reasoning": "brief explanation of your assessment"
}
TEXT TO ANALYZE:
"""
def validate_report_with_ai(text_content):
"""Use Gemini to validate if the PDF is a legitimate environmental report."""
try:
# Take first 15000 chars for validation (enough to assess legitimacy)
sample_text = text_content[:15000] if len(text_content) > 15000 else text_content
client = genai.Client(api_key=os.environ.get("GOOGLE_API_KEY"))
response = client.models.generate_content(
model="gemini-2.0-flash",
contents=f"{REPORT_VALIDATION_PROMPT}\n{sample_text}"
)
# Parse the JSON response
response_text = response.text
# Extract JSON from response
import json
json_match = re.search(r'\{[\s\S]*\}', response_text)
if json_match:
return json.loads(json_match.group())
return {"is_valid_report": False, "reasoning": "Could not parse AI response"}
except Exception as e:
print(f"AI validation error: {e}")
return {"is_valid_report": False, "reasoning": str(e)}
@reports_bp.route('/upload', methods=['POST'])
def upload_report():
"""Upload and verify a company environmental report PDF."""
try:
data = request.json
pdf_data = data.get('pdf_data')
company_name = data.get('company_name', '').strip()
if not pdf_data:
return jsonify({
'status': 'error',
'step': 'validation',
'message': 'No PDF data provided'
}), 400
# Step 1: Decode PDF
try:
# Remove data URL prefix if present
if ',' in pdf_data:
pdf_data = pdf_data.split(',')[1]
pdf_bytes = base64.b64decode(pdf_data)
except Exception as e:
return jsonify({
'status': 'error',
'step': 'decode',
'message': f'Failed to decode PDF: {str(e)}'
}), 400
# Step 2: Extract text from PDF
import io
from pypdf import PdfReader
try:
pdf_file = io.BytesIO(pdf_bytes)
reader = PdfReader(pdf_file)
all_text = ""
page_count = len(reader.pages)
for page in reader.pages:
text = page.extract_text()
if text:
all_text += text + "\n\n"
if len(all_text.strip()) < 500:
return jsonify({
'status': 'error',
'step': 'extraction',
'message': 'PDF contains insufficient text content. It may be image-based or corrupted.'
}), 400
except Exception as e:
return jsonify({
'status': 'error',
'step': 'extraction',
'message': f'Failed to extract text from PDF: {str(e)}'
}), 400
# Step 3: Validate with AI
validation = validate_report_with_ai(all_text)
if not validation.get('is_valid_report', False):
return jsonify({
'status': 'rejected',
'step': 'validation',
'message': 'This does not appear to be a legitimate environmental/sustainability report.',
'validation': validation
}), 400
# Step 4: Generate filename
detected_company = validation.get('company_name') or company_name or 'Unknown'
detected_year = validation.get('report_year') or '2024'
report_type = validation.get('report_type', 'sustainability')
sector = validation.get('sector', 'Other')
# Clean company name for filename
safe_company = re.sub(r'[^a-zA-Z0-9]', '-', detected_company).lower()
filename = f"{safe_company}-{detected_year}-{report_type}-report.pdf"
# Step 5: Save PDF to dataset folder
current_dir = os.path.dirname(os.path.abspath(__file__))
dataset_dir = os.path.join(current_dir, '..', '..', 'dataset')
os.makedirs(dataset_dir, exist_ok=True)
file_path = os.path.join(dataset_dir, filename)
# Check if file already exists
if os.path.exists(file_path):
# Add timestamp to make unique
import time
timestamp = int(time.time())
filename = f"{safe_company}-{detected_year}-{report_type}-report-{timestamp}.pdf"
file_path = os.path.join(dataset_dir, filename)
with open(file_path, 'wb') as f:
f.write(pdf_bytes)
# Step 6: Chunk and embed for RAG
chunks = chunk_text(all_text, target_length=2000, overlap=100)
if not chunks:
chunks = [all_text[:4000]]
# Get embeddings in batches
embeddings = get_embeddings_batch(chunks)
# Create metadata for each chunk
metadata_list = []
for i, chunk in enumerate(chunks):
metadata_list.append({
'source': filename,
'company_name': detected_company,
'year': detected_year,
'sector': sector,
'report_type': report_type,
'chunk_index': i,
'total_chunks': len(chunks),
'page_count': page_count,
'type': 'company_report'
})
# Insert into ChromaDB
insert_documents(chunks, embeddings, metadata_list=metadata_list)
return jsonify({
'status': 'success',
'message': 'Report verified and uploaded successfully',
'filename': filename,
'validation': validation,
'stats': {
'page_count': page_count,
'text_length': len(all_text),
'chunks_created': len(chunks),
'company_name': detected_company,
'year': detected_year,
'sector': sector,
'report_type': report_type
}
})
except Exception as e:
print(f"Upload error: {e}")
import traceback
traceback.print_exc()
return jsonify({
'status': 'error',
'step': 'unknown',
'message': str(e)
}), 500
@reports_bp .route ('/',methods =['GET']) @reports_bp .route ('/',methods =['GET'])
def get_reports (): def get_reports ():
try : try :
metadatas =get_all_metadatas () metadatas =get_all_metadatas ()
unique_reports ={} unique_reports ={}
@@ -24,12 +230,6 @@ def get_reports ():
if filename not in unique_reports : if filename not in unique_reports :
company_name ="Unknown" company_name ="Unknown"
year ="N/A" year ="N/A"
sector ="Other" sector ="Other"
@@ -106,15 +306,10 @@ def search_reports ():
try : try :
import re import re
query_embedding =get_embedding (query ) query_embedding =get_embedding (query )
results = search_documents (query_embedding ,num_results =50 )
query_lower = query .lower ()
results =search_documents (query_embedding ,num_results =50 )
query_lower =query .lower ()
def extract_company_info (filename ): def extract_company_info (filename ):
company_name ="Unknown" company_name ="Unknown"
@@ -224,10 +419,55 @@ def view_report_file (filename ):
import os import os
from flask import send_from_directory from flask import send_from_directory
current_dir =os .path .dirname (os .path .abspath (__file__ )) current_dir =os .path .dirname (os .path .abspath (__file__ ))
dataset_dir =os .path .join (current_dir ,'..','..','dataset') dataset_dir =os .path .join (current_dir ,'..','..','dataset')
return send_from_directory (dataset_dir ,filename ) return send_from_directory (dataset_dir ,filename )
@reports_bp.route('/stats', methods=['GET'])
def get_stats():
"""Get real statistics for the landing page"""
try:
# 1. Count Verified Reports (Company Reports)
metadatas = get_all_metadatas()
unique_reports = set()
unique_sectors = set()
for meta in metadatas:
filename = meta.get('source') or meta.get('filename')
# Check if it's a company report (not incident)
if filename and not filename.startswith('incident_') and meta.get('type') != 'incident_report':
unique_reports.add(filename)
# Count sectors
sector = meta.get('sector')
if sector and sector != 'Other':
unique_sectors.add(sector)
verified_reports_count = len(unique_reports)
active_sectors_count = len(unique_sectors)
# Fallback if no specific sectors found but we have reports
if active_sectors_count == 0 and verified_reports_count > 0:
active_sectors_count = 1
# 2. Count Products Scanned / Incidents
from src.mongo.connection import get_mongo_client
client = get_mongo_client()
db = client["ethix"]
# Count all incidents (users scanning products)
incidents_count = db["incidents"].count_documents({})
return jsonify({
"verified_reports": verified_reports_count,
"incidents_reported": incidents_count,
"active_sectors": active_sectors_count
})
except Exception as e:
print(f"Error fetching stats: {e}")
return jsonify({
"verified_reports": 0,
"incidents_reported": 0,
"active_sectors": 0
})

View File

@@ -0,0 +1,51 @@
import os
import sys
from datetime import datetime
# Add the backend directory to sys.path so we can import src modules
sys.path.append(os.path.join(os.path.dirname(__file__), '../../'))
from src.mongo.connection import get_mongo_client
from src.routes.incidents import save_to_chromadb
def sync_incidents():
print("Starting sync of incidents from MongoDB to ChromaDB...")
client = get_mongo_client()
db = client["ethix"]
collection = db["incidents"]
# Find all confirmed greenwashing incidents
cursor = collection.find({"is_greenwashing": True})
count = 0
synced = 0
errors = 0
for incident in cursor:
count += 1
incident_id = str(incident["_id"])
product_name = incident.get("product_name", "Unknown")
print(f"Processing Incident #{incident_id} ({product_name})...")
try:
# Convert ObjectId to string for the function if needed,
# but save_to_chromadb expects the dict and the id string.
# Convert _id in dict to string just in case
incident["_id"] = incident_id
save_to_chromadb(incident, incident_id)
synced += 1
print(f" -> Successfully synced.")
except Exception as e:
errors += 1
print(f" -> FAILED to sync: {e}")
print(f"\nSync Complete.")
print(f"Total processed: {count}")
print(f"Successfully synced: {synced}")
print(f"Errors: {errors}")
if __name__ == "__main__":
sync_incidents()

View File

@@ -1,7 +1,112 @@
# Tauri + SvelteKit + TypeScript # Ethix Frontend
This template should help get you started developing with Tauri, SvelteKit and TypeScript in Vite. A SvelteKit web application for the Ethix greenwashing detection platform. Scan products, report misleading environmental claims, and chat with an AI assistant about sustainability.
## Recommended IDE Setup ## Technology Stack
[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer). | Component | Technology |
|-----------|------------|
| Framework | SvelteKit |
| UI Library | Svelte 5 |
| Language | TypeScript |
| Styling | TailwindCSS 4 |
| Build Tool | Vite |
| Desktop App | Tauri |
| Icons | Iconify |
| 3D Graphics | Three.js |
| Markdown | marked |
## Prerequisites
- Node.js 18+ or Bun
- Backend server running on `http://localhost:5000`
## Installation
Using Bun (recommended):
```bash
bun install
```
Or using npm:
```bash
npm install
```
## Development
Start the development server:
```bash
bun run dev
# or
npm run dev
```
The application will be available at `http://localhost:5173`.
## Building
### Web Build
```bash
bun run build
# or
npm run build
```
Preview the production build:
```bash
bun run preview
# or
npm run preview
```
### Desktop Build (Tauri)
```bash
bun run tauri build
# or
npm run tauri build
```
## Features
### Home Page
- Responsive design with separate mobile and web layouts
- Animated parallax landscape background
- Quick access to all main features
### AI Chat Assistant
- Powered by Google Gemini with RAG context
- Real-time conversation interface
- Sustainability and greenwashing expertise
- Message history within session
### Greenwashing Report Submission
- Two report types: Product Incident or Company Report
- Image upload for product evidence
- PDF upload for company sustainability reports
- Real-time analysis progress indicator
- Structured verdict display with confidence levels
### Catalogue Browser
- Browse company sustainability reports
- View user-submitted incidents
- Category filtering (Tech, Energy, Automotive, etc.)
- Semantic search functionality
- Pagination for large datasets
- Detailed modal views for reports and incidents
### Product Scanner
- Camera integration for scanning product labels
- Brand/logo detection via AI vision
- Direct report submission from scan results

View File

@@ -3,7 +3,6 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head% %sveltekit.head%
</head> </head>

View File

@@ -11,7 +11,7 @@
const tabs = [ const tabs = [
{ {
name: "Goals", name: "Goals",
route: "/community", route: "/goal",
icon: "ri:flag-fill", icon: "ri:flag-fill",
activeColor: "#34d399", activeColor: "#34d399",
}, },

View File

@@ -43,7 +43,11 @@
<div class="header"> <div class="header">
<div class="header-left"> <div class="header-left">
<div class="logo-icon"> <div class="logo-icon">
<Icon icon="ri:leaf-fill" width="24" /> <img
src="/ethix-logo.png"
alt="Ethix Logo"
class="logo-img"
/>
</div> </div>
<div> <div>
<h1 class="app-name">Ethix</h1> <h1 class="app-name">Ethix</h1>
@@ -213,13 +217,17 @@
.logo-icon { .logo-icon {
width: 48px; width: 48px;
height: 48px; height: 48px;
background: linear-gradient(135deg, #4ade80, #22c55e);
border-radius: 16px; border-radius: 16px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
color: #052e16; overflow: hidden;
box-shadow: 0 4px 16px rgba(74, 222, 128, 0.4); }
.logo-img {
width: 100%;
height: 100%;
object-fit: contain;
} }
.app-name { .app-name {

View File

@@ -71,10 +71,10 @@
<header class="header"> <header class="header">
<div class="header-left"> <div class="header-left">
<div class="avatar"> <div class="avatar">
<Icon <img
icon="ri:seedling-fill" src="/ethix-logo.png"
width="24" alt="Ethix Logo"
style="color: #34d399;" class="avatar-logo"
/> />
</div> </div>
<div class="header-text"> <div class="header-text">
@@ -222,6 +222,13 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
overflow: hidden;
}
.avatar-logo {
width: 36px;
height: 36px;
object-fit: contain;
} }
.header-text { .header-text {

View File

@@ -1,202 +1,132 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
import { fly, fade } from "svelte/transition";
import ParallaxLandscape from "$lib/components/ParallaxLandscape.svelte"; import ParallaxLandscape from "$lib/components/ParallaxLandscape.svelte";
import Icon from "@iconify/svelte";
interface Props { interface Props {
onProgressChange?: (progress: number) => void; onProgressChange?: (progress: number) => void;
} }
let { onProgressChange }: Props = $props(); let { onProgressChange }: Props = $props();
const news = [
{
id: 1,
title: "Ocean Cleanup Hits 500 Tons",
desc: "Major milestone reached in the Pacific cleanup initiative.",
date: "2h ago",
icon: "ri:ship-line",
},
{
id: 2,
title: "Plastic Ban Starts Today",
desc: "Big cities are saying no to single-use plastics.",
date: "5h ago",
icon: "ri:prohibited-line",
},
{
id: 3,
title: "Ethix Launches Globally",
desc: "Our platform is now available worldwide.",
date: "1d ago",
icon: "ri:global-line",
},
];
const features = [
{
title: "Scan It",
desc: "Point your camera at any product.",
icon: "ri:scan-2-line",
},
{
title: "Get Real Info",
desc: "AI breaks down the real impact.",
icon: "ri:information-line",
},
{
title: "Find Better",
desc: "See eco-friendly swaps instantly.",
icon: "ri:leaf-line",
},
{
title: "Call It Out",
desc: "Report misleading green claims.",
icon: "ri:megaphone-line",
},
];
const stats = [
{ value: "50K+", label: "Scans" },
{ value: "12K", label: "Users" },
{ value: "98%", label: "Accuracy" },
{ value: "24/7", label: "Support" },
];
let sceneProgress = $state(0); let sceneProgress = $state(0);
let isEcoTheme = $derived(sceneProgress < 0.5); let statsData = $state({
verified_reports: 0,
incidents_reported: 0,
active_sectors: 0,
});
async function fetchStats() {
try {
const res = await fetch("http://localhost:5000/api/reports/stats");
const data = await res.json();
statsData = data;
} catch (e) {
console.error("Failed to fetch stats", e);
}
}
onMount(() => {
fetchStats();
});
function handleProgressChange(progress: number) { function handleProgressChange(progress: number) {
sceneProgress = progress; sceneProgress = progress;
onProgressChange?.(progress); onProgressChange?.(progress);
} }
let scoreIndex = $state(0); const features = [
const scores = [
{ {
label: "Fiji Water", icon: "ri:camera-lens-line",
score: "94/100", title: "Scan Products",
color: "#1ed760", desc: "Point your camera at any product to analyze environmental claims.",
image: "/water-bottle.png",
scale: 0.7,
}, },
{ {
label: "Plastic Bag", icon: "ri:chat-3-line",
score: "12/100", title: "Ask Questions",
color: "#e91429", desc: "Chat with our AI about sustainability and eco-friendly alternatives.",
image: "/plastic-bag.png",
scale: 0.75,
}, },
{ {
label: "Starbucks", icon: "ri:alarm-warning-line",
score: "65/100", title: "Report Issues",
color: "#f59b23", desc: "Submit products or companies you suspect of greenwashing.",
image: "/coffee-cup.png", },
scale: 1, {
icon: "ri:folder-open-line",
title: "Browse Reports",
desc: "Explore verified sustainability reports and community findings.",
}, },
]; ];
onMount(() => { let stats = $derived([
const interval = setInterval(() => { {
scoreIndex = (scoreIndex + 1) % scores.length; value: statsData.incidents_reported.toString(),
}, 4000); label: "Products Scanned",
return () => clearInterval(interval); },
}); {
value: statsData.verified_reports.toString(),
label: "Verified Reports",
},
{
value: statsData.active_sectors.toString(),
label: "Data Sectors",
},
]);
</script> </script>
<div class="web-home" class:industrial-theme={!isEcoTheme}> <div class="web-home">
<ParallaxLandscape onProgressChange={handleProgressChange} /> <ParallaxLandscape onProgressChange={handleProgressChange} />
<section class="hero"> <section class="hero">
<div class="glass-card hero-content"> <div class="hero-content">
<div class="hero-badge"> <span class="hero-badge">
<iconify-icon icon="ri:eye-line" width="16"></iconify-icon> <Icon icon="ri:leaf-line" width="16" />
<span>See the real impact</span> Sustainability Made Simple
</div> </span>
<h1 class="hero-title"> <h1 class="hero-title">
Know What <br /> You Buy. Know What<br />You're Buying
</h1> </h1>
<p class="hero-desc"> <p class="hero-desc">
Scan a product. See if it's actually good for the planet. Find Scan products, detect greenwashing, and make informed choices.
better alternatives if it's not. Simple as that. Ethix helps you see through misleading environmental claims.
</p> </p>
<div class="hero-actions"> <div class="hero-actions">
<a href="/catalogue" class="cta-primary"> <a href="/catalogue" class="btn-primary">
<iconify-icon icon="ri:store-2-fill" width="20" <Icon icon="ri:search-line" width="18" />
></iconify-icon> Browse Catalogue
<span>Browse Database</span> </a>
<a href="/chat" class="btn-secondary">
<Icon icon="ri:chat-3-line" width="18" />
Chat with AI
</a> </a>
</div>
</div>
<div class="hero-visual">
<div class="visual-container">
<div class="hero-image">
{#key scoreIndex}
<div
class="product-wrapper"
in:fly={{ x: 100, duration: 500, opacity: 0 }}
out:fly={{ x: -100, duration: 500, opacity: 0 }}
>
<img
src={scores[scoreIndex].image}
alt={scores[scoreIndex].label}
class="product-image"
style="transform: scale({scores[scoreIndex]
.scale});"
/>
</div>
{/key}
</div>
<div class="orbit orbit-1"></div>
<div class="orbit orbit-2"></div>
<div class="floating-card glass-card">
{#key scoreIndex}
<div class="score-content" in:fade={{ duration: 300 }}>
<iconify-icon
icon="ri:checkbox-circle-fill"
width="24"
style="color: {scores[scoreIndex].color};"
></iconify-icon>
<div>
<div class="card-label">
{scores[scoreIndex].label}
</div>
<div class="card-value">
{scores[scoreIndex].score}
</div>
</div>
</div>
{/key}
</div>
</div> </div>
</div> </div>
</section> </section>
<div class="spacer"></div> <div class="spacer"></div>
<section class="content-section"> <section class="stats-section">
<div class="glass-card stats-grid"> <div class="stats-card">
{#each stats as stat} {#each stats as stat}
<div class="stat-item"> <div class="stat-item">
<div class="stat-value">{stat.value}</div> <span class="stat-value">{stat.value}</span>
<div class="stat-label">{stat.label}</div> <span class="stat-label">{stat.label}</span>
</div> </div>
{/each} {/each}
</div> </div>
</section> </section>
<section class="content-section"> <section class="features-section">
<div class="section-header">
<h2 class="section-title">How It Works</h2> <h2 class="section-title">How It Works</h2>
<p class="section-desc">Tools to help you shop smarter.</p> <p class="section-desc">Simple tools to help you shop smarter</p>
</div>
<div class="features-grid"> <div class="features-grid">
{#each features as feature} {#each features as feature}
<div class="glass-card feature-card"> <div class="feature-card">
<div class="feature-icon"> <div class="feature-icon">
<iconify-icon icon={feature.icon} width="24" <Icon icon={feature.icon} width="24" />
></iconify-icon>
</div> </div>
<h3 class="feature-title">{feature.title}</h3> <h3 class="feature-title">{feature.title}</h3>
<p class="feature-desc">{feature.desc}</p> <p class="feature-desc">{feature.desc}</p>
@@ -205,30 +135,41 @@
</div> </div>
</section> </section>
<section class="content-section"> <section class="info-section">
<div class="section-header"> <div class="info-card">
<h2 class="section-title">Latest News</h2> <div class="info-header">
<p class="section-desc"> <Icon
Updates from the world of sustainability. icon="ri:error-warning-line"
width="20"
class="info-icon"
/>
<span>What is Greenwashing?</span>
</div>
<p class="info-text">
Greenwashing is when companies make misleading claims about how
environmentally friendly their products are. Common tactics
include vague terms like "eco-friendly" without proof,
highlighting minor green features while hiding major harms, and
using green imagery without substance.
</p> </p>
</div> <a href="/report" class="info-link">
<div class="news-grid"> Report suspicious claims
{#each news as item (item.id)} <Icon icon="ri:arrow-right-line" width="16" />
<article class="glass-card news-card">
<div class="news-icon">
<iconify-icon icon={item.icon} width="24"
></iconify-icon>
</div>
<div class="news-meta">{item.date}</div>
<h3 class="news-title">{item.title}</h3>
<p class="news-desc">{item.desc}</p>
<a href="/news/{item.id}" class="news-link">
Read more
<iconify-icon icon="ri:arrow-right-line" width="16"
></iconify-icon>
</a> </a>
</article> </div>
{/each} </section>
<section class="cta-section">
<h2 class="cta-title">Ready to start?</h2>
<p class="cta-desc">
Every scan helps build a more transparent marketplace.
</p>
<div class="cta-actions">
<a href="/report" class="btn-primary">
<Icon icon="ri:shield-check-line" width="18" />
Submit a Report
</a>
<a href="/catalogue" class="btn-secondary"> View All Reports </a>
</div> </div>
</section> </section>
@@ -243,255 +184,121 @@
z-index: 10; z-index: 10;
} }
.glass-card {
background: rgba(0, 0, 0, 0.35);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 24px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
transition: all 0.4s ease;
}
.glass-card:hover {
background: rgba(0, 0, 0, 0.45);
border-color: rgba(255, 255, 255, 0.15);
}
.hero { .hero {
min-height: 100vh; min-height: 100vh;
display: grid; display: flex;
grid-template-columns: 1fr 1fr;
gap: 60px;
align-items: center; align-items: center;
padding: 120px 60px; padding: 120px 60px 100px;
max-width: 1400px; max-width: 1200px;
margin: 0 auto; margin: 0 auto;
} }
.hero-content { .hero-content {
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(16px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 24px;
padding: 48px; padding: 48px;
max-width: 600px; max-width: 550px;
} }
.hero-badge { .hero-badge {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
background: rgba(16, 185, 129, 0.1); background: rgba(34, 197, 94, 0.1);
color: #34d399; color: #4ade80;
padding: 10px 20px; padding: 8px 16px;
border-radius: 50px; border-radius: 50px;
font-size: 13px; font-size: 13px;
font-weight: 700; font-weight: 600;
margin-bottom: 24px; margin-bottom: 24px;
border: 1px solid rgba(16, 185, 129, 0.2); border: 1px solid rgba(34, 197, 94, 0.2);
text-transform: uppercase;
letter-spacing: 1.5px;
} }
.hero-title { .hero-title {
font-size: 72px; font-size: 56px;
font-weight: 900; font-weight: 900;
line-height: 1.05; line-height: 1.1;
color: white; color: white;
margin: 0 0 28px 0; margin: 0 0 20px 0;
letter-spacing: -2px; letter-spacing: -1px;
} }
.hero-desc { .hero-desc {
font-size: 18px; font-size: 17px;
line-height: 1.7; line-height: 1.7;
color: #d1d5db; color: #d1d5db;
margin: 0 0 36px 0; margin: 0 0 32px 0;
} }
.hero-actions { .hero-actions {
display: flex; display: flex;
gap: 16px;
}
.cta-primary {
display: flex;
align-items: center;
gap: 12px; gap: 12px;
padding: 18px 36px; flex-wrap: wrap;
}
.btn-primary {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 14px 28px;
border-radius: 50px; border-radius: 50px;
font-size: 15px; font-size: 14px;
font-weight: 700;
cursor: pointer;
border: none;
background: #10b981;
color: white;
text-transform: uppercase;
letter-spacing: 1px;
text-decoration: none;
box-shadow: 0 8px 24px rgba(16, 185, 129, 0.25);
transition: all 0.3s ease;
}
.cta-primary:hover {
transform: translateY(-3px);
box-shadow: 0 16px 40px rgba(16, 185, 129, 0.35);
background: #059669;
}
.hero-visual {
position: relative;
height: 500px;
display: flex;
align-items: center;
justify-content: center;
}
.visual-container {
position: relative;
width: 420px;
height: 420px;
display: flex;
align-items: center;
justify-content: center;
}
.hero-image {
width: 360px;
height: 360px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
position: relative;
z-index: 2;
overflow: hidden;
}
.product-wrapper {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
.product-image {
width: 300px;
height: 300px;
object-fit: contain;
filter: drop-shadow(0 10px 30px rgba(0, 0, 0, 0.3));
}
.orbit {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 50%;
z-index: 1;
animation: spin 25s linear infinite;
}
.orbit-1 {
width: 340px;
height: 340px;
}
.orbit-2 {
width: 480px;
height: 480px;
opacity: 0.4;
animation-duration: 45s;
animation-direction: reverse;
}
@keyframes spin {
from {
transform: translate(-50%, -50%) rotate(0deg);
}
to {
transform: translate(-50%, -50%) rotate(360deg);
}
}
.floating-card {
position: absolute;
right: -20px;
bottom: 80px;
padding: 16px 24px;
min-width: 200px;
z-index: 10;
animation: float 4s ease-in-out infinite;
background: rgba(5, 31, 24, 0.8);
border: 1px solid rgba(52, 211, 153, 0.2);
}
@keyframes float {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-12px);
}
}
.score-content {
display: flex;
align-items: center;
gap: 14px;
}
.card-label {
font-size: 11px;
color: #9ca3af;
font-weight: 600; font-weight: 600;
text-transform: uppercase; background: #22c55e;
letter-spacing: 1px; color: white;
text-decoration: none;
transition: all 0.2s;
} }
.card-value { .btn-primary:hover {
font-size: 22px; background: #16a34a;
transform: translateY(-2px);
}
.btn-secondary {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 14px 28px;
border-radius: 50px;
font-size: 14px;
font-weight: 600;
background: rgba(255, 255, 255, 0.1);
color: white; color: white;
font-weight: 800; text-decoration: none;
border: 1px solid rgba(255, 255, 255, 0.15);
transition: all 0.2s;
}
.btn-secondary:hover {
background: rgba(255, 255, 255, 0.15);
} }
.spacer { .spacer {
height: 25vh; height: 20vh;
} }
.footer-spacer { .footer-spacer {
height: 15vh; height: 10vh;
} }
.content-section { .stats-section {
max-width: 1200px; max-width: 900px;
margin: 0 auto; margin: 0 auto;
padding: 60px 40px; padding: 0 40px 60px;
} }
.section-header { .stats-card {
text-align: center; background: rgba(0, 0, 0, 0.5);
margin-bottom: 48px; backdrop-filter: blur(16px);
} border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 20px;
.section-title {
font-size: 42px;
font-weight: 800;
color: white;
margin: 0 0 12px 0;
letter-spacing: -1px;
}
.section-desc {
font-size: 18px;
color: #d1d5db;
margin: 0;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 32px;
padding: 40px; padding: 40px;
display: flex;
justify-content: space-around;
gap: 40px;
} }
.stat-item { .stat-item {
@@ -499,152 +306,184 @@
} }
.stat-value { .stat-value {
font-size: 48px; display: block;
font-weight: 900; font-size: 42px;
color: #34d399; font-weight: 800;
margin-bottom: 4px; color: #4ade80;
letter-spacing: -1px; letter-spacing: -1px;
} }
.stat-label { .stat-label {
font-size: 14px; font-size: 14px;
color: #9ca3af; color: #9ca3af;
font-weight: 600; font-weight: 500;
text-transform: uppercase; }
letter-spacing: 1px;
.features-section {
max-width: 1000px;
margin: 0 auto;
padding: 60px 40px;
text-align: center;
}
.section-title {
font-size: 36px;
font-weight: 800;
color: white;
margin: 0 0 8px 0;
}
.section-desc {
font-size: 16px;
color: #9ca3af;
margin: 0 0 40px 0;
} }
.features-grid { .features-grid {
display: grid; display: grid;
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(4, 1fr);
gap: 24px; gap: 20px;
} }
.feature-card { .feature-card {
padding: 32px 24px; background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(16px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 20px;
padding: 28px 20px;
text-align: center; text-align: center;
transition: border-color 0.2s;
}
.feature-card:hover {
border-color: rgba(34, 197, 94, 0.3);
} }
.feature-icon { .feature-icon {
width: 56px; width: 48px;
height: 56px; height: 48px;
background: rgba(16, 185, 129, 0.1); background: rgba(34, 197, 94, 0.1);
border-radius: 16px; border-radius: 14px;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
color: #34d399; color: #4ade80;
margin-bottom: 20px; margin-bottom: 16px;
} }
.feature-title { .feature-title {
font-size: 18px; font-size: 16px;
font-weight: 700; font-weight: 700;
color: white; color: white;
margin: 0 0 10px 0; margin: 0 0 8px 0;
} }
.feature-desc { .feature-desc {
font-size: 14px; font-size: 13px;
color: #9ca3af; color: #9ca3af;
line-height: 1.6; line-height: 1.5;
margin: 0; margin: 0;
} }
.news-grid { .info-section {
display: grid; max-width: 800px;
grid-template-columns: repeat(3, 1fr); margin: 0 auto;
gap: 28px; padding: 40px 40px 60px;
} }
.news-card { .info-card {
padding: 28px; background: rgba(0, 0, 0, 0.5);
display: flex; backdrop-filter: blur(16px);
flex-direction: column; border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 20px;
padding: 32px;
} }
.news-icon { .info-header {
width: 56px;
height: 56px;
background: rgba(16, 185, 129, 0.1);
border-radius: 14px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; gap: 10px;
color: #34d399; font-size: 18px;
margin-bottom: 20px;
}
.news-meta {
font-size: 12px;
color: #34d399;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 10px;
}
.news-title {
font-size: 20px;
font-weight: 700; font-weight: 700;
color: white; color: white;
margin: 0 0 10px 0; margin-bottom: 16px;
line-height: 1.3;
} }
.news-desc { .info-text {
font-size: 14px; font-size: 15px;
color: #d1d5db; color: #d1d5db;
line-height: 1.6; line-height: 1.7;
flex-grow: 1;
margin: 0 0 20px 0; margin: 0 0 20px 0;
} }
.news-link { .info-link {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 8px; gap: 6px;
font-size: 13px; color: #4ade80;
font-size: 14px;
font-weight: 600; font-weight: 600;
color: #34d399;
text-decoration: none; text-decoration: none;
transition: all 0.2s ease; transition: gap 0.2s;
} }
.news-link:hover { .info-link:hover {
gap: 12px; gap: 10px;
color: #6ee7b7;
} }
@media (max-width: 1024px) { .cta-section {
.hero { max-width: 600px;
grid-template-columns: 1fr; margin: 0 auto;
gap: 40px; padding: 60px 40px;
padding: 100px 30px;
}
.hero-content {
max-width: 100%;
text-align: center; text-align: center;
} }
.hero-title { .cta-title {
font-size: 52px; font-size: 32px;
font-weight: 800;
color: white;
margin: 0 0 12px 0;
} }
.hero-actions {
.cta-desc {
font-size: 16px;
color: #9ca3af;
margin: 0 0 28px 0;
}
.cta-actions {
display: flex;
justify-content: center; justify-content: center;
gap: 12px;
flex-wrap: wrap;
} }
.hero-visual {
height: 400px; @media (max-width: 900px) {
}
.features-grid { .features-grid {
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
} }
.news-grid {
grid-template-columns: 1fr;
} }
.stats-grid {
grid-template-columns: repeat(2, 1fr); @media (max-width: 768px) {
.hero {
padding: 80px 20px;
}
.hero-content {
padding: 32px;
}
.hero-title {
font-size: 40px;
}
.stats-card {
flex-direction: column;
gap: 24px;
}
.features-grid {
grid-template-columns: 1fr;
} }
} }
</style> </style>

View File

@@ -24,16 +24,15 @@
</script> </script>
<div <div
class="bg-black/60 backdrop-blur-2xl border border-white/15 rounded-4xl p-12 lg:p-14 mb-12 shadow-2xl shadow-black/50" class="bg-black/40 backdrop-blur-2xl border border-white/10 rounded-4xl p-12 lg:p-14 mb-12 shadow-2xl shadow-black/30"
> >
<div class="mb-10 px-4 text-center"> <div class="mb-10 px-4 text-center">
<h1 <h1
class="text-white text-[42px] lg:text-[48px] font-black m-0 tracking-[-2px]" class="text-white text-[42px] lg:text-[48px] font-black m-0 tracking-[-2px]"
> >
Sustainability Database Sustainability Database
</h1> </h1>
<p class="text-white/80 text-base lg:text-lg mt-3 font-medium"> <p class="text-white/90 text-base lg:text-lg mt-3 font-medium">
{#if viewMode === "company"} {#if viewMode === "company"}
Search within verified company reports and impact assessments Search within verified company reports and impact assessments
{:else} {:else}
@@ -42,19 +41,18 @@
</p> </p>
</div> </div>
<div class="flex items-center justify-center gap-5 my-8"> <div class="flex items-center justify-center gap-5 my-8">
<span <span
class="flex items-center gap-2 text-sm lg:text-base font-semibold transition-all duration-300 {viewMode === class="flex items-center gap-2 text-sm lg:text-base font-semibold transition-all duration-300 {viewMode ===
'company' 'company'
? 'text-emerald-400' ? 'text-emerald-400'
: 'text-white/50'}" : 'text-white/60'}"
> >
<Icon icon="ri:building-2-line" width="16" /> <Icon icon="ri:building-2-line" width="16" />
Company Company
</span> </span>
<button <button
class="relative w-16 h-8 bg-white/10 border-none rounded-full cursor-pointer transition-all duration-300 p-0 hover:bg-white/15 shadow-inner" class="relative w-16 h-8 bg-white/10 border-none rounded-full cursor-pointer transition-all duration-300 p-0 hover:bg-white/20 shadow-inner"
onclick={() => onclick={() =>
(viewMode = viewMode === "company" ? "user" : "company")} (viewMode = viewMode === "company" ? "user" : "company")}
aria-label="Toggle between company and user reports" aria-label="Toggle between company and user reports"
@@ -70,7 +68,7 @@
class="flex items-center gap-2 text-sm lg:text-base font-semibold transition-all duration-300 {viewMode === class="flex items-center gap-2 text-sm lg:text-base font-semibold transition-all duration-300 {viewMode ===
'user' 'user'
? 'text-emerald-400' ? 'text-emerald-400'
: 'text-white/50'}" : 'text-white/60'}"
> >
<Icon icon="ri:user-voice-line" width="16" /> <Icon icon="ri:user-voice-line" width="16" />
User Reports User Reports
@@ -86,7 +84,7 @@
</div> </div>
<input <input
type="text" type="text"
class="w-full bg-black/30 border border-white/15 rounded-full py-4 lg:py-5 pl-14 pr-6 text-white text-base lg:text-lg font-medium outline-none transition-all duration-200 focus:bg-black/40 focus:border-emerald-400 placeholder:text-white/50 shadow-inner" class="w-full bg-white/10 border border-white/10 rounded-full py-4 lg:py-5 pl-14 pr-6 text-white text-base lg:text-lg font-medium outline-none transition-all duration-200 focus:bg-white/15 focus:border-emerald-400 placeholder:text-white/40 shadow-inner"
placeholder="Search for companies, topics (e.g., 'emissions')..." placeholder="Search for companies, topics (e.g., 'emissions')..."
bind:value={searchQuery} bind:value={searchQuery}
oninput={onSearchInput} oninput={onSearchInput}
@@ -99,7 +97,7 @@
class="px-7 py-3 rounded-full text-sm lg:text-base font-semibold cursor-pointer transition-all duration-200 border {selectedCategory === class="px-7 py-3 rounded-full text-sm lg:text-base font-semibold cursor-pointer transition-all duration-200 border {selectedCategory ===
category category
? 'bg-emerald-500 border-emerald-500 text-emerald-950 shadow-[0_4px_20px_rgba(34,197,94,0.4)]' ? 'bg-emerald-500 border-emerald-500 text-emerald-950 shadow-[0_4px_20px_rgba(34,197,94,0.4)]'
: 'bg-black/30 backdrop-blur-sm border-white/15 text-white/70 hover:bg-black/40 hover:text-white hover:-translate-y-0.5 hover:shadow-lg'}" : 'bg-white/10 backdrop-blur-sm border-white/10 text-white/80 hover:bg-white/20 hover:text-white hover:-translate-y-0.5 hover:shadow-lg'}"
onclick={() => (selectedCategory = category)} onclick={() => (selectedCategory = category)}
> >
{category} {category}

View File

@@ -43,57 +43,63 @@
</script> </script>
<button <button
class="group flex items-center gap-6 lg:gap-8 bg-black/60 backdrop-blur-2xl border border-white/15 rounded-3xl p-7 lg:p-8 w-full text-left cursor-pointer transition-all duration-300 hover:bg-black/70 hover:border-emerald-500/50 hover:-translate-y-1 hover:scale-[1.01] hover:shadow-2xl hover:shadow-black/50 relative overflow-hidden outline-none" class="group flex items-center gap-6 lg:gap-8 bg-black/40 backdrop-blur-2xl border border-white/10 rounded-3xl p-7 lg:p-8 w-full text-left cursor-pointer transition-all duration-300 hover:bg-black/50 hover:border-emerald-500/40 hover:-translate-y-1 hover:scale-[1.005] hover:shadow-xl hover:shadow-black/30 relative overflow-hidden outline-none"
onclick={() => openReport(report)} onclick={() => openReport(report)}
> >
<div <div
class="w-16 h-16 lg:w-[4.375rem] lg:h-[4.375rem] bg-emerald-500/15 backdrop-blur-sm rounded-[1.125rem] flex items-center justify-center shrink-0 transition-all duration-300 group-hover:bg-emerald-500 group-hover:-rotate-6 group-hover:scale-110" class="w-16 h-16 lg:w-[4.375rem] lg:h-[4.375rem] bg-emerald-500/10 backdrop-blur-sm rounded-[1.125rem] flex items-center justify-center shrink-0 transition-all duration-300 group-hover:bg-emerald-500 group-hover:-rotate-3 group-hover:scale-105 border border-white/5 group-hover:border-emerald-400/50"
> >
<Icon icon={fileDetails.icon} width="32" class="text-white" /> <Icon
icon={fileDetails.icon}
width="32"
class="text-emerald-100 group-hover:text-white transition-colors"
/>
</div> </div>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div class="flex items-center gap-3 mb-2.5"> <div class="flex items-center gap-3 mb-2.5">
<h3 class="text-white text-xl lg:text-[22px] font-extrabold m-0"> <h3
class="text-white text-xl lg:text-[22px] font-bold m-0 tracking-tight"
>
{report.company_name} {report.company_name}
</h3> </h3>
<span <span
class="text-emerald-300 bg-emerald-500/15 backdrop-blur-sm px-3 py-1 rounded-lg text-[12px] lg:text-[13px] font-bold" class="text-emerald-300 bg-emerald-500/15 backdrop-blur-sm px-3 py-1 rounded-lg text-[12px] lg:text-[13px] font-bold border border-emerald-500/10"
>{report.year}</span >{report.year}</span
> >
</div> </div>
{#if report.snippet} {#if report.snippet}
<p <p
class="text-white/70 text-sm lg:text-base leading-relaxed mb-4 line-clamp-2 m-0" class="text-white/80 text-sm lg:text-base leading-relaxed mb-4 line-clamp-2 m-0 font-medium"
> >
{@html report.snippet.replace( {@html report.snippet.replace(
new RegExp(searchQuery || "", "gi"), new RegExp(searchQuery || "", "gi"),
(match) => (match) =>
`<span class="text-emerald-300 bg-emerald-500/25 px-1 rounded font-semibold">${match}</span>`, `<span class="text-emerald-200 bg-emerald-500/30 px-1 rounded font-bold">${match}</span>`,
)} )}
</p> </p>
{:else} {:else}
<p class="text-white/50 text-sm lg:text-base mb-4 m-0"> <p class="text-white/60 text-sm lg:text-base mb-4 m-0 font-medium">
{report.sector} Sector • Impact Report {report.sector} Sector • Impact Report
</p> </p>
{/if} {/if}
<div class="flex flex-wrap gap-2.5"> <div class="flex flex-wrap gap-2.5">
<span <span
class="flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-[11px] lg:text-[12px] font-bold tracking-tight bg-white/10 backdrop-blur-sm text-white/70 max-w-xs truncate" class="flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-[11px] lg:text-[12px] font-bold tracking-tight bg-white/5 backdrop-blur-sm text-white/70 max-w-xs truncate border border-white/5"
title={report.filename} title={report.filename}
> >
<Icon icon={fileDetails.icon} width="15" /> <Icon icon={fileDetails.icon} width="15" />
{fileDetails.type} {fileDetails.type}
</span> </span>
<span <span
class="flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-[11px] lg:text-[12px] font-bold tracking-tight bg-emerald-500/15 backdrop-blur-sm text-emerald-300" class="flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-[11px] lg:text-[12px] font-bold tracking-tight bg-emerald-500/10 backdrop-blur-sm text-emerald-300 border border-emerald-500/10"
> >
<Icon <Icon
icon="ri:checkbox-circle-fill" icon="ri:checkbox-circle-fill"
width="14" width="14"
class="text-emerald-500" class="text-emerald-400"
/> />
Analyzed Analyzed
</span> </span>
@@ -101,18 +107,21 @@
</div> </div>
{#if report.greenwashing_score} {#if report.greenwashing_score}
<div class="text-center ml-5 lg:ml-6">
<div <div
class="w-14 h-14 lg:w-[3.875rem] lg:h-[3.875rem] rounded-xe flex items-center justify-center mb-2 rounded-2xl shadow-lg transition-transform group-hover:scale-110 {getScoreColor( class="text-center ml-5 lg:ml-6 group-hover:translate-x-1 transition-transform duration-300"
report.greenwashing_score,
)}"
> >
<span class="text-emerald-950 text-[19px] lg:text-[21px] font-black" <div
class="w-14 h-14 lg:w-[3.875rem] lg:h-[3.875rem] rounded-xe flex items-center justify-center mb-2 rounded-2xl shadow-lg {getScoreColor(
report.greenwashing_score,
)} ring-4 ring-black/20"
>
<span
class="text-emerald-950 text-[19px] lg:text-[21px] font-black"
>{Math.round(Number(report.greenwashing_score))}</span >{Math.round(Number(report.greenwashing_score))}</span
> >
</div> </div>
<span <span
class="text-white/50 text-[10px] lg:text-[11px] font-extrabold uppercase tracking-widest" class="text-white/60 text-[10px] lg:text-[11px] font-bold uppercase tracking-widest"
>Trust Score</span >Trust Score</span
> >
</div> </div>

View File

@@ -49,16 +49,42 @@ export function drawLandscape(
} }
if (sceneType === 'transition') { if (sceneType === 'transition') {
const ecoOpacity = 1 - state.progress; const p = state.progress;
const industrialOpacity = state.progress;
if (ecoOpacity > 0.1) { // Eco fades out quickly: 1.0 -> 0.0 from progress 0.0 to 0.45
const ecoOpacity = Math.max(0, 1 - (p / 0.45));
// Industrial fades in later: 0.0 -> 1.0 from progress 0.55 to 1.0
const industrialOpacity = Math.max(0, (p - 0.55) / 0.45);
if (ecoOpacity > 0.01) {
ctx.save();
ctx.globalAlpha = ecoOpacity; ctx.globalAlpha = ecoOpacity;
drawEcoScene(dc); drawEcoScene(dc);
ctx.restore();
} }
if (industrialOpacity > 0.1) {
// Transition Fog: Peaks at progress 0.5 to bridge the gap between scenes
// Increased max opacity to 0.9 to fully hide the "swap"
const fogOpacity = Math.sin(p * Math.PI) * 0.9;
if (fogOpacity > 0.01) {
ctx.save();
ctx.globalAlpha = fogOpacity;
const gradient = ctx.createLinearGradient(0, height * 0.2, 0, height);
gradient.addColorStop(0, 'rgba(230, 230, 230, 0)'); // Transparent top
gradient.addColorStop(0.3, 'rgba(200, 200, 200, 0.6)'); // Hazy middle
gradient.addColorStop(1, 'rgba(160, 160, 160, 1.0)'); // Thick bottom
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, width, height);
ctx.restore();
}
if (industrialOpacity > 0.01) {
ctx.save();
ctx.globalAlpha = industrialOpacity; ctx.globalAlpha = industrialOpacity;
drawIndustrialScene(dc); drawIndustrialScene(dc);
ctx.restore();
} }
ctx.globalAlpha = 1; ctx.globalAlpha = 1;
} else if (blendToScene && blendProgress !== undefined && blendToScene !== 'transition') { } else if (blendToScene && blendProgress !== undefined && blendToScene !== 'transition') {

View File

@@ -123,8 +123,32 @@ export function drawDeadTrees(dc: DrawContext): void {
drawDeadTree(width * 0.88, height * 0.76, 1.2); drawDeadTree(width * 0.88, height * 0.76, 1.2);
} }
function drawFog(dc: DrawContext): void {
const { ctx, width, height, state } = dc;
const time = Date.now() * 0.001;
// Drifting fog banks
const drawFogLayer = (y: number, speed: number, opacity: number) => {
const drift = (time * speed) % width;
const xOffset = drift > 0 ? drift - width : drift;
const gradient = ctx.createLinearGradient(0, y, 0, y + 150);
gradient.addColorStop(0, 'rgba(120, 120, 120, 0)');
gradient.addColorStop(0.5, `rgba(120, 120, 120, ${opacity})`);
gradient.addColorStop(1, 'rgba(120, 120, 120, 0)');
ctx.fillStyle = gradient;
ctx.fillRect(xOffset, y, width * 2, 150); // Draw wide to handle looping
};
drawFogLayer(height * 0.65, 20, 0.15);
drawFogLayer(height * 0.75, -15, 0.2);
drawFogLayer(height * 0.55, 10, 0.1);
}
export function drawIndustrialScene(dc: DrawContext): void { export function drawIndustrialScene(dc: DrawContext): void {
drawPowerLines(dc); drawPowerLines(dc);
drawFactories(dc); drawFactories(dc);
drawDeadTrees(dc); drawDeadTrees(dc);
drawFog(dc);
} }

View File

@@ -65,7 +65,7 @@
const navLinks = [ const navLinks = [
{ name: "Home", route: "/", icon: "ri:home-4-line" }, { name: "Home", route: "/", icon: "ri:home-4-line" },
{ name: "Goal", route: "/community", icon: "ri:flag-2-line" }, { name: "Goal", route: "/goal", icon: "ri:flag-2-line" },
{ name: "Chat", route: "/chat", icon: "ri:chat-3-line" }, { name: "Chat", route: "/chat", icon: "ri:chat-3-line" },
{ name: "Report", route: "/report", icon: "ri:alarm-warning-line" }, { name: "Report", route: "/report", icon: "ri:alarm-warning-line" },
]; ];
@@ -77,6 +77,7 @@
name="description" name="description"
content="Scan products to reveal their true environmental impact. Join the community of eco-conscious shoppers making a difference." content="Scan products to reveal their true environmental impact. Join the community of eco-conscious shoppers making a difference."
/> />
<link rel="icon" href="/ethix-logo.png" />
</svelte:head> </svelte:head>
{#if isMobile} {#if isMobile}
@@ -181,7 +182,9 @@
} }
.desktop-nav { .desktop-nav {
position: relative; position: absolute;
top: 0;
left: 0;
width: 100%; width: 100%;
height: auto; height: auto;
overflow: visible; overflow: visible;

View File

@@ -32,7 +32,6 @@
} }
$effect(() => { $effect(() => {
messages; messages;
isLoading; isLoading;
scrollToBottom(); scrollToBottom();
@@ -101,12 +100,11 @@
</div> </div>
<div <div
class="relative z-10 max-w-6xl mx-auto h-full flex flex-col pt-24 pb-6 px-0 md:px-6 md:pt-20 md:pb-10" class="relative z-10 max-w-6xl mx-auto h-full flex flex-col pt-0 pb-20 px-0 md:px-6 md:pt-20 md:pb-10"
> >
<div <div
class="flex flex-col h-full bg-[#0d2e25] md:bg-black/40 md:backdrop-blur-xl border-x md:border border-[#1f473b] md:border-white/10 md:rounded-[32px] overflow-hidden shadow-2xl" class="flex flex-col h-full bg-[#0d2e25] md:bg-black/40 md:backdrop-blur-xl border-x md:border border-[#1f473b] md:border-white/10 md:rounded-[32px] overflow-hidden shadow-2xl"
> >
<div <div
class="p-3 px-5 border-b border-[#1f473b] md:border-white/10 bg-[#051f18] md:bg-transparent flex items-center gap-3 shrink-0" class="p-3 px-5 border-b border-[#1f473b] md:border-white/10 bg-[#051f18] md:bg-transparent flex items-center gap-3 shrink-0"
> >
@@ -126,7 +124,6 @@
</div> </div>
</div> </div>
<div <div
class="flex-1 flex flex-col overflow-hidden bg-[#0d2e25] md:bg-transparent" class="flex-1 flex flex-col overflow-hidden bg-[#0d2e25] md:bg-transparent"
> >
@@ -150,7 +147,6 @@
</div> </div>
<style> <style>
.scrollbar-none::-webkit-scrollbar { .scrollbar-none::-webkit-scrollbar {
display: none; display: none;
} }

View File

@@ -11,6 +11,7 @@
let isLoading = $state(false); let isLoading = $state(false);
let analysisResult = $state<any>(null); let analysisResult = $state<any>(null);
let error = $state<string | null>(null); let error = $state<string | null>(null);
let uploadSuccess = $state(false);
$effect(() => { $effect(() => {
const initialName = new URLSearchParams(window.location.search).get( const initialName = new URLSearchParams(window.location.search).get(
@@ -22,7 +23,9 @@
}); });
let isValid = $derived( let isValid = $derived(
productName.trim().length > 0 && description.trim().length > 0, reportType === "product"
? productName.trim().length > 0 && description.trim().length > 0
: productName.trim().length > 0 && pdfData !== null,
); );
async function pickImage() { async function pickImage() {
@@ -62,7 +65,8 @@
input.click(); input.click();
} }
const progressSteps = [ // Progress steps for product incident
const productProgressSteps = [
{ id: 1, label: "Scanning image...", icon: "ri:camera-lens-line" }, { id: 1, label: "Scanning image...", icon: "ri:camera-lens-line" },
{ {
id: 2, id: 2,
@@ -78,6 +82,31 @@
}, },
]; ];
// Progress steps for company report upload
const companyProgressSteps = [
{ id: 1, label: "Decoding PDF...", icon: "ri:file-pdf-2-line" },
{
id: 2,
label: "Extracting text content...",
icon: "ri:file-text-line",
},
{
id: 3,
label: "Validating report authenticity...",
icon: "ri:shield-check-line",
},
{
id: 4,
label: "Analyzing environmental claims...",
icon: "ri:robot-2-line",
},
{ id: 5, label: "Saving to database...", icon: "ri:database-2-line" },
{ id: 6, label: "Indexing for search...", icon: "ri:search-line" },
];
let progressSteps = $derived(
reportType === "product" ? productProgressSteps : companyProgressSteps,
);
let currentStep = $state(0); let currentStep = $state(0);
async function handleSubmit() { async function handleSubmit() {
@@ -86,15 +115,60 @@
isLoading = true; isLoading = true;
error = null; error = null;
currentStep = 1; currentStep = 1;
uploadSuccess = false;
const stepInterval = setInterval(
const stepInterval = setInterval(() => { () => {
if (currentStep < progressSteps.length) { if (currentStep < progressSteps.length) {
currentStep++; currentStep++;
} }
}, 1500); },
reportType === "company" ? 2000 : 1500,
);
try { try {
if (reportType === "company") {
// Use the new upload endpoint for company reports
const response = await fetch(
"http://localhost:5000/api/reports/upload",
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
company_name: productName,
pdf_data: pdfData,
}),
},
);
clearInterval(stepInterval);
currentStep = progressSteps.length;
const data = await response.json();
if (data.status === "success") {
await new Promise((r) => setTimeout(r, 500));
analysisResult = {
...data,
is_company_report: true,
};
uploadSuccess = true;
submitted = true;
} else if (data.status === "rejected") {
error =
data.message ||
"Report was rejected - not a valid environmental report";
analysisResult = {
rejected: true,
validation: data.validation,
};
} else {
error = data.message || "Failed to upload report";
}
} else {
// Original product incident flow
const response = await fetch( const response = await fetch(
"http://localhost:5000/api/incidents/submit", "http://localhost:5000/api/incidents/submit",
{ {
@@ -106,8 +180,8 @@
product_name: productName, product_name: productName,
description: description, description: description,
report_type: reportType, report_type: reportType,
image: reportType === "product" ? image : null, image: image,
pdf_data: reportType === "company" ? pdfData : null, pdf_data: null,
}), }),
}, },
); );
@@ -118,13 +192,13 @@
const data = await response.json(); const data = await response.json();
if (data.status === "success") { if (data.status === "success") {
await new Promise((r) => setTimeout(r, 500)); await new Promise((r) => setTimeout(r, 500));
analysisResult = data; analysisResult = data;
submitted = true; submitted = true;
} else { } else {
error = data.message || "Failed to submit report"; error = data.message || "Failed to submit report";
} }
}
} catch (e) { } catch (e) {
clearInterval(stepInterval); clearInterval(stepInterval);
error = "Failed to connect to server. Please try again."; error = "Failed to connect to server. Please try again.";
@@ -134,6 +208,18 @@
currentStep = 0; currentStep = 0;
} }
} }
function resetForm() {
submitted = false;
uploadSuccess = false;
analysisResult = null;
error = null;
productName = "";
description = "";
image = null;
pdfData = null;
pdfName = null;
}
</script> </script>
<div class="page-wrapper"> <div class="page-wrapper">
@@ -144,7 +230,91 @@
<div class="content-container"> <div class="content-container">
{#if submitted && analysisResult} {#if submitted && analysisResult}
<div class="glass-card success-card"> <div class="glass-card success-card">
{#if analysisResult.is_greenwashing} {#if analysisResult.is_company_report}
<!-- Company Report Upload Success -->
<div class="icon-circle success">
<iconify-icon
icon="ri:file-check-fill"
width="60"
style="color: #4ade80;"
></iconify-icon>
</div>
<h2 class="success-title">Report Verified & Uploaded</h2>
<div class="analysis-result">
<div class="result-item">
<span class="result-label">Company:</span>
<span class="result-value"
>{analysisResult.stats?.company_name ||
"Unknown"}</span
>
</div>
<div class="result-item">
<span class="result-label">Year:</span>
<span class="result-value"
>{analysisResult.stats?.year || "N/A"}</span
>
</div>
<div class="result-item">
<span class="result-label">Sector:</span>
<span class="result-value badge"
>{analysisResult.stats?.sector || "Other"}</span
>
</div>
<div class="result-item">
<span class="result-label">Report Type:</span>
<span class="result-value"
>{analysisResult.stats?.report_type ||
"Unknown"}</span
>
</div>
<div class="result-item">
<span class="result-label">Pages:</span>
<span class="result-value"
>{analysisResult.stats?.page_count || 0}</span
>
</div>
<div class="result-item">
<span class="result-label">Chunks Created:</span>
<span class="result-value"
>{analysisResult.stats?.chunks_created ||
0}</span
>
</div>
{#if analysisResult.validation?.key_topics}
<div class="result-item full-width">
<span class="result-label">Key Topics:</span>
<div class="topics-list">
{#each analysisResult.validation.key_topics.slice(0, 5) as topic}
<span class="topic-tag">{topic}</span>
{/each}
</div>
</div>
{/if}
{#if analysisResult.validation?.reasoning}
<div class="result-item full-width">
<span class="result-label">AI Assessment:</span>
<p class="result-text">
{analysisResult.validation.reasoning}
</p>
</div>
{/if}
</div>
<div class="action-buttons">
<a href="/catalogue" class="btn-primary">
<iconify-icon icon="ri:folder-open-line" width="18"
></iconify-icon>
View in Catalogue
</a>
<button class="back-btn" onclick={resetForm}>
<iconify-icon icon="ri:add-line" width="20"
></iconify-icon>
Upload Another
</button>
</div>
{:else if analysisResult.is_greenwashing}
<!-- Greenwashing Detected -->
<div class="icon-circle warning"> <div class="icon-circle warning">
<iconify-icon <iconify-icon
icon="ri:alert-fill" icon="ri:alert-fill"
@@ -155,34 +325,25 @@
<h2 class="success-title warning-text"> <h2 class="success-title warning-text">
Greenwashing Detected! Greenwashing Detected!
</h2> </h2>
{:else}
<div class="icon-circle success">
<iconify-icon
icon="ri:checkbox-circle-fill"
width="60"
style="color: #4ade80;"
></iconify-icon>
</div>
<h2 class="success-title">Report Analyzed</h2>
{/if}
<div class="analysis-result"> <div class="analysis-result">
<div class="result-item"> <div class="result-item">
<span class="result-label">Verdict:</span> <span class="result-label">Verdict:</span>
<span class="result-value" <span class="result-value"
>{analysisResult.analysis?.verdict || "N/A"}</span >{analysisResult.analysis?.verdict ||
"N/A"}</span
> >
</div> </div>
<div class="result-item"> <div class="result-item">
<span class="result-label">Confidence:</span> <span class="result-label">Confidence:</span>
<span <span
class="result-value badge {analysisResult.analysis class="result-value badge {analysisResult
?.confidence}" .analysis?.confidence}"
> >
{analysisResult.analysis?.confidence || "unknown"} {analysisResult.analysis?.confidence ||
"unknown"}
</span> </span>
</div> </div>
{#if analysisResult.is_greenwashing}
<div class="result-item"> <div class="result-item">
<span class="result-label">Severity:</span> <span class="result-label">Severity:</span>
<span <span
@@ -192,7 +353,6 @@
{analysisResult.analysis?.severity || "unknown"} {analysisResult.analysis?.severity || "unknown"}
</span> </span>
</div> </div>
{/if}
<div class="result-item full-width"> <div class="result-item full-width">
<span class="result-label">Analysis:</span> <span class="result-label">Analysis:</span>
<p class="result-text"> <p class="result-text">
@@ -202,7 +362,8 @@
</div> </div>
{#if analysisResult.detected_brand && analysisResult.detected_brand !== "Unknown"} {#if analysisResult.detected_brand && analysisResult.detected_brand !== "Unknown"}
<div class="result-item"> <div class="result-item">
<span class="result-label">Detected Brand:</span> <span class="result-label">Detected Brand:</span
>
<span class="result-value" <span class="result-value"
>{analysisResult.detected_brand}</span >{analysisResult.detected_brand}</span
> >
@@ -210,11 +371,70 @@
{/if} {/if}
</div> </div>
<button class="back-btn" onclick={() => window.history.back()}> <button
class="back-btn"
onclick={() => window.history.back()}
>
<iconify-icon icon="ri:arrow-left-line" width="20" <iconify-icon icon="ri:arrow-left-line" width="20"
></iconify-icon> ></iconify-icon>
Go Back Go Back
</button> </button>
{:else}
<!-- Report Analyzed (No Greenwashing) -->
<div class="icon-circle success">
<iconify-icon
icon="ri:checkbox-circle-fill"
width="60"
style="color: #4ade80;"
></iconify-icon>
</div>
<h2 class="success-title">Report Analyzed</h2>
<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>
<div class="result-item full-width">
<span class="result-label">Analysis:</span>
<p class="result-text">
{analysisResult.analysis?.reasoning ||
"No details available"}
</p>
</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>
{/if}
</div> </div>
{:else} {:else}
<div class="header-section"> <div class="header-section">
@@ -263,6 +483,7 @@
</div> </div>
</div> </div>
{#if reportType === "product"}
<div class="form-group"> <div class="form-group">
<label class="label" for="description" <label class="label" for="description"
>Why is it misleading?</label >Why is it misleading?</label
@@ -280,6 +501,7 @@
></textarea> ></textarea>
</div> </div>
</div> </div>
{/if}
<div class="form-group"> <div class="form-group">
<span class="label"> <span class="label">
@@ -366,7 +588,11 @@
width="24" width="24"
class="pulse" class="pulse"
></iconify-icon> ></iconify-icon>
<span>Analyzing Report</span> <span>
{reportType === "product"
? "Analyzing Report"
: "Verifying & Uploading Report"}
</span>
</div> </div>
<div class="progress-steps"> <div class="progress-steps">
{#each progressSteps as step} {#each progressSteps as step}
@@ -417,9 +643,19 @@
onclick={handleSubmit} onclick={handleSubmit}
type="button" type="button"
> >
<iconify-icon icon="ri:shield-flash-line" width="20" {#if reportType === "product"}
<iconify-icon
icon="ri:shield-flash-line"
width="20"
></iconify-icon> ></iconify-icon>
Analyze for Greenwashing Analyze for Greenwashing
{:else}
<iconify-icon
icon="ri:upload-cloud-2-line"
width="20"
></iconify-icon>
Upload & Verify Report
{/if}
</button> </button>
{/if} {/if}
</div> </div>
@@ -443,7 +679,7 @@
.content-container { .content-container {
position: relative; position: relative;
z-index: 10; z-index: 10;
padding: 80px 20px 40px; padding: 120px 20px 40px;
max-width: 500px; max-width: 500px;
margin: 0 auto; margin: 0 auto;
} }
@@ -821,6 +1057,51 @@
background: rgba(255, 255, 255, 0.2); background: rgba(255, 255, 255, 0.2);
} }
.action-buttons {
display: flex;
gap: 12px;
margin-top: 24px;
flex-wrap: wrap;
justify-content: center;
}
.btn-primary {
background: #22c55e;
color: #052e16;
padding: 12px 24px;
border-radius: 50px;
font-weight: 700;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
transition: all 0.2s;
text-decoration: none;
border: none;
}
.btn-primary:hover {
background: #16a34a;
transform: translateY(-2px);
}
.topics-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
}
.topic-tag {
background: rgba(34, 197, 94, 0.1);
color: #4ade80;
padding: 4px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
border: 1px solid rgba(34, 197, 94, 0.2);
}
.error-message { .error-message {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@@ -11,9 +11,4 @@
"strict": true, "strict": true,
"moduleResolution": "bundler" "moduleResolution": "bundler"
} }
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
// from the referenced tsconfig.json - TypeScript does not merge them in
} }