Spaces:
Runtime error
Runtime error
Initial project upload via Python API for Flask Space
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- .gitattributes +9 -0
- Dockerfile +19 -0
- LICENCE +21 -0
- README.md +351 -7
- app.py +170 -0
- config.py +59 -0
- core/fake_manager.py +190 -0
- demo/demo.mp4 +3 -0
- demo/demo1.png +0 -0
- demo/demo2.png +3 -0
- demo/demo3.png +0 -0
- demo/demo4.png +3 -0
- demo/demo5.png +3 -0
- demo/demo6.png +0 -0
- demo/demo7.png +0 -0
- models/Meso4_DF.h5 +3 -0
- models/Meso4_F2F.h5 +3 -0
- models/ai_text_detector.joblib +3 -0
- models/efficientnet_b3_full_ai_image_classifier.pt +3 -0
- models/face_det_10g.onnx +3 -0
- models/fake_news_detector.pt +3 -0
- models/models.py +27 -0
- models/word2idx.pt +3 -0
- notebooks/ai-generated-vs-human-image-99-f1-score.ipynb +0 -0
- notebooks/ai-vs-human-text-classifier-99-f1-score.ipynb +0 -0
- notebooks/fake-news-lstm-classifier-f1-score-99.ipynb +0 -0
- requirements.txt +13 -0
- schemas/fact_search_schemas.py +36 -0
- schemas/fake_manager_schemas.py +178 -0
- schemas/text_schemas.py +71 -0
- schemas/vision_schemas.py +94 -0
- services/ai_image_service.py +76 -0
- services/ai_text_service.py +68 -0
- services/deepfake_service.py +127 -0
- services/face_detection_service.py +137 -0
- services/fact_search_service.py +77 -0
- services/fake_text_news_service.py +132 -0
- services/search_quries_service.py +48 -0
- services/text_emotion_service.py +132 -0
- static/css/style.css +335 -0
- static/js/script.js +185 -0
- static/uploads/0a42e714b24849829247e0a44e51953a.webp +0 -0
- static/uploads/0d337020f9ae491782633516889f3e79.webp +0 -0
- static/uploads/26ad0b08883347718d38274861bccd8e.webp +0 -0
- static/uploads/26de02ceda464fb9acd0edb6b0c678db.webp +0 -0
- static/uploads/28afe2d042a341ceb60c187954deccfc.jpg +3 -0
- static/uploads/3156289a7e0e46fa82b7d4f0965aeae7.webp +0 -0
- static/uploads/37f95b1e0e9c44b79e527a0deafab813.webp +0 -0
- static/uploads/3b69dc4d23714b3a801c5131b476fafd.webp +0 -0
- static/uploads/45aa41d76e71411bb28a19de398ba785.webp +0 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,12 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
demo/demo.mp4 filter=lfs diff=lfs merge=lfs -text
|
| 37 |
+
demo/demo2.png filter=lfs diff=lfs merge=lfs -text
|
| 38 |
+
demo/demo4.png filter=lfs diff=lfs merge=lfs -text
|
| 39 |
+
demo/demo5.png filter=lfs diff=lfs merge=lfs -text
|
| 40 |
+
static/uploads/28afe2d042a341ceb60c187954deccfc.jpg filter=lfs diff=lfs merge=lfs -text
|
| 41 |
+
static/uploads/8faf0f3a7992493d9870225198e49273.jpg filter=lfs diff=lfs merge=lfs -text
|
| 42 |
+
static/uploads/a326c59eb34f48ab939624dcac5bd556.jpg filter=lfs diff=lfs merge=lfs -text
|
| 43 |
+
static/uploads/d9ab337c2ef44bc5ac7b2d2a8b136b19.jpg filter=lfs diff=lfs merge=lfs -text
|
| 44 |
+
static/uploads/e3e074e58f9545b294ec541566759e45.jpg filter=lfs diff=lfs merge=lfs -text
|
Dockerfile
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 1. Use a base Python image
|
| 2 |
+
FROM python:3.10-slim
|
| 3 |
+
|
| 4 |
+
# 2. Set the working directory inside the container
|
| 5 |
+
WORKDIR /app
|
| 6 |
+
|
| 7 |
+
# 3. Copy the dependencies file and install them
|
| 8 |
+
COPY requirements.txt .
|
| 9 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 10 |
+
|
| 11 |
+
# 4. Copy the rest of your application code
|
| 12 |
+
COPY . .
|
| 13 |
+
|
| 14 |
+
# 5. Expose the required port (Hugging Face default)
|
| 15 |
+
EXPOSE 7860
|
| 16 |
+
|
| 17 |
+
# 6. Start the production server (Gunicorn is highly recommended for production)
|
| 18 |
+
# The format is: gunicorn -b <host>:<port> <app_file>:<flask_app_object>
|
| 19 |
+
CMD ["gunicorn", "-b", "0.0.0.0:7860", "app:app"]
|
LICENCE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2025 Eslam Tarek
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
README.md
CHANGED
|
@@ -1,11 +1,355 @@
|
|
| 1 |
---
|
| 2 |
title: FactSight
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
-
sdk: docker
|
| 7 |
-
|
| 8 |
-
|
| 9 |
---
|
| 10 |
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
title: FactSight
|
| 3 |
+
emoji: 🚀
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: purple
|
| 6 |
+
sdk: docker
|
| 7 |
+
# Optional: You can specify the port if it's not the default 7860
|
| 8 |
+
# app_port: 7860
|
| 9 |
---
|
| 10 |
|
| 11 |
+
|
| 12 |
+
# FactSight: Advanced Fake News Detection System
|
| 13 |
+
|
| 14 |
+
[](https://www.python.org/downloads/)
|
| 15 |
+
[](https://flask.palletsprojects.com/)
|
| 16 |
+
[](https://pytorch.org/)
|
| 17 |
+
[](https://opensource.org/licenses/MIT)
|
| 18 |
+
|
| 19 |
+
## 📋 Table of Contents
|
| 20 |
+
|
| 21 |
+
- [Overview](#overview)
|
| 22 |
+
- [Architecture](#architecture)
|
| 23 |
+
- [Installation](#installation)
|
| 24 |
+
- [Usage](#usage)
|
| 25 |
+
- [Services & Models Deep Dive](#services--models-deep-dive)
|
| 26 |
+
- [Text Analysis Services](#text-analysis-services)
|
| 27 |
+
- [Image Analysis Services](#image-analysis-services)
|
| 28 |
+
- [Fact-Checking Integration](#fact-checking-integration)
|
| 29 |
+
- [Model Performance](#model-performance)
|
| 30 |
+
- [Demo Media Files](#demo-media-files)
|
| 31 |
+
- [Configuration](#configuration)
|
| 32 |
+
- [API Endpoints](#api-endpoints)
|
| 33 |
+
- [Contributing](#contributing)
|
| 34 |
+
- [License](#license)
|
| 35 |
+
|
| 36 |
+
## 🎯 Overview
|
| 37 |
+
|
| 38 |
+
**FactSight** is a comprehensive fake news detection system that combines multiple AI/ML technologies to analyze news content for authenticity. The system performs multi-modal analysis using both **text** and **image** content to determine if news articles are genuine or fabricated.
|
| 39 |
+
|
| 40 |
+
### Key Features
|
| 41 |
+
|
| 42 |
+
- **🔍 Multi-Modal Analysis**: Combines text and image analysis for comprehensive fact-checking
|
| 43 |
+
- **🤖 AI Content Detection**: Identifies AI-generated text and images
|
| 44 |
+
- **🧠 Deep Learning Models**: Uses state-of-the-art neural networks for classification
|
| 45 |
+
- **🔗 External Fact-Checking**: Integrates with Google Fact Check Tools API
|
| 46 |
+
- **📊 Emotion Analysis**: Detects sensationalism in news content
|
| 47 |
+
- **👤 Face Analysis**: Analyzes faces in images for deepfake detection
|
| 48 |
+
- **🌐 Web Interface**: User-friendly web application for easy analysis
|
| 49 |
+
|
| 50 |
+
## 🏗️ Architecture
|
| 51 |
+
|
| 52 |
+
```
|
| 53 |
+
┌─────────────────────────────────────────────────────────────────┐
|
| 54 |
+
│ FactSight System │
|
| 55 |
+
├─────────────────────────────────────────────────────────────────┤
|
| 56 |
+
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
| 57 |
+
│ │ Flask │ │ Core │ │ Services │ │
|
| 58 |
+
│ │ Web App │ │ Manager │ │ & Models │ │
|
| 59 |
+
│ └─────────────┘ └─────────────┘ └─────────────┘ │
|
| 60 |
+
├─────────────────────────────────────────────────────────────────┤
|
| 61 |
+
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
| 62 |
+
│ │ Text │ │ Image │ │ Fact │ │
|
| 63 |
+
│ │ Analysis │ │ Analysis │ │ Checking │ │
|
| 64 |
+
│ └─────────────┘ └─────────────┘ └─────────────┘ │
|
| 65 |
+
├─────────────────────────────────────────────────────────────────┤
|
| 66 |
+
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
| 67 |
+
│ │ LSTM │ │ EfficientNet│ │ Google │ │
|
| 68 |
+
│ │ Classifier │ │ B3 │ │ Fact Check │ │
|
| 69 |
+
│ └─────────────┘ └─────────────┘ └─────────────┘ │
|
| 70 |
+
└─────────────────────────────────────────────────────────────────┘
|
| 71 |
+
```
|
| 72 |
+
|
| 73 |
+
## 🚀 Installation
|
| 74 |
+
|
| 75 |
+
### Prerequisites
|
| 76 |
+
|
| 77 |
+
- Python 3.11+
|
| 78 |
+
- CUDA-compatible GPU (recommended for faster inference)
|
| 79 |
+
- 8GB+ RAM
|
| 80 |
+
- 4GB+ free disk space
|
| 81 |
+
|
| 82 |
+
### Setup Instructions
|
| 83 |
+
|
| 84 |
+
1. **Clone the repository**
|
| 85 |
+
```bash
|
| 86 |
+
git clone <repository-url>
|
| 87 |
+
cd FactSight
|
| 88 |
+
```
|
| 89 |
+
|
| 90 |
+
2. **Install dependencies**
|
| 91 |
+
```bash
|
| 92 |
+
pip install -r requirements.txt
|
| 93 |
+
```
|
| 94 |
+
|
| 95 |
+
3. **Download required models from [HUGGINGFACE](<https://huggingface.co/>)** (if not included)
|
| 96 |
+
```bash
|
| 97 |
+
# Models will be automatically downloaded on first run
|
| 98 |
+
# or placed in the models/ directory
|
| 99 |
+
```
|
| 100 |
+
|
| 101 |
+
4. **Configure environment**
|
| 102 |
+
```bash
|
| 103 |
+
# Set your Google Fact Check API key in config.py
|
| 104 |
+
FACT_API_KEY = "your-api-key-here"
|
| 105 |
+
```
|
| 106 |
+
|
| 107 |
+
5. **Run the application**
|
| 108 |
+
```bash
|
| 109 |
+
python app.py
|
| 110 |
+
```
|
| 111 |
+
|
| 112 |
+
6. **Access the web interface**
|
| 113 |
+
```
|
| 114 |
+
Open http://localhost:5000 in your browser
|
| 115 |
+
```
|
| 116 |
+
|
| 117 |
+
## 📖 Usage
|
| 118 |
+
|
| 119 |
+
### Web Interface
|
| 120 |
+
|
| 121 |
+
1. **Navigate to the homepage** (`/`)
|
| 122 |
+
2. **Paste news text** in the text area (max 10,000 characters)
|
| 123 |
+
3. **Upload images** related to the news (drag & drop or browse)
|
| 124 |
+
4. **Click "Analyze"** to start the fact-checking process
|
| 125 |
+
5. **View detailed results** including:
|
| 126 |
+
- Overall authenticity score
|
| 127 |
+
- Text analysis breakdown
|
| 128 |
+
- Image analysis results
|
| 129 |
+
- Fact-checking verification
|
| 130 |
+
- Emotion analysis
|
| 131 |
+
|
| 132 |
+
### API Usage
|
| 133 |
+
|
| 134 |
+
```python
|
| 135 |
+
import requests
|
| 136 |
+
|
| 137 |
+
# Submit for analysis
|
| 138 |
+
response = requests.post('http://localhost:5000/analyze', json={
|
| 139 |
+
'text': 'Your news article text here...',
|
| 140 |
+
'images': [
|
| 141 |
+
{'data': 'base64-encoded-image-data'}
|
| 142 |
+
]
|
| 143 |
+
})
|
| 144 |
+
|
| 145 |
+
analysis_id = response.json()['analysis_id']
|
| 146 |
+
|
| 147 |
+
# Get results
|
| 148 |
+
results = requests.get(f'http://localhost:5000/analysis/{analysis_id}')
|
| 149 |
+
```
|
| 150 |
+
|
| 151 |
+
## 🔬 Services & Models Deep Dive
|
| 152 |
+
|
| 153 |
+
### Text Analysis Services
|
| 154 |
+
|
| 155 |
+
#### 1. **AI Text Detection** (`ai_text_service.py`)
|
| 156 |
+
- **Model**: Custom-trained classifier [placeholder for url]
|
| 157 |
+
- **Architecture**: Joblib-based scikit-learn model
|
| 158 |
+
- **Performance**: **99% F1-Score**
|
| 159 |
+
- **Purpose**: Distinguishes between AI-generated and human-written text
|
| 160 |
+
- **Features**: Text preprocessing, feature extraction, binary classification
|
| 161 |
+
|
| 162 |
+
#### 2. **Fake News Classification** (`fake_text_news_service.py`)
|
| 163 |
+
- **Model**: Custom LSTM Neural Network [placeholder for url]
|
| 164 |
+
- **Architecture**:
|
| 165 |
+
- Embedding layer (100 dimensions)
|
| 166 |
+
- Bidirectional LSTM (128 hidden units)
|
| 167 |
+
- Dropout regularization (0.5)
|
| 168 |
+
- Sigmoid output layer
|
| 169 |
+
- **Performance**: **99% F1-Score**
|
| 170 |
+
- **Purpose**: Classifies news as fake or real
|
| 171 |
+
- **Features**:
|
| 172 |
+
- Text cleaning and preprocessing
|
| 173 |
+
- Contraction expansion
|
| 174 |
+
- Stopword removal
|
| 175 |
+
- Sequence padding/truncation (300 tokens)
|
| 176 |
+
|
| 177 |
+
#### 3. **Emotion Analysis** (`text_emotion_service.py`)
|
| 178 |
+
- **Model**: `j-hartmann/emotion-english-distilroberta-base`
|
| 179 |
+
- **Architecture**: DistilRoBERTa transformer model
|
| 180 |
+
- **Purpose**: Detects emotional tone (anger, fear, joy, etc.)
|
| 181 |
+
- **Features**:
|
| 182 |
+
- Chunked processing for long texts
|
| 183 |
+
- Confidence-weighted aggregation
|
| 184 |
+
- Sensationalism detection
|
| 185 |
+
|
| 186 |
+
#### 4. **Search Query Extraction** (`search_queries_service.py`)
|
| 187 |
+
- **Model**: `transformers` pipeline for question generation
|
| 188 |
+
- **Purpose**: Extracts key claims from text for fact-checking
|
| 189 |
+
- **Features**: NLP-based query generation
|
| 190 |
+
|
| 191 |
+
### Image Analysis Services
|
| 192 |
+
|
| 193 |
+
#### 1. **AI Image Detection** (`ai_image_service.py`)
|
| 194 |
+
- **Model**: Custom EfficientNet-B3 [placeholder for url]
|
| 195 |
+
- **Architecture**:
|
| 196 |
+
- EfficientNet-B3 backbone
|
| 197 |
+
- Custom classification head
|
| 198 |
+
- Binary classification (AI vs Real)
|
| 199 |
+
- **Performance**: **99% F1-Score**
|
| 200 |
+
- **Purpose**: Identifies AI-generated vs real images
|
| 201 |
+
- **Features**:
|
| 202 |
+
- 300x300 input resolution
|
| 203 |
+
- Batch normalization
|
| 204 |
+
- GPU acceleration
|
| 205 |
+
|
| 206 |
+
#### 2. **Face Detection** (`face_detection_service.py`)
|
| 207 |
+
- **Model**: `scrfd` (pre-trained ONNX model)
|
| 208 |
+
- **Purpose**: Detects and locates faces in images
|
| 209 |
+
- **Features**:
|
| 210 |
+
- Multi-scale face detection
|
| 211 |
+
- Landmark extraction (eyes, nose, mouth)
|
| 212 |
+
- Confidence scoring
|
| 213 |
+
- NMS (Non-Maximum Suppression)
|
| 214 |
+
|
| 215 |
+
#### 3. **Deepfake Detection** (`deepfake_service.py`)
|
| 216 |
+
- **Model**: Meso4 architecture (two variants)
|
| 217 |
+
- `Meso4_DF.h5`: Trained on DeepFake dataset
|
| 218 |
+
- `Meso4_F2F.h5`: Trained on Face2Face dataset
|
| 219 |
+
- **Architecture**:
|
| 220 |
+
- 4 convolutional layers
|
| 221 |
+
- Batch normalization
|
| 222 |
+
- Max pooling
|
| 223 |
+
- Dropout (0.5)
|
| 224 |
+
- Sigmoid classification
|
| 225 |
+
- **Purpose**: Detects manipulated faces in images
|
| 226 |
+
- **Features**:
|
| 227 |
+
- Dual-model ensemble
|
| 228 |
+
- Face cropping integration
|
| 229 |
+
- Confidence threshold adjustment
|
| 230 |
+
|
| 231 |
+
### Fact-Checking Integration
|
| 232 |
+
|
| 233 |
+
#### **Fact Check Service** (`fact_search_service.py`)
|
| 234 |
+
- **API**: Google Fact Check Tools API v1alpha1
|
| 235 |
+
- **Purpose**: External verification of claims
|
| 236 |
+
- **Features**:
|
| 237 |
+
- Multi-source fact-checking
|
| 238 |
+
- Verdict aggregation
|
| 239 |
+
- Confidence scoring
|
| 240 |
+
- Source credibility assessment
|
| 241 |
+
|
| 242 |
+
## 📊 Model Performance
|
| 243 |
+
|
| 244 |
+
| Model | Type | F1-Score | Dataset | Purpose |
|
| 245 |
+
|-------|------|----------|---------|---------|
|
| 246 |
+
| **AI Text Detector** | Custom Classifier | **99%** | Custom | AI vs Human Text |
|
| 247 |
+
| **Fake News LSTM** | LSTM Neural Network | **99%** | Custom | Fake vs Real News |
|
| 248 |
+
| **AI Image Detector** | EfficientNet-B3 | **99%** | Custom | AI vs Real Images |
|
| 249 |
+
| **Emotion Detector** | DistilRoBERTa | N/A | Pre-trained | Emotion Analysis |
|
| 250 |
+
| **Face Detector** | SCRFD | N/A | Pre-trained | Face Detection |
|
| 251 |
+
| **Deepfake Detector** | Meso4 | N/A | Custom | Deepfake Detection |
|
| 252 |
+
|
| 253 |
+
## 🎬 Demo Media Files
|
| 254 |
+
|
| 255 |
+
The `demo/` folder contains sample media:
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
### Videos
|
| 259 |
+
- <video src="./demo/demo.mp4" controls width="720"></video>
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
### Images
|
| 263 |
+
- `demo1.png` - 
|
| 264 |
+
- `demo2.png` - 
|
| 265 |
+
- `demo3.png` - 
|
| 266 |
+
- `demo4.png` - 
|
| 267 |
+
- `demo5.png` - 
|
| 268 |
+
- `demo6.png` - 
|
| 269 |
+
- `demo7.png` - 
|
| 270 |
+
|
| 271 |
+
## ⚙️ Configuration
|
| 272 |
+
|
| 273 |
+
### Environment Variables
|
| 274 |
+
|
| 275 |
+
```python
|
| 276 |
+
# config.py
|
| 277 |
+
class Config:
|
| 278 |
+
flask: FlaskConfig = FlaskConfig(
|
| 279 |
+
SECRET_KEY = os.environ.get('SECRET_KEY', 'dev-secret-key'),
|
| 280 |
+
UPLOAD_FOLDER = 'static/uploads',
|
| 281 |
+
MAX_CONTENT_LENGTH = 50 * 1024 * 1024 # 50MB
|
| 282 |
+
)
|
| 283 |
+
|
| 284 |
+
service: ServiceConfig = ServiceConfig(
|
| 285 |
+
FACT_API_KEY = "your-google-fact-check-api-key",
|
| 286 |
+
FAKENESS_SCORE_THRESHOLD = 0.6,
|
| 287 |
+
FACE_DETECTION_THRESHOLD = 0.5
|
| 288 |
+
)
|
| 289 |
+
```
|
| 290 |
+
|
| 291 |
+
### Model Paths
|
| 292 |
+
|
| 293 |
+
```python
|
| 294 |
+
# Model file locations
|
| 295 |
+
AI_TEXT_DETECTOR = "./models/ai_text_detector.joblib"
|
| 296 |
+
FAKE_NEWS_DETECTOR = "models/fake_news_detector.pt"
|
| 297 |
+
EFFICIENTNET_AI_IMAGE = "./models/efficientnet_b3_full_ai_image_classifier.pt"
|
| 298 |
+
FACE_DETECTION = "models/face_det_10g.onnx"
|
| 299 |
+
MESO4_DF = "models/Meso4_DF.h5"
|
| 300 |
+
MESO4_F2F = "models/Meso4_F2F.h5"
|
| 301 |
+
```
|
| 302 |
+
|
| 303 |
+
## 🔗 API Endpoints
|
| 304 |
+
|
| 305 |
+
| Method | Endpoint | Description |
|
| 306 |
+
|--------|----------|-------------|
|
| 307 |
+
| GET | `/` | Main analysis interface |
|
| 308 |
+
| POST | `/analyze` | Submit content for analysis |
|
| 309 |
+
| GET | `/analysis/<id>` | View analysis results |
|
| 310 |
+
| GET | `/health` | System health check |
|
| 311 |
+
|
| 312 |
+
## 🤝 Contributing
|
| 313 |
+
|
| 314 |
+
1. Fork the repository
|
| 315 |
+
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
|
| 316 |
+
3. Commit your changes (`git commit -m 'Add amazing feature'`)
|
| 317 |
+
4. Push to the branch (`git push origin feature/amazing-feature`)
|
| 318 |
+
5. Open a Pull Request
|
| 319 |
+
|
| 320 |
+
### Development Guidelines
|
| 321 |
+
|
| 322 |
+
- Follow PEP 8 style guidelines
|
| 323 |
+
- Add tests for new features
|
| 324 |
+
- Update documentation
|
| 325 |
+
- Ensure model compatibility
|
| 326 |
+
|
| 327 |
+
## 📝 License
|
| 328 |
+
|
| 329 |
+
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
| 330 |
+
|
| 331 |
+
## Acknowledgments
|
| 332 |
+
|
| 333 |
+
- **Hugging Face** for transformer models and pipelines
|
| 334 |
+
- **Google** for Fact Check Tools API
|
| 335 |
+
- **PyTorch Team** for deep learning framework
|
| 336 |
+
- **OpenCV** for computer vision utilities
|
| 337 |
+
- **SCRFD** for face detection models
|
| 338 |
+
|
| 339 |
+
## Support
|
| 340 |
+
|
| 341 |
+
For support and questions:
|
| 342 |
+
- Create an issue in the repository
|
| 343 |
+
- Contact: [your-email@example.com]
|
| 344 |
+
|
| 345 |
+
## Version History
|
| 346 |
+
|
| 347 |
+
### v1.0.0
|
| 348 |
+
- Initial release with full multi-modal fake news detection
|
| 349 |
+
- 99% F1-scores on custom trained models
|
| 350 |
+
- Comprehensive web interface
|
| 351 |
+
- Integration with external fact-checking services
|
| 352 |
+
|
| 353 |
+
---
|
| 354 |
+
|
| 355 |
+
**FactSight** - Bringing truth to the digital age through advanced AI-powered analysis. 🛡️
|
app.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from flask import Flask, render_template, request, jsonify, redirect, url_for
|
| 2 |
+
from dataclasses import dataclass, asdict, is_dataclass
|
| 3 |
+
from typing import List, Optional, Tuple, Any
|
| 4 |
+
import os
|
| 5 |
+
import uuid
|
| 6 |
+
import base64
|
| 7 |
+
import io
|
| 8 |
+
from datetime import datetime
|
| 9 |
+
from PIL import Image
|
| 10 |
+
|
| 11 |
+
# Import configuration
|
| 12 |
+
from config import general_config
|
| 13 |
+
|
| 14 |
+
# -----------------------------
|
| 15 |
+
# Configuration and Setup
|
| 16 |
+
# -----------------------------
|
| 17 |
+
|
| 18 |
+
# Initialize Flask app with configuration
|
| 19 |
+
app = Flask(__name__)
|
| 20 |
+
app.config['UPLOAD_FOLDER'] = general_config.flask.UPLOAD_FOLDER
|
| 21 |
+
app.config['MAX_CONTENT_LENGTH'] = general_config.flask.MAX_CONTENT_LENGTH
|
| 22 |
+
|
| 23 |
+
# -----------------------------
|
| 24 |
+
# Data models (matching your schema)
|
| 25 |
+
# -----------------------------
|
| 26 |
+
|
| 27 |
+
from schemas.fact_search_schemas import FactCheckEntry, FactCheckResult
|
| 28 |
+
from schemas.text_schemas import EmotionResult
|
| 29 |
+
from schemas.vision_schemas import FaceMainPoints
|
| 30 |
+
from schemas.fake_manager_schemas import ImageAnalysis, AggregatedNewsAnalysis
|
| 31 |
+
from core.fake_manager import FakeNewsManager
|
| 32 |
+
from schemas.fake_manager_schemas import News
|
| 33 |
+
|
| 34 |
+
from services.ai_text_service import NBAITextDetector
|
| 35 |
+
from services.fake_text_news_service import FakeTextNewsDetector
|
| 36 |
+
from services.search_quries_service import TransformersSearchQueryExtractor
|
| 37 |
+
from services.text_emotion_service import TransformersEmotionDetector
|
| 38 |
+
from services.fact_search_service import FactCheckService
|
| 39 |
+
from services.ai_image_service import ENetAIImageDetector
|
| 40 |
+
from services.face_detection_service import SCRFDFaceDetector
|
| 41 |
+
from services.deepfake_service import Meso4FakeFaceDetector
|
| 42 |
+
from models.models import LSTMClassifier
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
# In-memory store: analysis_id -> JSON dict
|
| 46 |
+
STORE: dict[str, dict] = {}
|
| 47 |
+
|
| 48 |
+
# -----------------------------
|
| 49 |
+
# Helpers
|
| 50 |
+
# -----------------------------
|
| 51 |
+
def save_image(image_data: str, filename: str | None = None) -> Optional[str]:
|
| 52 |
+
try:
|
| 53 |
+
if image_data.startswith('data:image'):
|
| 54 |
+
header, b64 = image_data.split(',', 1)
|
| 55 |
+
mime = header.split(';')[0].split(':')[1] # e.g. image/png
|
| 56 |
+
ext = {'image/jpeg': 'jpg', 'image/png': 'png', 'image/webp': 'webp'}.get(mime, 'jpg')
|
| 57 |
+
else:
|
| 58 |
+
b64 = image_data
|
| 59 |
+
ext = 'jpg'
|
| 60 |
+
|
| 61 |
+
raw = base64.b64decode(b64)
|
| 62 |
+
img = Image.open(io.BytesIO(raw))
|
| 63 |
+
img.verify() # validate
|
| 64 |
+
|
| 65 |
+
img = Image.open(io.BytesIO(raw))
|
| 66 |
+
if img.mode not in ('RGB', 'L'):
|
| 67 |
+
img = img.convert('RGB')
|
| 68 |
+
|
| 69 |
+
if not filename:
|
| 70 |
+
filename = f"{uuid.uuid4().hex}.{ext}"
|
| 71 |
+
path = os.path.join(app.config['UPLOAD_FOLDER'], filename)
|
| 72 |
+
img.save(path, quality=92)
|
| 73 |
+
return path
|
| 74 |
+
except Exception as e:
|
| 75 |
+
print(f"Error saving image: {e}")
|
| 76 |
+
import traceback
|
| 77 |
+
traceback.print_exc()
|
| 78 |
+
return None
|
| 79 |
+
|
| 80 |
+
def initialize_services():
|
| 81 |
+
"""Initialize and configure all ML services."""
|
| 82 |
+
return FakeNewsManager(
|
| 83 |
+
ai_text_detector=NBAITextDetector(general_config.service.models.AI_TEXT_DETECTOR),
|
| 84 |
+
news_detector=FakeTextNewsDetector(
|
| 85 |
+
model_path=general_config.service.models.FAKE_NEWS_DETECTOR,
|
| 86 |
+
vocab_path=general_config.service.models.VOCAB_PATH,
|
| 87 |
+
),
|
| 88 |
+
query_extractor=TransformersSearchQueryExtractor(),
|
| 89 |
+
emotion_detector=TransformersEmotionDetector(),
|
| 90 |
+
fact_checker=FactCheckService(api_key=general_config.service.FACT_API_KEY),
|
| 91 |
+
ai_image_detector=ENetAIImageDetector(general_config.service.models.EFFICIENTNET_AI_IMAGE),
|
| 92 |
+
face_detector=SCRFDFaceDetector(
|
| 93 |
+
model_path=general_config.service.models.FACE_DETECTION,
|
| 94 |
+
threshold_probability=general_config.service.FACE_DETECTION_THRESHOLD,
|
| 95 |
+
nms=general_config.service.FACE_DETECTION_NMS,
|
| 96 |
+
),
|
| 97 |
+
fake_face_detector=Meso4FakeFaceDetector(
|
| 98 |
+
df_model_path=general_config.service.models.MESO4_DF,
|
| 99 |
+
f2f_model_path=general_config.service.models.MESO4_F2F,
|
| 100 |
+
),
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
# Initialize services
|
| 105 |
+
fake_news_manager = initialize_services()
|
| 106 |
+
|
| 107 |
+
# -----------------------------
|
| 108 |
+
# Routes
|
| 109 |
+
# -----------------------------
|
| 110 |
+
@app.route('/')
|
| 111 |
+
def index():
|
| 112 |
+
return render_template('index.html')
|
| 113 |
+
|
| 114 |
+
@app.route('/analyze', methods=['POST'])
|
| 115 |
+
def analyze():
|
| 116 |
+
try:
|
| 117 |
+
data = request.get_json()
|
| 118 |
+
if not data or 'text' not in data or 'images' not in data:
|
| 119 |
+
return jsonify({'success': False, 'error': 'Invalid request data'}), 400
|
| 120 |
+
|
| 121 |
+
text = (data.get('text') or '').strip()
|
| 122 |
+
if not text:
|
| 123 |
+
return jsonify({'success': False, 'error': 'News text is required'}), 400
|
| 124 |
+
|
| 125 |
+
images_in = data.get('images') or []
|
| 126 |
+
if not images_in:
|
| 127 |
+
return jsonify({'success': False, 'error': 'At least one image is required'}), 400
|
| 128 |
+
|
| 129 |
+
saved_fs_paths_disk = []
|
| 130 |
+
saved_fs_paths_web = []
|
| 131 |
+
for img in images_in:
|
| 132 |
+
path = save_image(img.get('data', ''))
|
| 133 |
+
if path:
|
| 134 |
+
saved_fs_paths_disk.append(path)
|
| 135 |
+
saved_fs_paths_web.append('/' + path.replace('\\', '/'))
|
| 136 |
+
|
| 137 |
+
news = News(text=text, images=saved_fs_paths_disk)
|
| 138 |
+
analysis = fake_news_manager.analyze(news, fakeness_score_threshold=general_config.service.FAKENESS_SCORE_THRESHOLD)
|
| 139 |
+
analysis_json = analysis.to_json()
|
| 140 |
+
|
| 141 |
+
# Overwrite image paths in the JSON to web paths for frontend rendering
|
| 142 |
+
for i, img_entry in enumerate(analysis_json.get("images", [])):
|
| 143 |
+
if i < len(saved_fs_paths_web):
|
| 144 |
+
img_entry["image_path"] = saved_fs_paths_web[i]
|
| 145 |
+
|
| 146 |
+
STORE[analysis_json["analysis_id"]] = analysis_json
|
| 147 |
+
|
| 148 |
+
return jsonify({'success': True, 'analysis_id': analysis_json["analysis_id"]})
|
| 149 |
+
except Exception as e:
|
| 150 |
+
print("Analysis error:", e)
|
| 151 |
+
import traceback
|
| 152 |
+
traceback.print_exc()
|
| 153 |
+
|
| 154 |
+
return jsonify({'success': False, 'error': str(e)}), 500
|
| 155 |
+
|
| 156 |
+
@app.route('/analysis/<analysis_id>')
|
| 157 |
+
def analysis_page(analysis_id):
|
| 158 |
+
analysis = STORE.get(analysis_id)
|
| 159 |
+
if not analysis:
|
| 160 |
+
return redirect(url_for('index'))
|
| 161 |
+
return render_template('analysis.html', analysis=analysis)
|
| 162 |
+
|
| 163 |
+
@app.route('/health')
|
| 164 |
+
def health():
|
| 165 |
+
return jsonify({'status': 'healthy', 'timestamp': datetime.now().isoformat()})
|
| 166 |
+
|
| 167 |
+
if __name__ == '__main__':
|
| 168 |
+
print("Starting News Analyzer Server...")
|
| 169 |
+
print("Server running on http://localhost:5000")
|
| 170 |
+
app.run(debug=True, host='0.0.0.0', port=5000)
|
config.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from dataclasses import dataclass, field
|
| 2 |
+
import os
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
@dataclass
|
| 7 |
+
class FlaskConfig:
|
| 8 |
+
"""Flask application configuration."""
|
| 9 |
+
SECRET_KEY: str = field(default_factory=lambda: os.environ.get('SECRET_KEY', 'dev-secret-key'))
|
| 10 |
+
UPLOAD_FOLDER: str = 'static/uploads'
|
| 11 |
+
MAX_CONTENT_LENGTH: int = 50 * 1024 * 1024 # 50MB
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@dataclass
|
| 15 |
+
class ModelPaths:
|
| 16 |
+
"""Model file paths configuration."""
|
| 17 |
+
# Text detection models
|
| 18 |
+
AI_TEXT_DETECTOR: str = "./models/ai_text_detector.joblib"
|
| 19 |
+
FAKE_NEWS_DETECTOR: str = "models/fake_news_detector.pt"
|
| 20 |
+
VOCAB_PATH: str = "models/word2idx.pt"
|
| 21 |
+
|
| 22 |
+
# Image detection models
|
| 23 |
+
EFFICIENTNET_AI_IMAGE: str = "./models/efficientnet_b3_full_ai_image_classifier.pt"
|
| 24 |
+
FACE_DETECTION: str = "models/face_det_10g.onnx"
|
| 25 |
+
MESO4_DF: str = "models/Meso4_DF.h5"
|
| 26 |
+
MESO4_F2F: str = "models/Meso4_F2F.h5"
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@dataclass
|
| 30 |
+
class ServiceConfig:
|
| 31 |
+
"""Service-specific configuration parameters."""
|
| 32 |
+
# Face detection thresholds
|
| 33 |
+
FACE_DETECTION_THRESHOLD: float = 0.5
|
| 34 |
+
FACE_DETECTION_NMS: float = 0.5
|
| 35 |
+
|
| 36 |
+
# Analysis thresholds
|
| 37 |
+
FAKENESS_SCORE_THRESHOLD: float = 0.6
|
| 38 |
+
|
| 39 |
+
# API Keys
|
| 40 |
+
FACT_API_KEY: str = "your-google-fact-check"
|
| 41 |
+
|
| 42 |
+
# Model paths
|
| 43 |
+
models: ModelPaths = field(default_factory=ModelPaths)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
@dataclass
|
| 47 |
+
class Config:
|
| 48 |
+
"""Main application configuration."""
|
| 49 |
+
flask: FlaskConfig = field(default_factory=FlaskConfig)
|
| 50 |
+
service: ServiceConfig = field(default_factory=ServiceConfig)
|
| 51 |
+
|
| 52 |
+
def __post_init__(self):
|
| 53 |
+
"""Ensure upload folder exists after initialization."""
|
| 54 |
+
if hasattr(self.flask, 'UPLOAD_FOLDER'):
|
| 55 |
+
os.makedirs(self.flask.UPLOAD_FOLDER, exist_ok=True)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
# Global configuration instance
|
| 59 |
+
general_config = Config()
|
core/fake_manager.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from schemas.text_schemas import AITextDetector
|
| 2 |
+
from schemas.vision_schemas import FakeFaceDetector
|
| 3 |
+
from schemas.vision_schemas import FaceDetector
|
| 4 |
+
from schemas.text_schemas import FakeNewsDetector
|
| 5 |
+
from schemas.vision_schemas import AIImageDetector
|
| 6 |
+
from schemas.text_schemas import EmotionDetector
|
| 7 |
+
from schemas.text_schemas import SearchQueryExtractor
|
| 8 |
+
from schemas.fake_manager_schemas import News, AggregatedNewsAnalysis, ImageAnalysis
|
| 9 |
+
from schemas.vision_schemas import FaceMainPoints
|
| 10 |
+
from services.fact_search_service import FactCheckService
|
| 11 |
+
from utils.utils import open_image
|
| 12 |
+
from typing import List, Optional, Union
|
| 13 |
+
from PIL import Image as PILImage
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
import uuid
|
| 16 |
+
from datetime import datetime
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class FakeNewsManager:
|
| 20 |
+
"""Manager that aggregates multiple detectors and services to analyze a single
|
| 21 |
+
news item (text + images) and produce an aggregated analysis.
|
| 22 |
+
|
| 23 |
+
Responsibilities:
|
| 24 |
+
- Run text-based detectors (AI text detector, fake-news/text classifier).
|
| 25 |
+
- Extract search queries and run fact-checking.
|
| 26 |
+
- Run emotion analysis on the text.
|
| 27 |
+
- Run image-level detectors (face detection, AI-image detection, deepfake
|
| 28 |
+
face detection) and crop faces for per-face analysis.
|
| 29 |
+
|
| 30 |
+
Attributes:
|
| 31 |
+
ai_text_detector: Optional AI text detector; must provide `.detect(text) -> bool|float|None`.
|
| 32 |
+
fake_face_detector: Optional face-level deepfake detector; must provide `.detect(pil_image) -> bool|float|None`.
|
| 33 |
+
face_detector: Optional face detector; must provide `.detect(pil_image) -> list[FaceMainPoints]`.
|
| 34 |
+
news_detector: Optional fake-news/text detector; must provide `.detect(text) -> bool|float|None`.
|
| 35 |
+
ai_image_detector: Optional AI-image detector; must provide `.detect(pil_image) -> bool|float|None`.
|
| 36 |
+
query_extractor: Optional extractor that returns list[str] from text.
|
| 37 |
+
emotion_detector: Optional emotion detector; must provide `.analyze(text)`.
|
| 38 |
+
fact_checker: Optional fact-check service; must provide `.verify_claim(query)`.
|
| 39 |
+
"""
|
| 40 |
+
|
| 41 |
+
ai_text_detector: Optional[AITextDetector]
|
| 42 |
+
fake_face_detector: Optional[FakeFaceDetector]
|
| 43 |
+
face_detector: Optional[FaceDetector]
|
| 44 |
+
news_detector: Optional[FakeNewsDetector]
|
| 45 |
+
ai_image_detector: Optional[AIImageDetector]
|
| 46 |
+
query_extractor: Optional[SearchQueryExtractor]
|
| 47 |
+
emotion_detector: Optional[EmotionDetector]
|
| 48 |
+
fact_checker: Optional[FactCheckService]
|
| 49 |
+
|
| 50 |
+
def __init__(
|
| 51 |
+
self,
|
| 52 |
+
*,
|
| 53 |
+
ai_text_detector: Optional[AITextDetector] = None,
|
| 54 |
+
fake_face_detector: Optional[FakeFaceDetector] = None,
|
| 55 |
+
face_detector: Optional[FaceDetector] = None,
|
| 56 |
+
news_detector: Optional[FakeNewsDetector] = None,
|
| 57 |
+
ai_image_detector: Optional[AIImageDetector] = None,
|
| 58 |
+
query_extractor: Optional[SearchQueryExtractor] = None,
|
| 59 |
+
emotion_detector: Optional[EmotionDetector] = None,
|
| 60 |
+
fact_checker: Optional[FactCheckService] = None,
|
| 61 |
+
) -> None:
|
| 62 |
+
"""Create a FakeNewsManager.
|
| 63 |
+
|
| 64 |
+
All parameters are optional; missing detectors/services are simply skipped
|
| 65 |
+
during analysis. Types are intentionally permissive to accommodate a
|
| 66 |
+
variety of detector implementations used in this project.
|
| 67 |
+
"""
|
| 68 |
+
self.ai_text_detector = ai_text_detector
|
| 69 |
+
self.fake_face_detector = fake_face_detector
|
| 70 |
+
self.face_detector = face_detector
|
| 71 |
+
self.news_detector = news_detector
|
| 72 |
+
self.ai_image_detector = ai_image_detector
|
| 73 |
+
self.query_extractor = query_extractor
|
| 74 |
+
self.emotion_detector = emotion_detector
|
| 75 |
+
self.fact_checker = fact_checker
|
| 76 |
+
|
| 77 |
+
def test(self) -> None:
|
| 78 |
+
"""Lightweight method used for quick smoke tests.
|
| 79 |
+
|
| 80 |
+
Intended for interactive debugging only; it prints a short marker.
|
| 81 |
+
"""
|
| 82 |
+
print("test")
|
| 83 |
+
|
| 84 |
+
def _crop_face(self, img: PILImage, face_mp: FaceMainPoints) -> PILImage:
|
| 85 |
+
"""Crop a face region from a PIL image using coordinates from
|
| 86 |
+
a `FaceMainPoints` object.
|
| 87 |
+
|
| 88 |
+
Args:
|
| 89 |
+
img: PIL.Image instance to crop from.
|
| 90 |
+
face_mp: FaceMainPoints providing `box_start_point` and
|
| 91 |
+
`box_end_point` coordinates as (x, y) tuples.
|
| 92 |
+
|
| 93 |
+
Returns:
|
| 94 |
+
A new PIL.Image containing only the cropped face region.
|
| 95 |
+
"""
|
| 96 |
+
x1, y1 = face_mp.box_start_point
|
| 97 |
+
x2, y2 = face_mp.box_end_point
|
| 98 |
+
return img.crop((x1, y1, x2, y2))
|
| 99 |
+
|
| 100 |
+
def analyze(
|
| 101 |
+
self,
|
| 102 |
+
news: News,
|
| 103 |
+
fakeness_score_threshold: float = 0.6,
|
| 104 |
+
) -> AggregatedNewsAnalysis:
|
| 105 |
+
"""Analyze a `News` item and return an `AggregatedNewsAnalysis`.
|
| 106 |
+
|
| 107 |
+
The method coordinates text and image analyzers, runs optional
|
| 108 |
+
fact-checking on extracted queries, and constructs an
|
| 109 |
+
`AggregatedNewsAnalysis` object that summarizes all results.
|
| 110 |
+
|
| 111 |
+
Args:
|
| 112 |
+
news: `News` object containing `text` (str) and `images` (list of
|
| 113 |
+
paths or file-like objects) to analyze.
|
| 114 |
+
fakeness_score_threshold: Float threshold in [0, 1] used by the
|
| 115 |
+
aggregated analysis to decide the final `is_fake_final_decision`.
|
| 116 |
+
|
| 117 |
+
Returns:
|
| 118 |
+
AggregatedNewsAnalysis populated with detector outputs and a
|
| 119 |
+
computed final decision.
|
| 120 |
+
"""
|
| 121 |
+
# Text detectors
|
| 122 |
+
is_ai_text = self.ai_text_detector.detect(news.text) if self.ai_text_detector else None
|
| 123 |
+
is_fake_text = self.news_detector.detect(news.text) if self.news_detector else None
|
| 124 |
+
|
| 125 |
+
# Query extraction & emotion
|
| 126 |
+
queries: List[str] = self.query_extractor.extract(news.text) if self.query_extractor else []
|
| 127 |
+
emotion = self.emotion_detector.analyze(news.text) if self.emotion_detector else None
|
| 128 |
+
|
| 129 |
+
# Run fact-checking for each extracted query; if no queries, fall back to full text
|
| 130 |
+
fact_check: Optional[List[object]] = None
|
| 131 |
+
if self.fact_checker:
|
| 132 |
+
fact_check = []
|
| 133 |
+
targets = queries if queries else [news.text]
|
| 134 |
+
for q in targets:
|
| 135 |
+
res = self.fact_checker.verify_claim(q)
|
| 136 |
+
if res is not None:
|
| 137 |
+
fact_check.append(res)
|
| 138 |
+
|
| 139 |
+
# Image-level analysis
|
| 140 |
+
images_analysis: List[ImageAnalysis] = []
|
| 141 |
+
for img_in in news.images:
|
| 142 |
+
img = open_image(img_in)
|
| 143 |
+
|
| 144 |
+
faces = self.face_detector.detect(img) if self.face_detector else []
|
| 145 |
+
is_ai_image = self.ai_image_detector.detect(img) if self.ai_image_detector else False
|
| 146 |
+
|
| 147 |
+
deepfake_faces: List[bool] = []
|
| 148 |
+
if self.fake_face_detector and faces:
|
| 149 |
+
for f in faces:
|
| 150 |
+
face_img = self._crop_face(img, f)
|
| 151 |
+
deepfake_faces.append(bool(self.fake_face_detector.detect(face_img)))
|
| 152 |
+
|
| 153 |
+
# Ensure image_path is a string as required by schema
|
| 154 |
+
if isinstance(img_in, (str, Path)):
|
| 155 |
+
image_path = str(img_in)
|
| 156 |
+
else:
|
| 157 |
+
image_path = ""
|
| 158 |
+
|
| 159 |
+
images_analysis.append(
|
| 160 |
+
ImageAnalysis(
|
| 161 |
+
image_path=image_path,
|
| 162 |
+
is_ai_image=is_ai_image,
|
| 163 |
+
faces=faces,
|
| 164 |
+
deepfake_faces=deepfake_faces,
|
| 165 |
+
)
|
| 166 |
+
)
|
| 167 |
+
|
| 168 |
+
aggregated_news_analysis = AggregatedNewsAnalysis(
|
| 169 |
+
is_fake_final_decision=None,
|
| 170 |
+
analysis_timestamp=datetime.now().isoformat(),
|
| 171 |
+
analysis_id=str(uuid.uuid4()),
|
| 172 |
+
text=news.text,
|
| 173 |
+
is_ai_text=is_ai_text,
|
| 174 |
+
is_fake_text=is_fake_text,
|
| 175 |
+
queries=queries,
|
| 176 |
+
emotion=emotion,
|
| 177 |
+
fact_check=fact_check,
|
| 178 |
+
images=images_analysis,
|
| 179 |
+
)
|
| 180 |
+
|
| 181 |
+
# Compute final decision using the AggregatedNewsAnalysis helper
|
| 182 |
+
aggregated_news_analysis.is_fake_final_decision = (
|
| 183 |
+
aggregated_news_analysis.compute_final_decision(fakeness_score_threshold)
|
| 184 |
+
)
|
| 185 |
+
|
| 186 |
+
return aggregated_news_analysis
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
|
demo/demo.mp4
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:37c5c42334c060547544c1066c4b1eeaadaf56b77335ef574db8eda4c61085c8
|
| 3 |
+
size 27348551
|
demo/demo1.png
ADDED
|
demo/demo2.png
ADDED
|
Git LFS Details
|
demo/demo3.png
ADDED
|
demo/demo4.png
ADDED
|
Git LFS Details
|
demo/demo5.png
ADDED
|
Git LFS Details
|
demo/demo6.png
ADDED
|
demo/demo7.png
ADDED
|
models/Meso4_DF.h5
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:036ed30d04ffd9c7506f825b97c86aa64915f5818931da96817b169aad790af5
|
| 3 |
+
size 156128
|
models/Meso4_F2F.h5
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:bcfda015d217b9f8d6557693edbdf9f4e79cfd22672541968dd0e2954462b5d7
|
| 3 |
+
size 156128
|
models/ai_text_detector.joblib
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:946bba1b68f35d1e95e5245105fc4d1a991f8293df5c3407f7cfa723d359d400
|
| 3 |
+
size 436909018
|
models/efficientnet_b3_full_ai_image_classifier.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:fb6c6ede00a59848ccf176fd1207d421ae8ba7236daa7fe1ac07bfee3e549640
|
| 3 |
+
size 43455736
|
models/face_det_10g.onnx
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:5838f7fe053675b1c7a08b633df49e7af5495cee0493c7dcf6697200b85b5b91
|
| 3 |
+
size 16923827
|
models/fake_news_detector.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:6b6dd44b8663bfd062324914918e66c77c419335891c9921e7797f0ef88a920f
|
| 3 |
+
size 4947968
|
models/models.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import torch.nn as nn
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
class LSTMClassifier(nn.Module):
|
| 6 |
+
def __init__(self, vocab_size, embedding_dim=100, hidden_dim=128,
|
| 7 |
+
num_layers=1, dropout=0.5, bidirectional=True):
|
| 8 |
+
super().__init__()
|
| 9 |
+
self.embedding = nn.Embedding(vocab_size, embedding_dim, padding_idx=0)
|
| 10 |
+
self.lstm = nn.LSTM(
|
| 11 |
+
embedding_dim, hidden_dim, num_layers,
|
| 12 |
+
batch_first=True,
|
| 13 |
+
bidirectional=bidirectional,
|
| 14 |
+
dropout=dropout if num_layers > 1 else 0
|
| 15 |
+
)
|
| 16 |
+
self.dropout = nn.Dropout(dropout)
|
| 17 |
+
self.fc = nn.Linear(hidden_dim * (2 if bidirectional else 1), 1)
|
| 18 |
+
self.sigmoid = nn.Sigmoid()
|
| 19 |
+
|
| 20 |
+
def forward(self, x):
|
| 21 |
+
x = self.embedding(x)
|
| 22 |
+
out, _ = self.lstm(x)
|
| 23 |
+
last = out[:, -1, :]
|
| 24 |
+
out = self.dropout(last)
|
| 25 |
+
out = self.fc(out)
|
| 26 |
+
return self.sigmoid(out).squeeze()
|
| 27 |
+
|
models/word2idx.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:7bde7101ae6d75cbb8213dac33de55ed1de4e86bfc825e1ef9b549ef10b6e356
|
| 3 |
+
size 201444
|
notebooks/ai-generated-vs-human-image-99-f1-score.ipynb
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
notebooks/ai-vs-human-text-classifier-99-f1-score.ipynb
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
notebooks/fake-news-lstm-classifier-f1-score-99.ipynb
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
requirements.txt
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
numpy==2.3.3
|
| 2 |
+
scikit-learn==1.7.1
|
| 3 |
+
joblib==1.5.2
|
| 4 |
+
onnxruntime==1.22.1
|
| 5 |
+
timm==1.0.15
|
| 6 |
+
torch==2.8.0
|
| 7 |
+
torchvision==0.23.0
|
| 8 |
+
Pillow==11.3.0
|
| 9 |
+
scrfd==0.3.0
|
| 10 |
+
nltk==3.9.1
|
| 11 |
+
contractions==0.1.73
|
| 12 |
+
transformers==4.57.0
|
| 13 |
+
flask==3.1.1
|
schemas/fact_search_schemas.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from dataclasses import dataclass, field
|
| 2 |
+
from typing import List, Optional
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
@dataclass
|
| 7 |
+
class FactCheckEntry:
|
| 8 |
+
"""Represents a single fact-check result from a verified source."""
|
| 9 |
+
text: str
|
| 10 |
+
claimant: Optional[str]
|
| 11 |
+
claim_date: Optional[str]
|
| 12 |
+
rating: Optional[str]
|
| 13 |
+
publisher: Optional[str]
|
| 14 |
+
url: Optional[str]
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
@dataclass
|
| 18 |
+
class FactCheckResult:
|
| 19 |
+
"""Structured result returned by the Fact Check Tools API."""
|
| 20 |
+
verified: bool
|
| 21 |
+
summary_verdict: str
|
| 22 |
+
results: List[FactCheckEntry] = field(default_factory=list)
|
| 23 |
+
|
| 24 |
+
def is_fake(self) -> bool:
|
| 25 |
+
"""Return True if the aggregated verdict indicates likely false information."""
|
| 26 |
+
return self.summary_verdict.lower() in ["false", "likely false", "incorrect"]
|
| 27 |
+
|
| 28 |
+
def summary(self) -> str:
|
| 29 |
+
"""Return a human-readable summary for quick inspection."""
|
| 30 |
+
publishers = {r.publisher for r in self.results if r.publisher}
|
| 31 |
+
return (
|
| 32 |
+
f"✅ Verified: {self.verified}\n"
|
| 33 |
+
f"🧾 Summary Verdict: {self.summary_verdict}\n"
|
| 34 |
+
f"📚 Sources: {', '.join(publishers) if publishers else 'N/A'}"
|
| 35 |
+
)
|
| 36 |
+
|
schemas/fake_manager_schemas.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from dataclasses import dataclass, asdict, is_dataclass
|
| 2 |
+
from typing import List, Optional, Any, Tuple, Union
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
import numpy as np
|
| 5 |
+
from PIL.Image import Image
|
| 6 |
+
from schemas.vision_schemas import FaceMainPoints
|
| 7 |
+
from schemas.text_schemas import EmotionResult
|
| 8 |
+
from schemas.fact_search_schemas import FactCheckResult
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
@dataclass
|
| 13 |
+
class News:
|
| 14 |
+
text: str
|
| 15 |
+
images: List[Union[str, Path, Image, np.ndarray]]
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@dataclass
|
| 20 |
+
class IsFakeNewsResult:
|
| 21 |
+
|
| 22 |
+
is_fake_text: bool
|
| 23 |
+
is_ai_text: bool
|
| 24 |
+
is_deepfake_faces: list[bool]
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@dataclass
|
| 30 |
+
class ImageAnalysis:
|
| 31 |
+
image_path: str
|
| 32 |
+
is_ai_image: bool
|
| 33 |
+
faces: List[FaceMainPoints]
|
| 34 |
+
deepfake_faces: List[bool]
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
@dataclass
|
| 39 |
+
class AggregatedNewsAnalysis:
|
| 40 |
+
is_fake_final_decision: bool
|
| 41 |
+
text: str
|
| 42 |
+
is_ai_text: Optional[bool]
|
| 43 |
+
is_fake_text: Optional[bool]
|
| 44 |
+
queries: List[str]
|
| 45 |
+
emotion: Optional["EmotionResult"]
|
| 46 |
+
fact_check: Optional[List["FactCheckResult"]]
|
| 47 |
+
images: List["ImageAnalysis"]
|
| 48 |
+
analysis_timestamp: str
|
| 49 |
+
analysis_id: str
|
| 50 |
+
|
| 51 |
+
# ---------- existing ----------
|
| 52 |
+
def to_json(self) -> dict:
|
| 53 |
+
def _convert(obj: Any) -> Any:
|
| 54 |
+
if is_dataclass(obj):
|
| 55 |
+
return {k: _convert(v) for k, v in asdict(obj).items()}
|
| 56 |
+
if isinstance(obj, tuple):
|
| 57 |
+
return list(obj)
|
| 58 |
+
if isinstance(obj, list):
|
| 59 |
+
return [_convert(x) for x in obj]
|
| 60 |
+
if isinstance(obj, dict):
|
| 61 |
+
return {k: _convert(v) for k, v in obj.items()}
|
| 62 |
+
return obj
|
| 63 |
+
return _convert(self)
|
| 64 |
+
|
| 65 |
+
# ---------- NEW: scoring helpers ----------
|
| 66 |
+
@staticmethod
|
| 67 |
+
def _score_fact_verdict(verdict: str) -> float:
|
| 68 |
+
"""Map summary_verdict to [0,1] fake-likelihood."""
|
| 69 |
+
if not verdict:
|
| 70 |
+
return 0.0
|
| 71 |
+
v = verdict.strip().lower()
|
| 72 |
+
table = {
|
| 73 |
+
"false": 1.0, "pants on fire": 1.0, "fake": 1.0,
|
| 74 |
+
"likely false": 0.9, "incorrect": 0.9, "misleading": 0.85, "deceptive": 0.85,
|
| 75 |
+
"unsubstantiated": 0.6, "missing context": 0.5,
|
| 76 |
+
"mixed": 0.5, "partly false": 0.7, "partly true": 0.3,
|
| 77 |
+
"mostly true": 0.1, "true": 0.0, "correct": 0.0
|
| 78 |
+
}
|
| 79 |
+
# fuzzy match by containment if exact key not present
|
| 80 |
+
for k, s in table.items():
|
| 81 |
+
if k in v:
|
| 82 |
+
return s
|
| 83 |
+
return 0.0
|
| 84 |
+
|
| 85 |
+
@staticmethod
|
| 86 |
+
def _score_fact_checks(fact_checks: Optional[List["FactCheckResult"]]) -> float:
|
| 87 |
+
if not fact_checks:
|
| 88 |
+
return 0.0
|
| 89 |
+
# take the strongest (max) verdict score across checks
|
| 90 |
+
return max((AggregatedNewsAnalysis._score_fact_verdict(fc.summary_verdict) for fc in fact_checks if fc and fc.summary_verdict), default=0.0)
|
| 91 |
+
|
| 92 |
+
@staticmethod
|
| 93 |
+
def _score_text(is_fake_text: Optional[bool], is_ai_text: Optional[bool]) -> float:
|
| 94 |
+
score = 0.0
|
| 95 |
+
if is_fake_text is True:
|
| 96 |
+
score += 0.7
|
| 97 |
+
elif is_fake_text is False:
|
| 98 |
+
score += 0.0 # explicit non-fake does not add risk
|
| 99 |
+
|
| 100 |
+
# AI text alone is NOT proof of fake; small nudge only
|
| 101 |
+
if is_ai_text is True:
|
| 102 |
+
score += 0.1
|
| 103 |
+
elif is_ai_text is False:
|
| 104 |
+
score += 0.0
|
| 105 |
+
return min(score, 1.0)
|
| 106 |
+
|
| 107 |
+
@staticmethod
|
| 108 |
+
def _score_images(images: List["ImageAnalysis"]) -> float:
|
| 109 |
+
if not images:
|
| 110 |
+
return 0.0
|
| 111 |
+
# per-image risk
|
| 112 |
+
per_image = []
|
| 113 |
+
for img in images:
|
| 114 |
+
s = 0.0
|
| 115 |
+
if getattr(img, "is_ai_image", False):
|
| 116 |
+
s = max(s, 0.5)
|
| 117 |
+
# deepfake face => strong signal
|
| 118 |
+
if any(bool(x) for x in getattr(img, "deepfake_faces", []) or []):
|
| 119 |
+
s = max(s, 0.8)
|
| 120 |
+
per_image.append(s)
|
| 121 |
+
|
| 122 |
+
if not per_image:
|
| 123 |
+
return 0.0
|
| 124 |
+
|
| 125 |
+
# Combine by "noisy OR": 1 - Π(1 - s_i)
|
| 126 |
+
from math import prod
|
| 127 |
+
return 1.0 - prod(1.0 - s for s in per_image)
|
| 128 |
+
|
| 129 |
+
@staticmethod
|
| 130 |
+
def _score_emotion(emotion: Optional["EmotionResult"]) -> float:
|
| 131 |
+
# Small bump only if highly sensational and confident
|
| 132 |
+
if not emotion:
|
| 133 |
+
return 0.0
|
| 134 |
+
dom = (emotion.dominant_emotion or "").strip().lower()
|
| 135 |
+
conf = float(getattr(emotion, "confidence", 0.0) or 0.0)
|
| 136 |
+
sensational = {"anger", "fear", "surprise", "disgust"}
|
| 137 |
+
if dom in sensational and conf >= 0.75:
|
| 138 |
+
return 0.1
|
| 139 |
+
return 0.0
|
| 140 |
+
|
| 141 |
+
def _active_weights(self, w_fact=0.5, w_text=0.3, w_img=0.15, w_emote=0.05) -> Tuple[float, float, float, float]:
|
| 142 |
+
"""Re-normalize weights if any component is missing."""
|
| 143 |
+
have_fact = bool(self.fact_check)
|
| 144 |
+
have_text = (self.is_fake_text is not None) or (self.is_ai_text is not None)
|
| 145 |
+
have_img = bool(self.images)
|
| 146 |
+
have_emo = bool(self.emotion)
|
| 147 |
+
|
| 148 |
+
parts = [
|
| 149 |
+
(w_fact if have_fact else 0.0),
|
| 150 |
+
(w_text if have_text else 0.0),
|
| 151 |
+
(w_img if have_img else 0.0),
|
| 152 |
+
(w_emote if have_emo else 0.0),
|
| 153 |
+
]
|
| 154 |
+
total = sum(parts) or 1.0
|
| 155 |
+
return tuple(p / total for p in parts) # normalized
|
| 156 |
+
|
| 157 |
+
def compute_final_score(self) -> tuple[float, dict]:
|
| 158 |
+
"""Return (score, breakdown) in [0,1]."""
|
| 159 |
+
wf, wt, wi, we = self._active_weights()
|
| 160 |
+
|
| 161 |
+
s_fact = self._score_fact_checks(self.fact_check)
|
| 162 |
+
s_text = self._score_text(self.is_fake_text, self.is_ai_text)
|
| 163 |
+
s_img = self._score_images(self.images)
|
| 164 |
+
s_emo = self._score_emotion(self.emotion)
|
| 165 |
+
|
| 166 |
+
score = wf * s_fact + wt * s_text + wi * s_img + we * s_emo
|
| 167 |
+
breakdown = {
|
| 168 |
+
"weights": {"fact": wf, "text": wt, "images": wi, "emotion": we},
|
| 169 |
+
"components": {"fact": s_fact, "text": s_text, "images": s_img, "emotion": s_emo},
|
| 170 |
+
"total": score
|
| 171 |
+
}
|
| 172 |
+
return score, breakdown
|
| 173 |
+
|
| 174 |
+
def compute_final_decision(self, threshold: float = 0.60) -> bool:
|
| 175 |
+
"""Set and return final decision based on weighted score."""
|
| 176 |
+
score, _ = self.compute_final_score()
|
| 177 |
+
self.is_fake_final_decision = bool(score >= threshold)
|
| 178 |
+
return self.is_fake_final_decision
|
schemas/text_schemas.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from abc import ABC, abstractmethod
|
| 2 |
+
from dataclasses import dataclass
|
| 3 |
+
from typing import Dict, List
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class AITextDetector(ABC):
|
| 7 |
+
"""
|
| 8 |
+
Abstract base class for all AI text detectors.
|
| 9 |
+
Defines the interface that all concrete detectors must implement.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
@abstractmethod
|
| 13 |
+
def detect(self, text: str) -> bool:
|
| 14 |
+
"""
|
| 15 |
+
Detect whether the given text is AI-generated.
|
| 16 |
+
|
| 17 |
+
Args:
|
| 18 |
+
text (str): The input text to analyze.
|
| 19 |
+
|
| 20 |
+
Returns:
|
| 21 |
+
bool: True if the text is AI-generated, False if it is human-written.
|
| 22 |
+
"""
|
| 23 |
+
pass
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class EmotionDetector(ABC):
|
| 27 |
+
"""Abstract base class for emotion detection."""
|
| 28 |
+
|
| 29 |
+
@abstractmethod
|
| 30 |
+
def analyze(self, text: str):
|
| 31 |
+
"""Analyze the emotion in the given text and return a structured result."""
|
| 32 |
+
pass
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@dataclass
|
| 36 |
+
class EmotionResult:
|
| 37 |
+
"""Structured output for detected emotions."""
|
| 38 |
+
dominant_emotion: str
|
| 39 |
+
confidence: float
|
| 40 |
+
all_scores: Dict[str, float]
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class SearchQueryExtractor(ABC):
|
| 44 |
+
"""Abstract base class for extracting search queries from text."""
|
| 45 |
+
|
| 46 |
+
@abstractmethod
|
| 47 |
+
def extract(self, text: str, num_queries: int = 5) -> List[str]:
|
| 48 |
+
"""
|
| 49 |
+
Extract search-like queries from a given paragraph.
|
| 50 |
+
|
| 51 |
+
Args:
|
| 52 |
+
text: The input text to extract queries from.
|
| 53 |
+
num_queries: Number of queries to generate.
|
| 54 |
+
|
| 55 |
+
Returns:
|
| 56 |
+
List[str]: A list of extracted search queries.
|
| 57 |
+
"""
|
| 58 |
+
pass
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
from abc import ABC, abstractmethod
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
# ===== Abstract base class =====
|
| 67 |
+
class FakeNewsDetector(ABC):
|
| 68 |
+
@abstractmethod
|
| 69 |
+
def detect(self, text: str) -> int:
|
| 70 |
+
"""Return 1 for real news, 0 for fake news."""
|
| 71 |
+
pass
|
schemas/vision_schemas.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from abc import ABC, abstractmethod
|
| 2 |
+
from dataclasses import dataclass
|
| 3 |
+
from typing import List, Tuple
|
| 4 |
+
from PIL import Image
|
| 5 |
+
from scrfd import Face
|
| 6 |
+
import numpy as np
|
| 7 |
+
import os
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class AIImageDetector(ABC):
|
| 12 |
+
"""
|
| 13 |
+
Abstract base class for AI image detectors.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
@abstractmethod
|
| 17 |
+
def detect(self, image: Image.Image) -> bool:
|
| 18 |
+
"""
|
| 19 |
+
Detect whether the given image is AI-generated.
|
| 20 |
+
|
| 21 |
+
Args:
|
| 22 |
+
image (PIL.Image.Image): The input image.
|
| 23 |
+
|
| 24 |
+
Returns:
|
| 25 |
+
bool: True if AI-generated, False if real.
|
| 26 |
+
"""
|
| 27 |
+
pass
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
@dataclass
|
| 31 |
+
class FaceMainPoints:
|
| 32 |
+
"""
|
| 33 |
+
The main points of the face.
|
| 34 |
+
|
| 35 |
+
Attributes:
|
| 36 |
+
box_start_point (Tuple[int, int]): The start point of the bounding box.
|
| 37 |
+
box_end_point (Tuple[int, int]): The end point of the bounding box.
|
| 38 |
+
box_probabilty_score (float): The probability score of the bounding box.
|
| 39 |
+
left_eye (Tuple[int, int], optional): The left eye coordinates. Defaults to (0, 0).
|
| 40 |
+
right_eye (Tuple[int, int], optional): The right eye coordinates. Defaults to (0, 0).
|
| 41 |
+
nose (Tuple[int, int], optional): The nose coordinates. Defaults to (0, 0).
|
| 42 |
+
left_mouth (Tuple[int, int], optional): The left mouth coordinates. Defaults to (0, 0).
|
| 43 |
+
right_mouth (Tuple[int, int], optional): The right mouth coordinates. Defaults to (0, 0).
|
| 44 |
+
"""
|
| 45 |
+
box_start_point: Tuple[int, int]
|
| 46 |
+
box_end_point: Tuple[int, int]
|
| 47 |
+
box_probabilty_score: float
|
| 48 |
+
left_eye: Tuple[int, int] = (0, 0)
|
| 49 |
+
right_eye: Tuple[int, int] = (0, 0)
|
| 50 |
+
nose: Tuple[int, int] = (0, 0)
|
| 51 |
+
left_mouth: Tuple[int, int] = (0, 0)
|
| 52 |
+
right_mouth: Tuple[int, int] = (0, 0)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
class FaceDetector(ABC):
|
| 56 |
+
"""
|
| 57 |
+
The face detector interface.
|
| 58 |
+
"""
|
| 59 |
+
|
| 60 |
+
@abstractmethod
|
| 61 |
+
def detect(self, image_path: str) -> List[FaceMainPoints]:
|
| 62 |
+
"""
|
| 63 |
+
Detect the faces in an image.
|
| 64 |
+
|
| 65 |
+
Args:
|
| 66 |
+
image_path (str): The path to the image.
|
| 67 |
+
|
| 68 |
+
Returns:
|
| 69 |
+
A list of FaceMainPoints objects, one for each face in the image.
|
| 70 |
+
"""
|
| 71 |
+
pass
|
| 72 |
+
|
| 73 |
+
@abstractmethod
|
| 74 |
+
def convert_face_to_face_main_points(self, face: Face) -> FaceMainPoints:
|
| 75 |
+
"""
|
| 76 |
+
Convert a Face object to a FaceMainPoints object.
|
| 77 |
+
|
| 78 |
+
Args:
|
| 79 |
+
face (Face): The face to convert.
|
| 80 |
+
|
| 81 |
+
Returns:
|
| 82 |
+
A FaceMainPoints object.
|
| 83 |
+
"""
|
| 84 |
+
pass
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
class FakeFaceDetector(ABC):
|
| 88 |
+
@abstractmethod
|
| 89 |
+
def detect(self, image: Image.Image, threshold: float = 0.5) -> bool:
|
| 90 |
+
"""
|
| 91 |
+
Base detector method.
|
| 92 |
+
Subclasses must implement this.
|
| 93 |
+
"""
|
| 94 |
+
raise NotImplementedError("Subclasses should implement this method.")
|
services/ai_image_service.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import torch
|
| 2 |
+
import timm
|
| 3 |
+
from torchvision import transforms
|
| 4 |
+
from PIL import Image
|
| 5 |
+
from typing import Any
|
| 6 |
+
from schemas.vision_schemas import AIImageDetector
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class ENetAIImageDetector(AIImageDetector):
|
| 10 |
+
"""
|
| 11 |
+
EfficientNet-B3 AI Image Detector that classifies whether an image
|
| 12 |
+
is AI-generated or real using a pre-trained PyTorch model.
|
| 13 |
+
|
| 14 |
+
Attributes:
|
| 15 |
+
model_path (str): Path to the trained model file (.pt).
|
| 16 |
+
model (Any): Loaded PyTorch model.
|
| 17 |
+
device (str): Device to run inference on ('cuda' or 'cpu').
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
def __init__(self, model_path: str = "./models/efficientnet_b3_full_ai_image_classifier.pt"):
|
| 21 |
+
"""
|
| 22 |
+
Initialize the ENetAIImageDetector.
|
| 23 |
+
|
| 24 |
+
Args:
|
| 25 |
+
model_path (str, optional): Path to the trained EfficientNet model.
|
| 26 |
+
"""
|
| 27 |
+
self.model_path = model_path
|
| 28 |
+
self.device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 29 |
+
self.model = self._load_model()
|
| 30 |
+
self.transform = self._build_transform()
|
| 31 |
+
|
| 32 |
+
def _load_model(self) -> Any:
|
| 33 |
+
"""Load the trained EfficientNet-B3 model."""
|
| 34 |
+
if self.model_path.endswith(".pt"):
|
| 35 |
+
model = torch.load(self.model_path, map_location=self.device, weights_only=False)
|
| 36 |
+
else:
|
| 37 |
+
model = timm.create_model("efficientnet_b3", pretrained=False, num_classes=1)
|
| 38 |
+
model.load_state_dict(torch.load(self.model_path, map_location=self.device))
|
| 39 |
+
model.to(self.device)
|
| 40 |
+
model.eval()
|
| 41 |
+
return model
|
| 42 |
+
|
| 43 |
+
def _build_transform(self) -> Any:
|
| 44 |
+
"""Return preprocessing pipeline for input images."""
|
| 45 |
+
return transforms.Compose([
|
| 46 |
+
transforms.Resize((300, 300)),
|
| 47 |
+
transforms.ToTensor(),
|
| 48 |
+
transforms.Normalize(mean=[0.485, 0.456, 0.406],
|
| 49 |
+
std=[0.229, 0.224, 0.225]),
|
| 50 |
+
])
|
| 51 |
+
|
| 52 |
+
def _preprocess_image(self, image: Image.Image) -> torch.Tensor:
|
| 53 |
+
"""Convert a PIL Image to a normalized tensor."""
|
| 54 |
+
return self.transform(image).unsqueeze(0).to(self.device)
|
| 55 |
+
|
| 56 |
+
def detect(self, image: Image.Image) -> bool:
|
| 57 |
+
"""
|
| 58 |
+
Detect whether a given PIL image is AI-generated.
|
| 59 |
+
|
| 60 |
+
Args:
|
| 61 |
+
image (PIL.Image.Image): The input image.
|
| 62 |
+
|
| 63 |
+
Returns:
|
| 64 |
+
bool: True if AI-generated, False if real.
|
| 65 |
+
"""
|
| 66 |
+
if not isinstance(image, Image.Image):
|
| 67 |
+
raise TypeError("Input must be a PIL.Image.Image object.")
|
| 68 |
+
|
| 69 |
+
img_tensor = self._preprocess_image(image)
|
| 70 |
+
|
| 71 |
+
with torch.no_grad():
|
| 72 |
+
outputs = self.model(img_tensor)
|
| 73 |
+
prob = torch.sigmoid(outputs).item()
|
| 74 |
+
|
| 75 |
+
is_ai_generated = prob >= 0.001
|
| 76 |
+
return is_ai_generated
|
services/ai_text_service.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import joblib
|
| 2 |
+
import re
|
| 3 |
+
import string
|
| 4 |
+
from typing import Any
|
| 5 |
+
from nltk.corpus import stopwords
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
from schemas.text_schemas import AITextDetector
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class NBAITextDetector(AITextDetector):
|
| 12 |
+
"""
|
| 13 |
+
Naive Bayes AI Text Detector that classifies whether a text
|
| 14 |
+
is AI-generated or human-written using a pre-trained joblib model.
|
| 15 |
+
|
| 16 |
+
Attributes:
|
| 17 |
+
model_path (str): Path to the saved joblib model.
|
| 18 |
+
model (Any): Loaded ML model for prediction.
|
| 19 |
+
min_words (int): Minimum number of words required for valid detection.
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
def __init__(self, model_path: str = "./models/ai_text_detector.joblib", min_words: int = 0):
|
| 23 |
+
"""
|
| 24 |
+
Initialize the NBAITextDetector.
|
| 25 |
+
|
| 26 |
+
Args:
|
| 27 |
+
model_path (str, optional): Path to the trained joblib model file.
|
| 28 |
+
min_words (int, optional): Minimum number of words for detection.
|
| 29 |
+
"""
|
| 30 |
+
self.model_path = model_path
|
| 31 |
+
self.min_words = min_words
|
| 32 |
+
self.model = self._load_model()
|
| 33 |
+
self.stop_words = set(stopwords.words('english'))
|
| 34 |
+
|
| 35 |
+
def _load_model(self) -> Any:
|
| 36 |
+
"""Load the trained joblib model."""
|
| 37 |
+
return joblib.load(self.model_path)
|
| 38 |
+
|
| 39 |
+
def _preprocess_text(self, text: str) -> str:
|
| 40 |
+
"""Clean and preprocess the input text."""
|
| 41 |
+
text = text.lower()
|
| 42 |
+
text = re.sub(r'\s+', ' ', text.strip())
|
| 43 |
+
text = text.translate(str.maketrans('', '', string.punctuation))
|
| 44 |
+
text = ' '.join(word for word in text.split() if word not in self.stop_words)
|
| 45 |
+
text = re.sub(r'http\S+|www\.\S+', '', text)
|
| 46 |
+
text = re.sub(r'\S+@\S+\.\S+', '', text)
|
| 47 |
+
text = re.sub(r'#[A-Za-z0-9_]+', '', text)
|
| 48 |
+
text = re.sub(r'@[A-Za-z0-9_]+', '', text)
|
| 49 |
+
text = re.sub(r'\d+', '', text)
|
| 50 |
+
text = ''.join(ch for ch in text if ch.isprintable())
|
| 51 |
+
return text
|
| 52 |
+
|
| 53 |
+
def detect(self, text: str) -> bool:
|
| 54 |
+
"""
|
| 55 |
+
Detect whether a given text is AI-generated.
|
| 56 |
+
|
| 57 |
+
Args:
|
| 58 |
+
text (str): Input text to classify.
|
| 59 |
+
|
| 60 |
+
Returns:
|
| 61 |
+
bool: True if AI-generated, False if human-written.
|
| 62 |
+
"""
|
| 63 |
+
if len(text.split()) < self.min_words:
|
| 64 |
+
raise ValueError(f"Text must be at least {self.min_words} words long.")
|
| 65 |
+
|
| 66 |
+
processed = self._preprocess_text(text)
|
| 67 |
+
prediction = self.model.predict([processed])
|
| 68 |
+
return bool(int(prediction[0]) == 1)
|
services/deepfake_service.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from PIL import Image
|
| 2 |
+
|
| 3 |
+
from schemas.vision_schemas import FakeFaceDetector
|
| 4 |
+
|
| 5 |
+
import cv2
|
| 6 |
+
import numpy as np
|
| 7 |
+
from tensorflow.keras.models import Sequential
|
| 8 |
+
from tensorflow.keras.layers import Conv2D, BatchNormalization, MaxPooling2D, Flatten, Dropout, Dense
|
| 9 |
+
from tensorflow.keras.preprocessing.image import img_to_array
|
| 10 |
+
from PIL import Image
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class Meso4FakeFaceDetector(FakeFaceDetector):
|
| 14 |
+
"""
|
| 15 |
+
Deepfake face detector using Meso4 models trained on DF and F2F datasets.
|
| 16 |
+
Combines both models' predictions for a more robust detection.
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
def __init__(self,
|
| 20 |
+
df_model_path: str = "models/Meso4_DF.h5",
|
| 21 |
+
f2f_model_path: str = "models/Meso4_F2F.h5",
|
| 22 |
+
input_shape=(256, 256, 3)):
|
| 23 |
+
"""
|
| 24 |
+
Initialize and load both pretrained models.
|
| 25 |
+
|
| 26 |
+
Args:
|
| 27 |
+
df_model_path (str): Path to Meso4 model trained on DeepFake dataset.
|
| 28 |
+
f2f_model_path (str): Path to Meso4 model trained on Face2Face dataset.
|
| 29 |
+
input_shape (tuple): Expected input shape for the models.
|
| 30 |
+
"""
|
| 31 |
+
self.df_model_path = df_model_path
|
| 32 |
+
self.f2f_model_path = f2f_model_path
|
| 33 |
+
self.input_shape = input_shape
|
| 34 |
+
|
| 35 |
+
# Build both models
|
| 36 |
+
self.model_df = self._build_meso4()
|
| 37 |
+
self.model_f2f = self._build_meso4()
|
| 38 |
+
|
| 39 |
+
# Load pretrained weights
|
| 40 |
+
self.model_df.load_weights(self.df_model_path)
|
| 41 |
+
self.model_f2f.load_weights(self.f2f_model_path)
|
| 42 |
+
|
| 43 |
+
# ------------------ Internal Model Builder ------------------
|
| 44 |
+
|
| 45 |
+
def _build_meso4(self):
|
| 46 |
+
"""Build the Meso4 CNN model architecture."""
|
| 47 |
+
model = Sequential()
|
| 48 |
+
|
| 49 |
+
model.add(Conv2D(8, (3, 3), activation='relu', padding='same', input_shape=self.input_shape))
|
| 50 |
+
model.add(BatchNormalization())
|
| 51 |
+
model.add(MaxPooling2D(pool_size=(2, 2), strides=2))
|
| 52 |
+
|
| 53 |
+
model.add(Conv2D(8, (5, 5), activation='relu', padding='same'))
|
| 54 |
+
model.add(BatchNormalization())
|
| 55 |
+
model.add(MaxPooling2D(pool_size=(2, 2), strides=2))
|
| 56 |
+
|
| 57 |
+
model.add(Conv2D(16, (5, 5), activation='relu', padding='same'))
|
| 58 |
+
model.add(BatchNormalization())
|
| 59 |
+
model.add(MaxPooling2D(pool_size=(2, 2), strides=2))
|
| 60 |
+
|
| 61 |
+
model.add(Conv2D(16, (5, 5), activation='relu', padding='same'))
|
| 62 |
+
model.add(BatchNormalization())
|
| 63 |
+
model.add(MaxPooling2D(pool_size=(4, 4), strides=4))
|
| 64 |
+
|
| 65 |
+
model.add(Flatten())
|
| 66 |
+
model.add(Dropout(0.5))
|
| 67 |
+
model.add(Dense(16, activation='relu'))
|
| 68 |
+
model.add(Dense(1, activation='sigmoid'))
|
| 69 |
+
|
| 70 |
+
model.compile(optimizer='adam', loss='binary_crossentropy', metrics=['accuracy'])
|
| 71 |
+
return model
|
| 72 |
+
|
| 73 |
+
# ------------------ Image Preprocessing ------------------
|
| 74 |
+
|
| 75 |
+
def _preprocess_image(self, image: Image.Image) -> np.ndarray:
|
| 76 |
+
"""
|
| 77 |
+
Preprocess a PIL image for prediction.
|
| 78 |
+
|
| 79 |
+
Args:
|
| 80 |
+
image (PIL.Image): Input face image.
|
| 81 |
+
|
| 82 |
+
Returns:
|
| 83 |
+
np.ndarray: Preprocessed image ready for model input.
|
| 84 |
+
"""
|
| 85 |
+
img = image.convert("RGB")
|
| 86 |
+
img = img.resize((256, 256))
|
| 87 |
+
img = img_to_array(img) / 255.0
|
| 88 |
+
img = np.expand_dims(img, axis=0)
|
| 89 |
+
return img
|
| 90 |
+
|
| 91 |
+
# ------------------ Detection ------------------
|
| 92 |
+
|
| 93 |
+
def detect(self, image: Image.Image, threshold: float = 0.5, verbose=True) -> bool:
|
| 94 |
+
"""
|
| 95 |
+
Detect whether an input face image is fake or real.
|
| 96 |
+
|
| 97 |
+
Args:
|
| 98 |
+
image (PIL.Image): Input image of a face.
|
| 99 |
+
threshold (float): Decision threshold (default 0.5).
|
| 100 |
+
|
| 101 |
+
Returns:
|
| 102 |
+
bool: True if fake, False if real.
|
| 103 |
+
"""
|
| 104 |
+
img = self._preprocess_image(image)
|
| 105 |
+
|
| 106 |
+
# Get individual predictions
|
| 107 |
+
pred_df = float(self.model_df.predict(img, verbose=0)[0][0])
|
| 108 |
+
pred_f2f = float(self.model_f2f.predict(img, verbose=0)[0][0])
|
| 109 |
+
|
| 110 |
+
# Combine predictions (average)
|
| 111 |
+
combined_pred = (pred_df + pred_f2f) / 2.0
|
| 112 |
+
|
| 113 |
+
if verbose:
|
| 114 |
+
# Print detailed info
|
| 115 |
+
print(f"🔍 Meso4_DF Prediction: {pred_df:.4f}")
|
| 116 |
+
print(f"🔍 Meso4_F2F Prediction: {pred_f2f:.4f}")
|
| 117 |
+
print(f"⚖️ Combined Score: {combined_pred:.4f}")
|
| 118 |
+
|
| 119 |
+
# Determine if fake
|
| 120 |
+
is_fake = combined_pred >= threshold
|
| 121 |
+
|
| 122 |
+
if is_fake:
|
| 123 |
+
print("🔴 Deepfake detected.")
|
| 124 |
+
else:
|
| 125 |
+
print("🟢 Real face detected.")
|
| 126 |
+
|
| 127 |
+
return is_fake
|
services/face_detection_service.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from scrfd import SCRFD, Threshold, Face
|
| 2 |
+
from PIL.Image import Image
|
| 3 |
+
import numpy as np
|
| 4 |
+
from typing import List, Union
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
import os
|
| 7 |
+
|
| 8 |
+
from schemas.vision_schemas import FaceMainPoints, FaceDetector
|
| 9 |
+
from utils.utils import open_image
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class SCRFDFaceDetector(FaceDetector):
|
| 13 |
+
"""
|
| 14 |
+
A face detector using the SCRFD model.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
def __init__(self, model_path: str, threshold_probability: float, nms:float):
|
| 18 |
+
"""
|
| 19 |
+
Initializes the face detector.
|
| 20 |
+
|
| 21 |
+
Args:
|
| 22 |
+
model_path: The path to the SCRFD model.
|
| 23 |
+
threshold_probability: The probability threshold for face detection.
|
| 24 |
+
nms: The NMS threshold for face detection.
|
| 25 |
+
"""
|
| 26 |
+
self.model = SCRFD.from_path(model_path)
|
| 27 |
+
self.threshold = Threshold(probability=threshold_probability, nms=nms)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def convert_face_to_face_main_points(self, face: Face) -> FaceMainPoints:
|
| 31 |
+
"""
|
| 32 |
+
Converts a Face object to a FaceMainPoints object.
|
| 33 |
+
|
| 34 |
+
Args:
|
| 35 |
+
face: The face to convert.
|
| 36 |
+
|
| 37 |
+
Returns:
|
| 38 |
+
A FaceMainPoints object.
|
| 39 |
+
"""
|
| 40 |
+
return FaceMainPoints(
|
| 41 |
+
box_start_point=(face.bbox.upper_left.x, face.bbox.upper_left.y,),
|
| 42 |
+
box_end_point=(face.bbox.lower_right.x, face.bbox.lower_right.y),
|
| 43 |
+
box_probabilty_score=face.probability,
|
| 44 |
+
left_eye=(face.keypoints.left_eye.x, face.keypoints.left_eye.y),
|
| 45 |
+
right_eye=(face.keypoints.right_eye.x, face.keypoints.right_eye.y),
|
| 46 |
+
nose=(face.keypoints.nose.x, face.keypoints.nose.y),
|
| 47 |
+
left_mouth=(face.keypoints.left_mouth.x, face.keypoints.left_mouth.y),
|
| 48 |
+
right_mouth=(face.keypoints.right_mouth.x, face.keypoints.right_mouth.y)
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def detect(self, image: Union[str, Path, np.ndarray, Image]) -> List[FaceMainPoints]:
|
| 53 |
+
"""
|
| 54 |
+
Detect faces in an image and return their main points.
|
| 55 |
+
|
| 56 |
+
Args:
|
| 57 |
+
image: Input image that can be:
|
| 58 |
+
- str: Path to image file
|
| 59 |
+
- Path: Path object to image file
|
| 60 |
+
- np.ndarray: Image array (BGR or RGB)
|
| 61 |
+
- PILImage.Image: PIL Image object
|
| 62 |
+
|
| 63 |
+
Returns:
|
| 64 |
+
List[FaceMainPoints]: List of face detections, each containing:
|
| 65 |
+
- Bounding box coordinates
|
| 66 |
+
- Key facial landmarks (eyes, nose, mouth)
|
| 67 |
+
- Confidence score
|
| 68 |
+
|
| 69 |
+
Note:
|
| 70 |
+
- Uses a threshold set during initialization for detection confidence
|
| 71 |
+
- Returns an empty list if no faces are detected
|
| 72 |
+
- Image is automatically converted to RGB format
|
| 73 |
+
"""
|
| 74 |
+
image = open_image(image).convert("RGB")
|
| 75 |
+
|
| 76 |
+
extracted_faces = self.model.detect(image, threshold=self.threshold)
|
| 77 |
+
|
| 78 |
+
faces = [self.convert_face_to_face_main_points(extracted_face) for extracted_face in extracted_faces]
|
| 79 |
+
|
| 80 |
+
return faces
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def extract_face_from_image(self, img, face_main_points:FaceMainPoints):
|
| 84 |
+
"""
|
| 85 |
+
Extract a face from an image using FaceMainPoints coordinates.
|
| 86 |
+
|
| 87 |
+
Args:
|
| 88 |
+
img: Input PIL Image
|
| 89 |
+
face_main_points: FaceMainPoints object containing face coordinates
|
| 90 |
+
|
| 91 |
+
Returns:
|
| 92 |
+
PIL.Image: Cropped face image
|
| 93 |
+
"""
|
| 94 |
+
start_x, start_y = face_main_points.box_start_point
|
| 95 |
+
end_x, end_y = face_main_points.box_end_point
|
| 96 |
+
|
| 97 |
+
return img.crop((start_x, start_y, end_x, end_y))
|
| 98 |
+
|
| 99 |
+
def cut_extracted_faces(self, image: Union[str, Path, np.ndarray, Image],
|
| 100 |
+
save_path: str = None) -> List[Image]:
|
| 101 |
+
"""
|
| 102 |
+
Detect and extract all faces from an image, optionally saving them.
|
| 103 |
+
|
| 104 |
+
Args:
|
| 105 |
+
image: Input image that can be:
|
| 106 |
+
- str: Path to image file
|
| 107 |
+
- Path: Path object to image file
|
| 108 |
+
- np.ndarray: Image array (BGR or RGB)
|
| 109 |
+
- PILImage.Image: PIL Image object
|
| 110 |
+
save_path: Optional path to save extracted faces
|
| 111 |
+
If provided, faces will be saved as 'face_{index}.jpg' in this directory
|
| 112 |
+
|
| 113 |
+
Returns:
|
| 114 |
+
List[PIL.Image]: List of cropped face images
|
| 115 |
+
"""
|
| 116 |
+
# Convert input to PIL Image
|
| 117 |
+
img = open_image(image)
|
| 118 |
+
|
| 119 |
+
# Detect faces and get their main points
|
| 120 |
+
faces_information = self.detect(img)
|
| 121 |
+
|
| 122 |
+
if not faces_information:
|
| 123 |
+
return [] # Return empty list if no faces detected
|
| 124 |
+
|
| 125 |
+
# Extract and optionally save each face
|
| 126 |
+
extracted_faces = []
|
| 127 |
+
for i, face_info in enumerate(faces_information):
|
| 128 |
+
face_image = self.extract_face_from_image(img, face_info)
|
| 129 |
+
extracted_faces.append(face_image)
|
| 130 |
+
|
| 131 |
+
# Save face if save_path is provided
|
| 132 |
+
if save_path:
|
| 133 |
+
os.makedirs(save_path, exist_ok=True)
|
| 134 |
+
face_path = os.path.join(save_path, f'face_{i}.jpg')
|
| 135 |
+
face_image.save(face_path)
|
| 136 |
+
|
| 137 |
+
return extracted_faces
|
services/fact_search_service.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import requests
|
| 2 |
+
|
| 3 |
+
from schemas.fact_search_schemas import FactCheckEntry, FactCheckResult
|
| 4 |
+
|
| 5 |
+
class FactCheckService:
|
| 6 |
+
"""
|
| 7 |
+
A service wrapper around the Google Fact Check Tools API (v1alpha1).
|
| 8 |
+
|
| 9 |
+
Provides an interface to verify claims and obtain structured verdicts from
|
| 10 |
+
fact-checking organizations (BBC, PolitiFact, FactCheck.org, etc.).
|
| 11 |
+
|
| 12 |
+
Example:
|
| 13 |
+
>>> service = FactCheckService(api_key="YOUR_API_KEY")
|
| 14 |
+
>>> result = service.verify_claim("COVID-19 vaccines cause infertility")
|
| 15 |
+
>>> print(result.summary())
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
BASE_URL = "https://factchecktools.googleapis.com/v1alpha1/claims:search"
|
| 19 |
+
|
| 20 |
+
def __init__(self, api_key: str, timeout: int = 10):
|
| 21 |
+
"""
|
| 22 |
+
Initialize the FactCheckService.
|
| 23 |
+
|
| 24 |
+
Args:
|
| 25 |
+
api_key (str): Google Fact Check Tools API key.
|
| 26 |
+
timeout (int): Optional. Request timeout in seconds.
|
| 27 |
+
"""
|
| 28 |
+
self.api_key = api_key
|
| 29 |
+
self.timeout = timeout
|
| 30 |
+
|
| 31 |
+
def verify_claim(self, claim_text: str) -> FactCheckResult:
|
| 32 |
+
"""
|
| 33 |
+
Verify a text claim using Google's Fact Check Tools API.
|
| 34 |
+
|
| 35 |
+
Args:
|
| 36 |
+
claim_text (str): The claim or post text to fact-check.
|
| 37 |
+
|
| 38 |
+
Returns:
|
| 39 |
+
FactCheckResult: Structured response with verification details.
|
| 40 |
+
"""
|
| 41 |
+
params = {"query": claim_text, "key": self.api_key}
|
| 42 |
+
|
| 43 |
+
try:
|
| 44 |
+
response = requests.get(self.BASE_URL, params=params, timeout=self.timeout)
|
| 45 |
+
response.raise_for_status()
|
| 46 |
+
data = response.json()
|
| 47 |
+
except requests.RequestException as e:
|
| 48 |
+
return FactCheckResult(verified=False, summary_verdict=f"Request failed: {e}")
|
| 49 |
+
|
| 50 |
+
if "claims" not in data or not data["claims"]:
|
| 51 |
+
return FactCheckResult(verified=False, summary_verdict="Unverified")
|
| 52 |
+
|
| 53 |
+
entries = []
|
| 54 |
+
for claim in data["claims"]:
|
| 55 |
+
reviews = claim.get("claimReview", [])
|
| 56 |
+
for review in reviews:
|
| 57 |
+
entries.append(
|
| 58 |
+
FactCheckEntry(
|
| 59 |
+
text=claim.get("text", ""),
|
| 60 |
+
claimant=claim.get("claimant"),
|
| 61 |
+
claim_date=claim.get("claimDate"),
|
| 62 |
+
rating=review.get("textualRating"),
|
| 63 |
+
publisher=review.get("publisher", {}).get("name"),
|
| 64 |
+
url=review.get("url"),
|
| 65 |
+
)
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
# Aggregate verdict
|
| 69 |
+
verdict_texts = [e.rating.lower() for e in entries if e.rating]
|
| 70 |
+
if any(v in verdict_texts for v in ["false", "incorrect", "pants on fire", "mostly false"]):
|
| 71 |
+
summary_verdict = "Likely False"
|
| 72 |
+
elif any(v in verdict_texts for v in ["true", "mostly true", "accurate"]):
|
| 73 |
+
summary_verdict = "Likely True"
|
| 74 |
+
else:
|
| 75 |
+
summary_verdict = "Mixed / Unclear"
|
| 76 |
+
|
| 77 |
+
return FactCheckResult(verified=True, summary_verdict=summary_verdict, results=entries)
|
services/fake_text_news_service.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from abc import ABC, abstractmethod
|
| 2 |
+
import re
|
| 3 |
+
import torch
|
| 4 |
+
import contractions
|
| 5 |
+
import nltk
|
| 6 |
+
from nltk.corpus import stopwords
|
| 7 |
+
import torch.nn as nn
|
| 8 |
+
|
| 9 |
+
from schemas.text_schemas import FakeNewsDetector
|
| 10 |
+
from models.models import LSTMClassifier
|
| 11 |
+
|
| 12 |
+
# Download stopwords (first run only)
|
| 13 |
+
nltk.download('stopwords', quiet=True)
|
| 14 |
+
stop_words = set(stopwords.words('english'))
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class FakeTextNewsDetector(FakeNewsDetector):
|
| 18 |
+
"""
|
| 19 |
+
A class for detecting fake news in text articles using a pre-trained LSTM model.
|
| 20 |
+
|
| 21 |
+
This class loads a trained PyTorch LSTM classifier and corresponding vocabulary
|
| 22 |
+
to evaluate whether a given text is real or fake. It performs preprocessing,
|
| 23 |
+
tokenization, and encoding before feeding the text to the neural network.
|
| 24 |
+
|
| 25 |
+
Attributes:
|
| 26 |
+
model_path (str): Path to the saved PyTorch model file (.pt).
|
| 27 |
+
vocab_path (str): Path to the saved vocabulary mapping file (.pt).
|
| 28 |
+
device (torch.device): The computing device used for inference ('cuda' or 'cpu').
|
| 29 |
+
model (nn.Module): The loaded LSTM model for fake news detection.
|
| 30 |
+
word2idx (dict): Mapping of words to integer indices for token encoding.
|
| 31 |
+
"""
|
| 32 |
+
|
| 33 |
+
def __init__(self, model_path: str = "models/model.pt", vocab_path: str = "models/word2idx.pt", device=None):
|
| 34 |
+
"""
|
| 35 |
+
Initializes the FakeTextNewsDetector.
|
| 36 |
+
|
| 37 |
+
Loads the trained model and vocabulary, sets up the computing device, and
|
| 38 |
+
prepares the model for inference.
|
| 39 |
+
|
| 40 |
+
Args:
|
| 41 |
+
model_path (str): Path to the pre-trained LSTM model (.pt). Defaults to "models/model.pt".
|
| 42 |
+
vocab_path (str): Path to the vocabulary mapping file (.pt). Defaults to "models/word2idx.pt".
|
| 43 |
+
device (torch.device, optional): Torch device ('cuda' or 'cpu').
|
| 44 |
+
If not provided, automatically detects CUDA availability.
|
| 45 |
+
"""
|
| 46 |
+
self.device = device or torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 47 |
+
torch.serialization.add_safe_globals([LSTMClassifier])
|
| 48 |
+
|
| 49 |
+
# Load vocabulary dictionary
|
| 50 |
+
self.word2idx = torch.load(vocab_path, map_location=self.device)
|
| 51 |
+
vocab_size = len(self.word2idx)
|
| 52 |
+
|
| 53 |
+
# Load the LSTM model
|
| 54 |
+
self.model = LSTMClassifier(vocab_size=vocab_size)
|
| 55 |
+
loaded_model = torch.load(model_path, map_location=self.device, weights_only=False)
|
| 56 |
+
|
| 57 |
+
# Support both raw state_dict and full model serialization
|
| 58 |
+
if isinstance(loaded_model, dict):
|
| 59 |
+
self.model.load_state_dict(loaded_model)
|
| 60 |
+
else:
|
| 61 |
+
self.model.load_state_dict(loaded_model.state_dict())
|
| 62 |
+
|
| 63 |
+
self.model.to(self.device)
|
| 64 |
+
self.model.eval()
|
| 65 |
+
|
| 66 |
+
def _clean_text(self, text: str):
|
| 67 |
+
"""
|
| 68 |
+
Cleans and tokenizes the input text for model inference.
|
| 69 |
+
|
| 70 |
+
Steps include:
|
| 71 |
+
- Expanding contractions (e.g., "don't" → "do not")
|
| 72 |
+
- Removing non-ASCII characters, HTML tags, URLs, and special symbols
|
| 73 |
+
- Lowercasing all text
|
| 74 |
+
- Removing single-letter words and stopwords
|
| 75 |
+
|
| 76 |
+
Args:
|
| 77 |
+
text (str): The input raw text.
|
| 78 |
+
|
| 79 |
+
Returns:
|
| 80 |
+
list[str]: A list of cleaned and filtered tokens.
|
| 81 |
+
"""
|
| 82 |
+
text = contractions.fix(text)
|
| 83 |
+
text = text.encode("ascii", "ignore").decode()
|
| 84 |
+
text = text.lower()
|
| 85 |
+
text = re.sub(r"http\S+|www\.\S+", "", text)
|
| 86 |
+
text = re.sub(r"<.*?>", "", text)
|
| 87 |
+
text = re.sub(r"[^a-z\s]", "", text)
|
| 88 |
+
text = re.sub(r"\b\w{1}\b", "", text)
|
| 89 |
+
tokens = [w for w in text.split() if w not in stop_words]
|
| 90 |
+
return tokens
|
| 91 |
+
|
| 92 |
+
def _encode(self, tokens, max_len: int = 300):
|
| 93 |
+
"""
|
| 94 |
+
Encodes tokens into a fixed-length tensor for model input.
|
| 95 |
+
|
| 96 |
+
Each token is mapped to its index in the loaded vocabulary.
|
| 97 |
+
If the token is unknown, a special <UNK> index is used.
|
| 98 |
+
The sequence is padded or truncated to the specified length.
|
| 99 |
+
|
| 100 |
+
Args:
|
| 101 |
+
tokens (list[str]): List of word tokens.
|
| 102 |
+
max_len (int): Maximum sequence length. Defaults to 300.
|
| 103 |
+
|
| 104 |
+
Returns:
|
| 105 |
+
torch.Tensor: Encoded token tensor of shape (1, max_len).
|
| 106 |
+
"""
|
| 107 |
+
ids = [self.word2idx.get(w, self.word2idx.get("<UNK>", 1)) for w in tokens]
|
| 108 |
+
if len(ids) < max_len:
|
| 109 |
+
ids += [self.word2idx.get("<PAD>", 0)] * (max_len - len(ids))
|
| 110 |
+
else:
|
| 111 |
+
ids = ids[:max_len]
|
| 112 |
+
return torch.tensor(ids, dtype=torch.long).unsqueeze(0)
|
| 113 |
+
|
| 114 |
+
def detect(self, text: str) -> bool:
|
| 115 |
+
"""
|
| 116 |
+
Predicts whether the given text is fake or real.
|
| 117 |
+
|
| 118 |
+
The method preprocesses the text, encodes it into numerical form,
|
| 119 |
+
and runs inference using the LSTM model. The output score is a probability
|
| 120 |
+
between 0 and 1, where values above 0.5 indicate fake news.
|
| 121 |
+
|
| 122 |
+
Args:
|
| 123 |
+
text (str): The input text (article or sentence).
|
| 124 |
+
|
| 125 |
+
Returns:
|
| 126 |
+
bool: True if the text is predicted as fake, False if real.
|
| 127 |
+
"""
|
| 128 |
+
tokens = self._clean_text(text)
|
| 129 |
+
seq = self._encode(tokens).to(self.device)
|
| 130 |
+
with torch.no_grad():
|
| 131 |
+
score = self.model(seq).item()
|
| 132 |
+
return score >= 0.5
|
services/search_quries_service.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from abc import ABC, abstractmethod
|
| 2 |
+
from typing import List
|
| 3 |
+
from transformers import AutoTokenizer, AutoModelForSeq2SeqLM
|
| 4 |
+
from schemas.text_schemas import SearchQueryExtractor
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class TransformersSearchQueryExtractor(SearchQueryExtractor):
|
| 8 |
+
|
| 9 |
+
"""Transformer-based implementation of the SearchQueryExtractor interface."""
|
| 10 |
+
|
| 11 |
+
def __init__(self, model_name: str = "google/flan-t5-small"):
|
| 12 |
+
"""
|
| 13 |
+
Initialize the lightweight transformer model for search query generation.
|
| 14 |
+
|
| 15 |
+
Args:
|
| 16 |
+
model_name: Hugging Face model name (default: 'google/flan-t5-small').
|
| 17 |
+
"""
|
| 18 |
+
self.model_name = model_name
|
| 19 |
+
self.tokenizer = AutoTokenizer.from_pretrained(model_name)
|
| 20 |
+
self.model = AutoModelForSeq2SeqLM.from_pretrained(model_name)
|
| 21 |
+
|
| 22 |
+
def extract(self, text: str, num_queries: int = 5) -> List[str]:
|
| 23 |
+
"""
|
| 24 |
+
Generate search-like queries using the transformer model.
|
| 25 |
+
|
| 26 |
+
Args:
|
| 27 |
+
text: The input paragraph.
|
| 28 |
+
num_queries: Number of queries to generate.
|
| 29 |
+
|
| 30 |
+
Returns:
|
| 31 |
+
List[str]: A list of extracted search queries.
|
| 32 |
+
"""
|
| 33 |
+
prompt = (
|
| 34 |
+
f"Generate {num_queries} useful and distinct search queries "
|
| 35 |
+
f"from the following paragraph:\n{text.strip()}"
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
inputs = self.tokenizer(prompt, return_tensors="pt", truncation=True)
|
| 39 |
+
outputs = self.model.generate(**inputs, max_length=96, num_return_sequences=1)
|
| 40 |
+
generated_text = self.tokenizer.decode(outputs[0], skip_special_tokens=True)
|
| 41 |
+
|
| 42 |
+
# Clean and split queries
|
| 43 |
+
queries = [
|
| 44 |
+
q.strip("-• \n").rstrip(".")
|
| 45 |
+
for q in generated_text.split("\n")
|
| 46 |
+
if q.strip()
|
| 47 |
+
]
|
| 48 |
+
return queries
|
services/text_emotion_service.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from transformers import pipeline, AutoTokenizer
|
| 2 |
+
from schemas.text_schemas import EmotionResult, EmotionDetector
|
| 3 |
+
from typing import List, Dict
|
| 4 |
+
|
| 5 |
+
class TransformersEmotionDetector(EmotionDetector):
|
| 6 |
+
"""Emotion detector using a lightweight DistilRoBERTa model from Hugging Face."""
|
| 7 |
+
|
| 8 |
+
def __init__(self, model_name: str = "j-hartmann/emotion-english-distilroberta-base"):
|
| 9 |
+
"""
|
| 10 |
+
Initialize the emotion detection model.
|
| 11 |
+
Args:
|
| 12 |
+
model_name: Pretrained Hugging Face model for emotion detection.
|
| 13 |
+
"""
|
| 14 |
+
self.model_name = model_name
|
| 15 |
+
self.pipeline = pipeline(
|
| 16 |
+
"text-classification",
|
| 17 |
+
model=model_name,
|
| 18 |
+
return_all_scores=True
|
| 19 |
+
)
|
| 20 |
+
# Load tokenizer for proper text truncation
|
| 21 |
+
self.tokenizer = AutoTokenizer.from_pretrained(model_name)
|
| 22 |
+
|
| 23 |
+
def analyze(self, text: str) -> EmotionResult:
|
| 24 |
+
"""
|
| 25 |
+
Analyze emotion in the given text using chunked processing for long texts.
|
| 26 |
+
Args:
|
| 27 |
+
text: The text to analyze.
|
| 28 |
+
Returns:
|
| 29 |
+
EmotionResult: Structured result containing dominant emotion and confidence.
|
| 30 |
+
"""
|
| 31 |
+
# Split text into chunks if it's too long
|
| 32 |
+
max_tokens_per_chunk = 400 # Conservative limit to leave room for special tokens
|
| 33 |
+
chunks = self._split_text_into_chunks(text, max_tokens_per_chunk)
|
| 34 |
+
|
| 35 |
+
if len(chunks) == 1:
|
| 36 |
+
# Single chunk - use original logic
|
| 37 |
+
results: List[List[Dict[str, float]]] = self.pipeline(chunks[0])
|
| 38 |
+
else:
|
| 39 |
+
# Multiple chunks - analyze each and aggregate results
|
| 40 |
+
results: List[List[Dict[str, float]]] = self.pipeline(chunks)
|
| 41 |
+
|
| 42 |
+
# Aggregate emotion scores across all chunks
|
| 43 |
+
aggregated_scores = self._aggregate_emotion_scores(results)
|
| 44 |
+
|
| 45 |
+
# Get the most likely emotion
|
| 46 |
+
dominant_emotion = max(aggregated_scores, key=aggregated_scores.get)
|
| 47 |
+
confidence = aggregated_scores[dominant_emotion]
|
| 48 |
+
|
| 49 |
+
return EmotionResult(
|
| 50 |
+
dominant_emotion=dominant_emotion,
|
| 51 |
+
confidence=confidence,
|
| 52 |
+
all_scores=aggregated_scores
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
def _split_text_into_chunks(self, text: str, max_tokens_per_chunk: int) -> List[str]:
|
| 56 |
+
"""
|
| 57 |
+
Split text into chunks that fit within token limits.
|
| 58 |
+
Args:
|
| 59 |
+
text: The text to split
|
| 60 |
+
max_tokens_per_chunk: Maximum tokens per chunk
|
| 61 |
+
Returns:
|
| 62 |
+
List of text chunks
|
| 63 |
+
"""
|
| 64 |
+
# Split text into sentences first for better chunk boundaries
|
| 65 |
+
sentences = text.split('. ')
|
| 66 |
+
chunks = []
|
| 67 |
+
current_chunk = ""
|
| 68 |
+
current_tokens = 0
|
| 69 |
+
|
| 70 |
+
for sentence in sentences:
|
| 71 |
+
sentence = sentence.strip()
|
| 72 |
+
if not sentence:
|
| 73 |
+
continue
|
| 74 |
+
|
| 75 |
+
# Add period back if it was removed
|
| 76 |
+
if not sentence.endswith('.'):
|
| 77 |
+
sentence += '.'
|
| 78 |
+
|
| 79 |
+
sentence_tokens = len(self.tokenizer.tokenize(sentence))
|
| 80 |
+
|
| 81 |
+
# If adding this sentence would exceed limit, start new chunk
|
| 82 |
+
if current_tokens + sentence_tokens > max_tokens_per_chunk and current_chunk:
|
| 83 |
+
chunks.append(current_chunk.strip())
|
| 84 |
+
current_chunk = sentence
|
| 85 |
+
current_tokens = sentence_tokens
|
| 86 |
+
else:
|
| 87 |
+
if current_chunk:
|
| 88 |
+
current_chunk += " " + sentence
|
| 89 |
+
else:
|
| 90 |
+
current_chunk = sentence
|
| 91 |
+
current_tokens += sentence_tokens
|
| 92 |
+
|
| 93 |
+
# Add the last chunk if it exists
|
| 94 |
+
if current_chunk:
|
| 95 |
+
chunks.append(current_chunk.strip())
|
| 96 |
+
|
| 97 |
+
return chunks
|
| 98 |
+
|
| 99 |
+
def _aggregate_emotion_scores(self, results: List[List[Dict[str, float]]]) -> Dict[str, float]:
|
| 100 |
+
"""
|
| 101 |
+
Aggregate emotion scores from multiple chunks.
|
| 102 |
+
Args:
|
| 103 |
+
results: List of emotion classification results from each chunk
|
| 104 |
+
Returns:
|
| 105 |
+
Dictionary of aggregated emotion scores
|
| 106 |
+
"""
|
| 107 |
+
if not results:
|
| 108 |
+
return {}
|
| 109 |
+
|
| 110 |
+
# Collect all emotion scores with weights
|
| 111 |
+
emotion_totals = {}
|
| 112 |
+
emotion_weights = {}
|
| 113 |
+
|
| 114 |
+
for chunk_results in results:
|
| 115 |
+
# Get confidence scores for this chunk
|
| 116 |
+
chunk_scores = {entry["label"]: entry["score"] for entry in chunk_results}
|
| 117 |
+
|
| 118 |
+
# Weight by confidence (more confident predictions get higher weight)
|
| 119 |
+
total_confidence = sum(chunk_scores.values())
|
| 120 |
+
|
| 121 |
+
for emotion, score in chunk_scores.items():
|
| 122 |
+
weight = score / total_confidence if total_confidence > 0 else 0
|
| 123 |
+
emotion_totals[emotion] = emotion_totals.get(emotion, 0) + score
|
| 124 |
+
emotion_weights[emotion] = emotion_weights.get(emotion, 0) + 1 # Simple count for now
|
| 125 |
+
|
| 126 |
+
# Average the scores across chunks
|
| 127 |
+
aggregated_scores = {}
|
| 128 |
+
for emotion in emotion_totals:
|
| 129 |
+
# Use weighted average based on number of chunks that detected this emotion
|
| 130 |
+
aggregated_scores[emotion] = emotion_totals[emotion] / emotion_weights[emotion]
|
| 131 |
+
|
| 132 |
+
return aggregated_scores
|
static/css/style.css
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* Dark Theme Core */
|
| 2 |
+
:root {
|
| 3 |
+
--bg-primary: #0a0a0a;
|
| 4 |
+
--bg-secondary: #111111;
|
| 5 |
+
--bg-surface: #161616;
|
| 6 |
+
--bg-card: #1d1d1d;
|
| 7 |
+
--bg-input: #232323;
|
| 8 |
+
|
| 9 |
+
--text-primary: #ffffff;
|
| 10 |
+
--text-secondary: #a0a0a0;
|
| 11 |
+
--text-muted: #6e6e6e;
|
| 12 |
+
|
| 13 |
+
--accent-primary: #3b82f6;
|
| 14 |
+
--accent-primary-hover: #2563eb;
|
| 15 |
+
--accent-success: #10b981;
|
| 16 |
+
--accent-warning: #f59e0b;
|
| 17 |
+
--accent-error: #ef4444;
|
| 18 |
+
--accent-info: #3b82f6;
|
| 19 |
+
|
| 20 |
+
--border-color: #2a2a2a;
|
| 21 |
+
--border-light: #3a3a3a;
|
| 22 |
+
|
| 23 |
+
--shadow-xl: 0 10px 16px rgba(0,0,0,0.35);
|
| 24 |
+
|
| 25 |
+
/* sharper borders */
|
| 26 |
+
--radius-xs: 4px;
|
| 27 |
+
--radius-sm: 6px;
|
| 28 |
+
--radius-md: 8px;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
* { margin:0; padding:0; box-sizing:border-box; }
|
| 32 |
+
|
| 33 |
+
body {
|
| 34 |
+
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
| 35 |
+
background: var(--bg-primary);
|
| 36 |
+
color: var(--text-primary);
|
| 37 |
+
line-height: 1.6;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
.container { max-width: 1100px; margin: 0 auto; padding: 0 1.5rem; }
|
| 41 |
+
|
| 42 |
+
/* Header */
|
| 43 |
+
.header {
|
| 44 |
+
background: var(--bg-secondary);
|
| 45 |
+
border-bottom: 1px solid var(--border-color);
|
| 46 |
+
padding: 1rem 0;
|
| 47 |
+
position: sticky; top: 0; z-index: 10;
|
| 48 |
+
}
|
| 49 |
+
.header-content { display: flex; justify-content: space-between; align-items: center; }
|
| 50 |
+
.logo { font-size: 1.25rem; font-weight: 700; color: var(--text-primary); }
|
| 51 |
+
|
| 52 |
+
/* Form Section (intro) */
|
| 53 |
+
.analysis-form-section { padding: 3rem 0; background: var(--bg-secondary); }
|
| 54 |
+
.form-container {
|
| 55 |
+
background: var(--bg-card);
|
| 56 |
+
border: 1px solid var(--border-color);
|
| 57 |
+
border-radius: var(--radius-sm);
|
| 58 |
+
padding: 2rem;
|
| 59 |
+
box-shadow: var(--shadow-xl);
|
| 60 |
+
}
|
| 61 |
+
.form-header { text-align: center; margin-bottom: 2rem; }
|
| 62 |
+
.form-header h2 { font-size: 1.75rem; margin-bottom: 0.5rem; }
|
| 63 |
+
.form-header p { color: var(--text-secondary); }
|
| 64 |
+
|
| 65 |
+
.form-group { margin-bottom: 1.5rem; }
|
| 66 |
+
.form-label {
|
| 67 |
+
display: flex; justify-content: space-between; align-items: center;
|
| 68 |
+
font-weight: 600; margin-bottom: 0.5rem; color: var(--text-primary);
|
| 69 |
+
}
|
| 70 |
+
.char-counter { color: var(--text-muted); font-size: 0.9rem; font-weight: 400; }
|
| 71 |
+
|
| 72 |
+
.form-textarea {
|
| 73 |
+
width: 100%; min-height: 200px; background: var(--bg-input);
|
| 74 |
+
border: 1px solid var(--border-light); border-radius: var(--radius-xs);
|
| 75 |
+
padding: 1rem; color: var(--text-primary); font-size: 1rem; resize: vertical;
|
| 76 |
+
}
|
| 77 |
+
.form-textarea:focus { outline: none; border-color: var(--accent-primary); }
|
| 78 |
+
|
| 79 |
+
.upload-container { display: flex; flex-direction: column; gap: 1rem; }
|
| 80 |
+
.upload-zone {
|
| 81 |
+
border: 2px dashed var(--border-light); border-radius: var(--radius-sm);
|
| 82 |
+
padding: 2rem 1rem; background: var(--bg-input);
|
| 83 |
+
transition: border-color .2s, background .2s; cursor: pointer;
|
| 84 |
+
}
|
| 85 |
+
.upload-zone:hover, .upload-zone.drag-over {
|
| 86 |
+
border-color: var(--accent-primary); background: var(--bg-surface);
|
| 87 |
+
}
|
| 88 |
+
.upload-content { display: flex; flex-direction: column; gap: 1rem; align-items: center; text-align: center; }
|
| 89 |
+
.upload-text h4 { font-size: 1.1rem; margin-bottom: 0.25rem; }
|
| 90 |
+
.upload-text p { color: var(--text-secondary); margin-bottom: 0.25rem; }
|
| 91 |
+
.upload-info { font-size: 0.85rem; color: var(--text-muted); }
|
| 92 |
+
.upload-btn {
|
| 93 |
+
background: var(--accent-primary); color: #fff; border: none;
|
| 94 |
+
padding: 0.6rem 1.1rem; border-radius: var(--radius-xs); font-weight: 600; cursor: pointer;
|
| 95 |
+
}
|
| 96 |
+
.upload-btn:hover { background: var(--accent-primary-hover); }
|
| 97 |
+
.file-input { display: none; }
|
| 98 |
+
|
| 99 |
+
.image-previews {
|
| 100 |
+
display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 0.75rem;
|
| 101 |
+
}
|
| 102 |
+
.preview-item {
|
| 103 |
+
position: relative; border-radius: var(--radius-xs); overflow: hidden;
|
| 104 |
+
background: var(--bg-input); border: 1px solid var(--border-light);
|
| 105 |
+
}
|
| 106 |
+
.preview-image { width: 100%; height: 100px; object-fit: cover; }
|
| 107 |
+
.remove-btn {
|
| 108 |
+
position: absolute; top: 0.5rem; right: 0.5rem;
|
| 109 |
+
background: rgba(239, 68, 68, 0.95); color: #fff; border: none;
|
| 110 |
+
width: 22px; height: 22px; border-radius: 2px; font-size: 0.8rem; cursor: pointer;
|
| 111 |
+
opacity: 0; transition: opacity .15s;
|
| 112 |
+
}
|
| 113 |
+
.preview-item:hover .remove-btn { opacity: 1; }
|
| 114 |
+
|
| 115 |
+
/* Actions */
|
| 116 |
+
.form-actions { display: flex; gap: 0.75rem; justify-content: center; margin-top: 1rem; }
|
| 117 |
+
.btn-primary, .btn-secondary {
|
| 118 |
+
padding: 0.85rem 1.3rem; border-radius: var(--radius-xs); font-weight: 700; cursor: pointer; border: none; min-width: 140px;
|
| 119 |
+
}
|
| 120 |
+
.btn-primary { background: var(--accent-primary); color: #fff; position: relative; }
|
| 121 |
+
.btn-primary:hover:not(:disabled) { background: var(--accent-primary-hover); }
|
| 122 |
+
.btn-primary:disabled { opacity: 0.6; cursor: not-allowed; }
|
| 123 |
+
.btn-secondary { background: transparent; color: var(--text-secondary); border: 1px solid var(--border-light); }
|
| 124 |
+
.btn-secondary:hover { color: var(--text-primary); }
|
| 125 |
+
|
| 126 |
+
.btn-loader { display: none; }
|
| 127 |
+
.btn-loader.active { display: inline-block; }
|
| 128 |
+
.spinner {
|
| 129 |
+
width: 16px; height: 16px; border: 2px solid transparent; border-top: 2px solid currentColor;
|
| 130 |
+
border-radius: 50%; animation: spin 1s linear infinite;
|
| 131 |
+
}
|
| 132 |
+
@keyframes spin { 0%{transform:rotate(0)} 100%{transform:rotate(360deg)} }
|
| 133 |
+
|
| 134 |
+
/* Toasts */
|
| 135 |
+
.message-container { position: fixed; top: 1rem; right: 1rem; z-index: 1000; }
|
| 136 |
+
.message {
|
| 137 |
+
background: var(--bg-surface); border: 1px solid var(--border-color);
|
| 138 |
+
border-left: 3px solid var(--accent-info);
|
| 139 |
+
border-radius: var(--radius-xs); padding: 0.7rem 1rem; margin-bottom: 0.5rem;
|
| 140 |
+
display: flex; gap: 0.75rem; transform: translateX(400px); transition: transform .25s ease-out;
|
| 141 |
+
}
|
| 142 |
+
.message.show { transform: translateX(0); }
|
| 143 |
+
.message.success { border-left-color: var(--accent-success); }
|
| 144 |
+
.message.error { border-left-color: var(--accent-error); }
|
| 145 |
+
.message.warning { border-left-color: var(--accent-warning); }
|
| 146 |
+
.message-title { font-weight: 700; font-size: 0.95rem; }
|
| 147 |
+
.message-text { color: var(--text-secondary); font-size: 0.85rem; }
|
| 148 |
+
.message-close { margin-left: auto; background: none; color: var(--text-muted); border: none; cursor: pointer; font-size: 1rem; }
|
| 149 |
+
|
| 150 |
+
/* Analysis page */
|
| 151 |
+
.analysis-header {
|
| 152 |
+
background: var(--bg-secondary);
|
| 153 |
+
border-bottom: 1px solid var(--border-color);
|
| 154 |
+
padding: 1rem 0;
|
| 155 |
+
}
|
| 156 |
+
.analysis-header .header-content {
|
| 157 |
+
display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 0.75rem;
|
| 158 |
+
}
|
| 159 |
+
.back-button {
|
| 160 |
+
color: var(--accent-primary); text-decoration: none; font-weight: 700;
|
| 161 |
+
padding: 0.35rem 0.7rem; border: 1px solid var(--border-light); border-radius: var(--radius-xs);
|
| 162 |
+
}
|
| 163 |
+
.back-button:hover { background: var(--bg-surface); }
|
| 164 |
+
.analysis-meta { display: flex; gap: 0.75rem; color: var(--text-secondary); font-size: 0.9rem; }
|
| 165 |
+
|
| 166 |
+
.overview-cards {
|
| 167 |
+
display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
| 168 |
+
gap: 1rem; margin: 1.5rem 0 2rem;
|
| 169 |
+
}
|
| 170 |
+
.overview-card {
|
| 171 |
+
background: var(--bg-card); border: 1px solid var(--border-color); border-radius: var(--radius-sm);
|
| 172 |
+
padding: 1rem; transition: transform .15s; display: flex; align-items: center;
|
| 173 |
+
}
|
| 174 |
+
.overview-card:hover { transform: translateY(-1px); }
|
| 175 |
+
.card-success { border-left: 3px solid var(--accent-success); }
|
| 176 |
+
.card-warning { border-left: 3px solid var(--accent-warning); }
|
| 177 |
+
.card-danger { border-left: 3px solid var(--accent-error); }
|
| 178 |
+
.card-content h3 { font-size: 0.9rem; color: var(--text-secondary); margin-bottom: 0.35rem; }
|
| 179 |
+
.card-value { font-size: 1.35rem; font-weight: 800; margin-bottom: 0.15rem; }
|
| 180 |
+
.card-description { font-size: 0.8rem; color: var(--text-muted); }
|
| 181 |
+
|
| 182 |
+
.analysis-dashboard { padding: 1rem 0 2.5rem; }
|
| 183 |
+
.dashboard-grid {
|
| 184 |
+
display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; margin-bottom: 2rem;
|
| 185 |
+
}
|
| 186 |
+
.dashboard-column { display: flex; flex-direction: column; gap: 1.25rem; }
|
| 187 |
+
|
| 188 |
+
.analysis-section {
|
| 189 |
+
background: var(--bg-card); border: 1px solid var(--border-color); border-radius: var(--radius-sm); overflow: hidden;
|
| 190 |
+
}
|
| 191 |
+
.section-header {
|
| 192 |
+
background: var(--bg-surface); padding: 1rem 1.25rem; border-bottom: 1px solid var(--border-color);
|
| 193 |
+
display: flex; justify-content: space-between; align-items: center;
|
| 194 |
+
}
|
| 195 |
+
.section-header h3 { font-size: 1.05rem; font-weight: 800; }
|
| 196 |
+
.section-badge {
|
| 197 |
+
background: var(--bg-input); color: var(--text-secondary);
|
| 198 |
+
padding: 0.2rem 0.6rem; border-radius: var(--radius-xs); font-size: 0.8rem; font-weight: 700;
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
.content-card { padding: 1rem 1.25rem; }
|
| 202 |
+
.content-text { color: var(--text-primary); margin-bottom: 0.75rem; }
|
| 203 |
+
.content-meta { display: flex; gap: 1rem; color: var(--text-muted); font-size: 0.9rem; }
|
| 204 |
+
|
| 205 |
+
.emotion-card { padding: 1rem 1.25rem; }
|
| 206 |
+
.dominant-emotion { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 1rem; }
|
| 207 |
+
.emotion-tag { background: var(--accent-primary); color: #fff; padding: 0.3rem 0.6rem; border-radius: var(--radius-xs); font-weight: 800; }
|
| 208 |
+
.emotion-breakdown { display: flex; flex-direction: column; gap: 0.6rem; }
|
| 209 |
+
.emotion-item { display: flex; align-items: center; gap: 0.75rem; }
|
| 210 |
+
.emotion-label { min-width: 80px; color: var(--text-secondary); font-size: 0.9rem; }
|
| 211 |
+
.emotion-bar { flex: 1; height: 8px; background: var(--bg-input); border-radius: var(--radius-xs); overflow: hidden; }
|
| 212 |
+
.emotion-fill { height: 100%; background: linear-gradient(90deg, var(--accent-primary), var(--accent-info)); }
|
| 213 |
+
.emotion-score { min-width: 40px; text-align: right; color: var(--text-muted); font-size: 0.9rem; }
|
| 214 |
+
|
| 215 |
+
.queries-card { padding: 1rem 1.25rem; }
|
| 216 |
+
.queries-grid { display: flex; flex-wrap: wrap; gap: 0.5rem; }
|
| 217 |
+
.query-chip {
|
| 218 |
+
background: var(--bg-input); color: var(--text-primary);
|
| 219 |
+
padding: 0.4rem 0.7rem; border-radius: var(--radius-xs); font-size: 0.85rem; border: 1px solid var(--border-light);
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
/* Fact checks */
|
| 223 |
+
.fact-checks { padding: 1rem 1.25rem; display: flex; flex-direction: column; gap: 1rem; }
|
| 224 |
+
.fact-check-card { border-radius: var(--radius-xs); overflow: hidden; }
|
| 225 |
+
.fact-unverified { background: rgba(239, 68, 68, 0.05); border: 1px solid rgba(239, 68, 68, 0.35); }
|
| 226 |
+
.fact-verified { background: rgba(16, 185, 129, 0.05); border: 1px solid rgba(16, 185, 129, 0.35); }
|
| 227 |
+
.fact-check-header {
|
| 228 |
+
display: flex; justify-content: space-between; align-items: center;
|
| 229 |
+
padding: 0.8rem 1rem; background: var(--bg-surface);
|
| 230 |
+
}
|
| 231 |
+
.fact-verdict { font-weight: 800; }
|
| 232 |
+
.fact-status { padding: 0.3rem 0.6rem; border-radius: var(--radius-xs); font-weight: 800; font-size: 0.8rem; }
|
| 233 |
+
.status-verified { background: var(--accent-success); color: #fff; }
|
| 234 |
+
.status-unverified { background: var(--accent-error); color: #fff; }
|
| 235 |
+
.fact-check-results { padding: 0.8rem 1rem; }
|
| 236 |
+
.fact-item { margin-bottom: 0.9rem; padding-bottom: 0.9rem; border-bottom: 1px solid var(--border-light); }
|
| 237 |
+
.fact-item:last-child { margin-bottom: 0; padding-bottom: 0; border-bottom: none; }
|
| 238 |
+
.fact-source { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.35rem; }
|
| 239 |
+
.claim-date { color: var(--text-muted); font-size: 0.8rem; }
|
| 240 |
+
.fact-details { display: flex; justify-content: space-between; align-items: flex-start; gap: 1rem; margin-bottom: 0.35rem; }
|
| 241 |
+
.fact-text { flex: 1; color: var(--text-secondary); font-size: 0.95rem; line-height: 1.4; }
|
| 242 |
+
.fact-rating { padding: 0.25rem 0.6rem; border-radius: var(--radius-xs); font-weight: 800; font-size: 0.8rem; white-space: nowrap; }
|
| 243 |
+
.fact-rating.false, .fact-rating.deceptive { background: var(--accent-error); color: #fff; }
|
| 244 |
+
.fact-rating.unsubstantiated { background: var(--accent-warning); color: #fff; }
|
| 245 |
+
.fact-link { color: var(--accent-primary); text-decoration: none; font-size: 0.85rem; }
|
| 246 |
+
.fact-link:hover { text-decoration: underline; }
|
| 247 |
+
|
| 248 |
+
/* Uploaded images with overlays */
|
| 249 |
+
.uploaded-images-grid {
|
| 250 |
+
display: grid;
|
| 251 |
+
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
| 252 |
+
gap: 1rem;
|
| 253 |
+
padding: 1rem 1.25rem;
|
| 254 |
+
}
|
| 255 |
+
.vision-card {
|
| 256 |
+
background: var(--bg-surface);
|
| 257 |
+
border: 1px solid var(--border-light);
|
| 258 |
+
border-radius: var(--radius-sm);
|
| 259 |
+
padding: 0.9rem;
|
| 260 |
+
}
|
| 261 |
+
.vision-header {
|
| 262 |
+
display: flex; justify-content: space-between; align-items: center;
|
| 263 |
+
margin-bottom: 0.6rem; border-bottom: 1px solid var(--border-light); padding-bottom: 0.4rem;
|
| 264 |
+
}
|
| 265 |
+
.image-type { padding: 0.2rem 0.55rem; border-radius: var(--radius-xs); font-size: 0.8rem; font-weight: 800; }
|
| 266 |
+
.ai-image { background: var(--accent-warning); color: #fff; }
|
| 267 |
+
.real-image { background: var(--accent-success); color: #fff; }
|
| 268 |
+
|
| 269 |
+
.image-canvas-wrap {
|
| 270 |
+
position: relative;
|
| 271 |
+
width: 100%;
|
| 272 |
+
background: #111;
|
| 273 |
+
border: 1px solid var(--border-color);
|
| 274 |
+
border-radius: var(--radius-xs);
|
| 275 |
+
overflow: hidden;
|
| 276 |
+
}
|
| 277 |
+
.image-canvas-wrap img { display: block; width: 100%; height: auto; }
|
| 278 |
+
.overlay-canvas {
|
| 279 |
+
position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none;
|
| 280 |
+
}
|
| 281 |
+
.no-image { color: var(--text-secondary); padding: 1rem; text-align: center; }
|
| 282 |
+
.faces-count { margin-top: 0.6rem; color: var(--text-secondary); }
|
| 283 |
+
|
| 284 |
+
/* Export */
|
| 285 |
+
.export-section {
|
| 286 |
+
background: var(--bg-card); border: 1px solid var(--border-color);
|
| 287 |
+
border-radius: var(--radius-sm); padding: 1.2rem; text-align: center;
|
| 288 |
+
}
|
| 289 |
+
.export-actions { display: flex; gap: 0.75rem; justify-content: center; flex-wrap: wrap; }
|
| 290 |
+
.export-btn {
|
| 291 |
+
background: var(--bg-surface); color: var(--text-primary); border: 1px solid var(--border-light);
|
| 292 |
+
padding: 0.7rem 1.0rem; border-radius: var(--radius-xs); cursor: pointer; transition: background .15s, border-color .15s;
|
| 293 |
+
font-size: 0.9rem; font-weight: 800;
|
| 294 |
+
}
|
| 295 |
+
.export-btn:hover { background: var(--bg-input); border-color: var(--accent-primary); }
|
| 296 |
+
|
| 297 |
+
/* Responsive */
|
| 298 |
+
@media (max-width: 820px) {
|
| 299 |
+
.dashboard-grid { grid-template-columns: 1fr; }
|
| 300 |
+
.form-container { padding: 1.25rem; }
|
| 301 |
+
.export-actions { flex-direction: column; }
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
|
| 306 |
+
/* ... keep your existing CSS exactly as-is ... */
|
| 307 |
+
|
| 308 |
+
/* NEW: inline/compact header inside the analysis form-container */
|
| 309 |
+
.form-header-inline {
|
| 310 |
+
display: flex;
|
| 311 |
+
align-items: center;
|
| 312 |
+
justify-content: space-between;
|
| 313 |
+
gap: .75rem;
|
| 314 |
+
padding-bottom: .25rem;
|
| 315 |
+
border-bottom: 1px solid var(--border-color);
|
| 316 |
+
}
|
| 317 |
+
.form-header-inline .inline-left {
|
| 318 |
+
display: flex;
|
| 319 |
+
align-items: center;
|
| 320 |
+
gap: .75rem;
|
| 321 |
+
}
|
| 322 |
+
.form-header-inline h2 {
|
| 323 |
+
margin: 0;
|
| 324 |
+
}
|
| 325 |
+
.form-header-inline .analysis-meta {
|
| 326 |
+
display: flex;
|
| 327 |
+
gap: .75rem;
|
| 328 |
+
color: var(--text-secondary);
|
| 329 |
+
font-size: 0.9rem;
|
| 330 |
+
}
|
| 331 |
+
|
| 332 |
+
/* NEW: analysis container block spacing (inside the shared card) */
|
| 333 |
+
.analysis-card-block {
|
| 334 |
+
margin-top: 1rem;
|
| 335 |
+
}
|
static/js/script.js
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
class IntroController {
|
| 2 |
+
constructor() {
|
| 3 |
+
this.state = { uploadedImages: [], isAnalyzing: false };
|
| 4 |
+
this.cacheEls();
|
| 5 |
+
if (!this.el.newsForm) return;
|
| 6 |
+
this.bind();
|
| 7 |
+
}
|
| 8 |
+
cacheEls() {
|
| 9 |
+
this.el = {
|
| 10 |
+
newsForm: document.getElementById('newsForm'),
|
| 11 |
+
newsText: document.getElementById('newsText'),
|
| 12 |
+
fileInput: document.getElementById('fileInput'),
|
| 13 |
+
uploadArea: document.getElementById('uploadArea'),
|
| 14 |
+
browseBtn: document.getElementById('browseBtn'),
|
| 15 |
+
imagePreviews: document.getElementById('imagePreviews'),
|
| 16 |
+
analyzeBtn: document.getElementById('analyzeBtn'),
|
| 17 |
+
clearBtn: document.getElementById('clearBtn'),
|
| 18 |
+
btnLoader: document.getElementById('btnLoader'),
|
| 19 |
+
charCounter: document.getElementById('charCounter'),
|
| 20 |
+
messageContainer: document.getElementById('messageContainer')
|
| 21 |
+
};
|
| 22 |
+
}
|
| 23 |
+
bind() {
|
| 24 |
+
this.el.newsText.addEventListener('input', () => this.updateCharCount());
|
| 25 |
+
this.el.browseBtn.addEventListener('click', () => this.el.fileInput.click());
|
| 26 |
+
this.el.fileInput.addEventListener('change', (e) => this.handleFileSelect(e));
|
| 27 |
+
this.el.uploadArea.addEventListener('click', () => this.el.fileInput.click());
|
| 28 |
+
this.el.uploadArea.addEventListener('dragover', (e) => this.dragOver(e));
|
| 29 |
+
this.el.uploadArea.addEventListener('dragleave', (e) => this.dragLeave(e));
|
| 30 |
+
this.el.uploadArea.addEventListener('drop', (e) => this.drop(e));
|
| 31 |
+
this.el.newsForm.addEventListener('submit', (e) => this.submit(e));
|
| 32 |
+
this.el.clearBtn.addEventListener('click', () => this.clearAll());
|
| 33 |
+
}
|
| 34 |
+
updateCharCount() {
|
| 35 |
+
const len = this.el.newsText.value.length;
|
| 36 |
+
this.el.charCounter.textContent = `${len}/10000`;
|
| 37 |
+
if (len > 9000) this.el.charCounter.style.color = 'var(--accent-error)';
|
| 38 |
+
else if (len > 7000) this.el.charCounter.style.color = 'var(--accent-warning)';
|
| 39 |
+
else this.el.charCounter.style.color = 'var(--text-muted)';
|
| 40 |
+
}
|
| 41 |
+
dragOver(e){ e.preventDefault(); this.el.uploadArea.classList.add('drag-over'); }
|
| 42 |
+
dragLeave(e){ e.preventDefault(); this.el.uploadArea.classList.remove('drag-over'); }
|
| 43 |
+
drop(e){ e.preventDefault(); this.el.uploadArea.classList.remove('drag-over'); this.processFiles(Array.from(e.dataTransfer.files)); }
|
| 44 |
+
handleFileSelect(e){ this.processFiles(Array.from(e.target.files)); this.el.fileInput.value = ''; }
|
| 45 |
+
processFiles(files){
|
| 46 |
+
files.forEach(file=>{
|
| 47 |
+
if(!file.type.startsWith('image/')){ this.toast('Invalid File','Please upload only image files','error'); return; }
|
| 48 |
+
if(file.size > 10*1024*1024){ this.toast('File Too Large','Image must be smaller than 10MB','error'); return; }
|
| 49 |
+
const reader=new FileReader();
|
| 50 |
+
reader.onload=(ev)=>{
|
| 51 |
+
const imageData={ id:Date.now()+Math.random(), name:file.name, size:this.hSize(file.size), data:ev.target.result };
|
| 52 |
+
this.state.uploadedImages.push(imageData);
|
| 53 |
+
this.renderPreview(imageData);
|
| 54 |
+
};
|
| 55 |
+
reader.readAsDataURL(file);
|
| 56 |
+
});
|
| 57 |
+
}
|
| 58 |
+
hSize(bytes){
|
| 59 |
+
if(bytes===0) return '0 B';
|
| 60 |
+
const k=1024, sizes=['B','KB','MB']; const i=Math.floor(Math.log(bytes)/Math.log(k));
|
| 61 |
+
return `${parseFloat((bytes/Math.pow(k,i)).toFixed(1))} ${sizes[i]}`;
|
| 62 |
+
}
|
| 63 |
+
renderPreview(imageData){
|
| 64 |
+
const el=document.createElement('div');
|
| 65 |
+
el.className='preview-item';
|
| 66 |
+
el.innerHTML=`
|
| 67 |
+
<img src="${imageData.data}" alt="${imageData.name}" class="preview-image">
|
| 68 |
+
<button type="button" class="remove-btn" data-id="${imageData.id}">×</button>
|
| 69 |
+
`;
|
| 70 |
+
this.el.imagePreviews.appendChild(el);
|
| 71 |
+
el.querySelector('.remove-btn').addEventListener('click',()=>this.removeImage(imageData.id));
|
| 72 |
+
}
|
| 73 |
+
removeImage(id){
|
| 74 |
+
this.state.uploadedImages=this.state.uploadedImages.filter(x=>x.id!==id);
|
| 75 |
+
const btn=this.el.imagePreviews.querySelector(`[data-id="${id}"]`);
|
| 76 |
+
btn?.closest('.preview-item')?.remove();
|
| 77 |
+
}
|
| 78 |
+
async submit(e){
|
| 79 |
+
e.preventDefault();
|
| 80 |
+
if(!this.validate()){ this.toast('Missing Information','Please add both text and at least one image.','error'); return; }
|
| 81 |
+
if(this.state.isAnalyzing) return;
|
| 82 |
+
this.state.isAnalyzing=true; this.el.analyzeBtn.disabled=true; this.el.btnLoader.classList.add('active');
|
| 83 |
+
this.el.analyzeBtn.querySelector('.btn-text').textContent='Analyzing...';
|
| 84 |
+
try{
|
| 85 |
+
const payload={ text:this.el.newsText.value.trim(), images:this.state.uploadedImages.map(img=>({name:img.name,data:img.data})) };
|
| 86 |
+
const res=await fetch('/analyze',{ method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(payload) });
|
| 87 |
+
const json=await res.json();
|
| 88 |
+
if(json.success){ window.location.href=`/analysis/${json.analysis_id}`; }
|
| 89 |
+
else{ throw new Error(json.error || 'Analysis failed'); }
|
| 90 |
+
}catch(err){
|
| 91 |
+
console.error(err); this.toast('Analysis Failed', err.message,'error');
|
| 92 |
+
}finally{
|
| 93 |
+
this.state.isAnalyzing=false; this.el.analyzeBtn.disabled=false; this.el.btnLoader.classList.remove('active');
|
| 94 |
+
this.el.analyzeBtn.querySelector('.btn-text').textContent='Analyze News';
|
| 95 |
+
}
|
| 96 |
+
}
|
| 97 |
+
validate(){ return this.el.newsText.value.trim().length>0 && this.state.uploadedImages.length>0; }
|
| 98 |
+
clearAll(){
|
| 99 |
+
this.el.newsText.value=''; this.state.uploadedImages=[]; this.el.imagePreviews.innerHTML=''; this.updateCharCount();
|
| 100 |
+
this.toast('Cleared','All inputs have been reset.','success');
|
| 101 |
+
}
|
| 102 |
+
toast(title,text,type='info'){
|
| 103 |
+
const msg=document.createElement('div'); msg.className=`message ${type}`;
|
| 104 |
+
msg.innerHTML=`<div class="message-content"><div class="message-title">${title}</div><div class="message-text">${text}</div></div><button class="message-close">×</button>`;
|
| 105 |
+
const root=document.getElementById('messageContainer') || document.body; root.appendChild(msg);
|
| 106 |
+
setTimeout(()=>msg.classList.add('show'),50);
|
| 107 |
+
msg.querySelector('.message-close').addEventListener('click',()=>this.hideToast(msg));
|
| 108 |
+
setTimeout(()=>this.hideToast(msg),5000);
|
| 109 |
+
}
|
| 110 |
+
hideToast(msg){ msg.classList.remove('show'); setTimeout(()=>msg.remove(),250); }
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
class OverlayController {
|
| 114 |
+
constructor() {
|
| 115 |
+
const dataEl = document.getElementById('analysis-data');
|
| 116 |
+
if (!dataEl) return;
|
| 117 |
+
try { this.analysis = JSON.parse(dataEl.textContent || '{}'); }
|
| 118 |
+
catch { this.analysis = null; }
|
| 119 |
+
if (!this.analysis || !Array.isArray(this.analysis.images)) return;
|
| 120 |
+
this.initOverlays();
|
| 121 |
+
window.addEventListener('resize', () => this.redrawAll());
|
| 122 |
+
}
|
| 123 |
+
initOverlays() {
|
| 124 |
+
const imgs = document.querySelectorAll('.image-with-overlay');
|
| 125 |
+
imgs.forEach(img => {
|
| 126 |
+
if (img.complete) this.setupCanvasFor(img);
|
| 127 |
+
else img.onload = () => this.setupCanvasFor(img);
|
| 128 |
+
});
|
| 129 |
+
}
|
| 130 |
+
setupCanvasFor(imgEl) {
|
| 131 |
+
const idx = parseInt(imgEl.dataset.index, 10);
|
| 132 |
+
const canvas = document.querySelector(`.overlay-canvas[data-index="${idx}"]`);
|
| 133 |
+
if (!canvas) return;
|
| 134 |
+
const rect = imgEl.getBoundingClientRect();
|
| 135 |
+
const dpr = window.devicePixelRatio || 1;
|
| 136 |
+
canvas.width = Math.floor(rect.width * dpr);
|
| 137 |
+
canvas.height = Math.floor(rect.height * dpr);
|
| 138 |
+
const ctx = canvas.getContext('2d');
|
| 139 |
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
| 140 |
+
const natW = imgEl.naturalWidth || rect.width;
|
| 141 |
+
const natH = imgEl.naturalHeight || rect.height;
|
| 142 |
+
const sx = rect.width / natW;
|
| 143 |
+
const sy = rect.height / natH;
|
| 144 |
+
this.drawFaces(ctx, this.analysis.images[idx], sx, sy);
|
| 145 |
+
}
|
| 146 |
+
redrawAll() {
|
| 147 |
+
document.querySelectorAll('.image-with-overlay').forEach(img => this.setupCanvasFor(img));
|
| 148 |
+
}
|
| 149 |
+
drawFaces(ctx, imageAnalysis, sx, sy) {
|
| 150 |
+
const faces = imageAnalysis.faces || [];
|
| 151 |
+
const deepfakes = imageAnalysis.deepfake_faces || [];
|
| 152 |
+
const BOX_COLOR = '#ef4444';
|
| 153 |
+
const BOX_COLOR_DF = '#f59e0b';
|
| 154 |
+
const POINT_COLOR = '#10b981';
|
| 155 |
+
const POINT_COLOR_DF = '#f59e0b';
|
| 156 |
+
const LINE_W = 2;
|
| 157 |
+
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
|
| 158 |
+
faces.forEach((f, i) => {
|
| 159 |
+
const isDF = !!deepfakes[i];
|
| 160 |
+
const stroke = isDF ? BOX_COLOR_DF : BOX_COLOR;
|
| 161 |
+
const dot = isDF ? POINT_COLOR_DF : POINT_COLOR;
|
| 162 |
+
ctx.lineWidth = LINE_W;
|
| 163 |
+
ctx.strokeStyle = stroke;
|
| 164 |
+
const x1 = f.box_start_point[0] * sx + 0.5;
|
| 165 |
+
const y1 = f.box_start_point[1] * sy + 0.5;
|
| 166 |
+
const x2 = f.box_end_point[0] * sx + 0.5;
|
| 167 |
+
const y2 = f.box_end_point[1] * sy + 0.5;
|
| 168 |
+
ctx.strokeRect(x1, y1, x2 - x1, y2 - y1);
|
| 169 |
+
const drawPoint = (pt) => {
|
| 170 |
+
const px = pt[0] * sx, py = pt[1] * sy, size = 4;
|
| 171 |
+
ctx.fillStyle = dot; ctx.fillRect(px - size/2, py - size/2, size, size);
|
| 172 |
+
};
|
| 173 |
+
drawPoint(f.left_eye);
|
| 174 |
+
drawPoint(f.right_eye);
|
| 175 |
+
drawPoint(f.nose);
|
| 176 |
+
drawPoint(f.left_mouth);
|
| 177 |
+
drawPoint(f.right_mouth);
|
| 178 |
+
});
|
| 179 |
+
}
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
document.addEventListener('DOMContentLoaded', () => {
|
| 183 |
+
new IntroController();
|
| 184 |
+
new OverlayController();
|
| 185 |
+
});
|
static/uploads/0a42e714b24849829247e0a44e51953a.webp
ADDED
|
static/uploads/0d337020f9ae491782633516889f3e79.webp
ADDED
|
static/uploads/26ad0b08883347718d38274861bccd8e.webp
ADDED
|
static/uploads/26de02ceda464fb9acd0edb6b0c678db.webp
ADDED
|
static/uploads/28afe2d042a341ceb60c187954deccfc.jpg
ADDED
|
Git LFS Details
|
static/uploads/3156289a7e0e46fa82b7d4f0965aeae7.webp
ADDED
|
static/uploads/37f95b1e0e9c44b79e527a0deafab813.webp
ADDED
|
static/uploads/3b69dc4d23714b3a801c5131b476fafd.webp
ADDED
|
static/uploads/45aa41d76e71411bb28a19de398ba785.webp
ADDED
|