Spaces:
Running
Running
Commit ·
50776af
0
Parent(s):
Deploy: SentinelAI clean build — post-cleanup
Browse files- .gitignore +68 -0
- Dockerfile +31 -0
- README.md +383 -0
- app/app.py +90 -0
- app/inference.py +105 -0
- app/ocr.py +76 -0
- app/pdf_generator.py +465 -0
- app/templates/index.html +1060 -0
- models/train_model_v2.py +144 -0
- requirements.txt +13 -0
.gitignore
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ── Virtual Environment ───────────────────────────────────────────────────────
|
| 2 |
+
env/
|
| 3 |
+
venv/
|
| 4 |
+
.venv/
|
| 5 |
+
.env
|
| 6 |
+
|
| 7 |
+
# ── Python cache ──────────────────────────────────────────────────────────────
|
| 8 |
+
__pycache__/
|
| 9 |
+
*.pyc
|
| 10 |
+
*.pyo
|
| 11 |
+
*.pyd
|
| 12 |
+
|
| 13 |
+
# ── OS files ──────────────────────────────────────────────────────────────────
|
| 14 |
+
.DS_Store
|
| 15 |
+
Thumbs.db
|
| 16 |
+
desktop.ini
|
| 17 |
+
|
| 18 |
+
# ── IDE / Editor ──────────────────────────────────────────────────────────────
|
| 19 |
+
.vscode/
|
| 20 |
+
.idea/
|
| 21 |
+
*.swp
|
| 22 |
+
*.swo
|
| 23 |
+
|
| 24 |
+
# ── PDF output (generated at runtime) ────────────────────────────────────────
|
| 25 |
+
SentinelAI_Report.pdf
|
| 26 |
+
*.pdf
|
| 27 |
+
|
| 28 |
+
# ── Model weights (too large for GitHub — hosted on HuggingFace Hub) ─────────
|
| 29 |
+
# Local model directories, if ever recreated, should not be committed.
|
| 30 |
+
models/sentinel_model/model.safetensors
|
| 31 |
+
scam_model/
|
| 32 |
+
|
| 33 |
+
# ── Training checkpoints & artifacts ─────────────────────────────────────────
|
| 34 |
+
models/results/
|
| 35 |
+
results/
|
| 36 |
+
*.pt
|
| 37 |
+
optimizer.pt
|
| 38 |
+
rng_state.pth
|
| 39 |
+
trainer_state.json
|
| 40 |
+
training_args.bin
|
| 41 |
+
|
| 42 |
+
# ── Logs ──────────────────────────────────────────────────────────────────────
|
| 43 |
+
*.log
|
| 44 |
+
logs/
|
| 45 |
+
|
| 46 |
+
# ── Jupyter ───────────────────────────────────────────────────────────────────
|
| 47 |
+
.ipynb_checkpoints/
|
| 48 |
+
*.ipynb
|
| 49 |
+
|
| 50 |
+
# ── Test / Coverage ───────────────────────────────────────────────────────────
|
| 51 |
+
.pytest_cache/
|
| 52 |
+
htmlcov/
|
| 53 |
+
.coverage
|
| 54 |
+
|
| 55 |
+
# ── Dev / Debug scripts (keep locally, don't push) ───────────────────────────
|
| 56 |
+
app/eval.py
|
| 57 |
+
app/datas.py
|
| 58 |
+
app/texting.py
|
| 59 |
+
app/diagnose_model.py
|
| 60 |
+
app/test_predictions.py
|
| 61 |
+
|
| 62 |
+
# ── Pitch deck / presentation files ──────────────────────────────────────────
|
| 63 |
+
*.pptx
|
| 64 |
+
SentinelAI_PitchDeck.pdf
|
| 65 |
+
|
| 66 |
+
# ── Dataset files (large, keep locally — not for version control) ─────────────
|
| 67 |
+
data/*.csv
|
| 68 |
+
*.csv
|
Dockerfile
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# -- 1. Base image -- slim Python 3.11 on Linux
|
| 2 |
+
FROM python:3.11-slim
|
| 3 |
+
|
| 4 |
+
# -- 2. Set working directory inside the container
|
| 5 |
+
WORKDIR /app
|
| 6 |
+
|
| 7 |
+
# -- 3. Install system dependencies
|
| 8 |
+
RUN apt-get update && apt-get install -y \
|
| 9 |
+
gcc \
|
| 10 |
+
tesseract-ocr \
|
| 11 |
+
tesseract-ocr-eng \
|
| 12 |
+
libglib2.0-0 \
|
| 13 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 14 |
+
|
| 15 |
+
# -- 4. Copy requirements first (Docker layer caching)
|
| 16 |
+
COPY requirements.txt .
|
| 17 |
+
|
| 18 |
+
# -- 5. Install PyTorch CPU-only FIRST (much smaller, ~200MB vs 2GB)
|
| 19 |
+
RUN pip install --no-cache-dir torch==2.2.2+cpu --index-url https://download.pytorch.org/whl/cpu
|
| 20 |
+
|
| 21 |
+
# -- 6. Install remaining Python dependencies
|
| 22 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 23 |
+
|
| 24 |
+
# -- 7. Copy the entire project into the container
|
| 25 |
+
COPY . .
|
| 26 |
+
|
| 27 |
+
# -- 8. HuggingFace Spaces requires port 7860
|
| 28 |
+
EXPOSE 7860
|
| 29 |
+
|
| 30 |
+
# -- 9. Start the app
|
| 31 |
+
CMD ["python", "app/app.py"]
|
README.md
ADDED
|
@@ -0,0 +1,383 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: SentinelAI
|
| 3 |
+
emoji: 🛡️
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: red
|
| 6 |
+
sdk: docker
|
| 7 |
+
pinned: false
|
| 8 |
+
---
|
| 9 |
+
|
| 10 |
+
<div align="center">
|
| 11 |
+
|
| 12 |
+
# 🛡️ SentinelAI
|
| 13 |
+
|
| 14 |
+
### AI-Powered Digital Threat Intelligence Engine
|
| 15 |
+
|
| 16 |
+
*Detect scams. Quantify risk. Generate intelligence briefs.*
|
| 17 |
+
|
| 18 |
+
[](https://python.org)
|
| 19 |
+
[](https://flask.palletsprojects.com)
|
| 20 |
+
[](https://huggingface.co)
|
| 21 |
+
[](https://pytorch.org)
|
| 22 |
+
[](/)
|
| 23 |
+
[](/)
|
| 24 |
+
[](LICENSE)
|
| 25 |
+
|
| 26 |
+
</div>
|
| 27 |
+
|
| 28 |
+
---
|
| 29 |
+
|
| 30 |
+
## 📋 Overview
|
| 31 |
+
|
| 32 |
+
**SentinelAI** is a real-time digital threat analysis system that uses a **fine-tuned DistilBERT transformer** combined with **rule-based heuristic pre-filtering** to detect digital arrest scams, phishing attempts, and financial fraud in text messages.
|
| 33 |
+
|
| 34 |
+
The system features:
|
| 35 |
+
- A **hybrid inference pipeline** (rules + neural network) achieving **97.42% accuracy**
|
| 36 |
+
- A **tactical HUD-style frontend** with animated score visualizations and threat indicator chips
|
| 37 |
+
- A **downloadable PDF intelligence brief** formatted like a professional security document
|
| 38 |
+
|
| 39 |
+
---
|
| 40 |
+
|
| 41 |
+
## 🖥️ UI Preview
|
| 42 |
+
|
| 43 |
+
> Dark-themed tactical HUD with animated SVG score arc, threat indicator chips, OCR image upload, and a downloadable intelligence brief.
|
| 44 |
+
|
| 45 |
+
---
|
| 46 |
+
|
| 47 |
+
## 🧠 Model Performance
|
| 48 |
+
|
| 49 |
+
The DistilBERT model was fine-tuned on a **curated digital arrest scam corpus** and evaluated on a held-out test set of **155 samples**.
|
| 50 |
+
|
| 51 |
+
### Final Evaluation Metrics
|
| 52 |
+
|
| 53 |
+
| Metric | Score |
|
| 54 |
+
|---|---|
|
| 55 |
+
| **Accuracy** | **97.42%** |
|
| 56 |
+
| **F1 Score** | **97.59%** |
|
| 57 |
+
| **Precision** | **97.59%** |
|
| 58 |
+
| **Recall** | **97.59%** |
|
| 59 |
+
|
| 60 |
+
### Confusion Matrix
|
| 61 |
+
|
| 62 |
+
| | Predicted SAFE | Predicted SCAM |
|
| 63 |
+
|---|---|---|
|
| 64 |
+
| **Actual SAFE** | 70 | 2 |
|
| 65 |
+
| **Actual SCAM** | 2 | 81 |
|
| 66 |
+
|
| 67 |
+
> Out of 155 test samples, the model produced only **4 misclassifications** (2 false positives + 2 false negatives), achieving a near-perfect detection rate.
|
| 68 |
+
|
| 69 |
+
### Key Takeaways
|
| 70 |
+
- **False Positive Rate**: 2.78% — only 2 out of 72 safe messages were incorrectly flagged
|
| 71 |
+
- **False Negative Rate**: 2.41% — only 2 out of 83 scam messages were missed
|
| 72 |
+
- **Balanced performance**: Equal precision and recall indicate the model doesn't bias toward either class
|
| 73 |
+
|
| 74 |
+
---
|
| 75 |
+
|
| 76 |
+
## ✨ Features
|
| 77 |
+
|
| 78 |
+
| Feature | Description |
|
| 79 |
+
|---|---|
|
| 80 |
+
| 🤖 **Fine-Tuned DistilBERT** | Transformer model trained on curated digital scam corpus with 97.42% accuracy |
|
| 81 |
+
| ⚡ **Hybrid Inference Engine** | Rule-based pre-screening (10 signal patterns) + neural network fallback |
|
| 82 |
+
| 🎯 **Real-Time Risk Scoring** | Probabilistic scam/safe scoring with HIGH / MEDIUM / LOW classification |
|
| 83 |
+
| 🔍 **Signal Extraction** | Names specific scam tactics detected (authority threats, urgency pressure, etc.) |
|
| 84 |
+
| 🖥️ **Tactical HUD Interface** | Dark-themed dashboard with animated SVG score arcs and threat indicator chips |
|
| 85 |
+
| 📄 **PDF Intelligence Brief** | Professional SIB-format report with risk bands, signal tables, and action items |
|
| 86 |
+
| ⌨️ **Keyboard Shortcuts** | Ctrl+Enter to run analysis |
|
| 87 |
+
|
| 88 |
+
---
|
| 89 |
+
|
| 90 |
+
## 🏗️ System Architecture
|
| 91 |
+
|
| 92 |
+
```mermaid
|
| 93 |
+
flowchart TD
|
| 94 |
+
A["👤 User\nPastes suspicious message"] --> B["🌐 Flask Web App\napp.py"]
|
| 95 |
+
|
| 96 |
+
B --> C["🔍 Hybrid Inference Engine\ninference.py"]
|
| 97 |
+
|
| 98 |
+
C --> D{"Rule-Based\nPre-filter"}
|
| 99 |
+
D -->|"≥2 scam signals matched"| E["🔴 HIGH RISK\nReturn immediately"]
|
| 100 |
+
D -->|"Safe keywords matched"| F["🟢 LOW RISK\nReturn immediately"]
|
| 101 |
+
D -->|"Ambiguous"| G["🤖 DistilBERT Model\nsentinel_model/"]
|
| 102 |
+
|
| 103 |
+
G --> H["Softmax Probabilities\nSCAM vs SAFE"]
|
| 104 |
+
H --> I["Probability Adjustment\nvia rule scores"]
|
| 105 |
+
I --> J["Final Classification\n+ risk_level + signals"]
|
| 106 |
+
|
| 107 |
+
E --> K["📡 JSON Response\nto Frontend"]
|
| 108 |
+
F --> K
|
| 109 |
+
J --> K
|
| 110 |
+
|
| 111 |
+
K --> L["🖥️ Tactical HUD UI\nScore arc + threat chips"]
|
| 112 |
+
L --> M{"User clicks\nDownload Brief?"}
|
| 113 |
+
M -->|Yes| N["📄 pdf_generator.py\nStructured Intelligence Brief"]
|
| 114 |
+
N --> O["⬇️ PDF Download\nSentinelAI_Report.pdf"]
|
| 115 |
+
```
|
| 116 |
+
|
| 117 |
+
---
|
| 118 |
+
|
| 119 |
+
## 🔬 Inference Pipeline
|
| 120 |
+
|
| 121 |
+
```mermaid
|
| 122 |
+
sequenceDiagram
|
| 123 |
+
participant U as User
|
| 124 |
+
participant F as Flask API
|
| 125 |
+
participant R as Rule Engine
|
| 126 |
+
participant M as DistilBERT Model
|
| 127 |
+
participant P as PDF Generator
|
| 128 |
+
|
| 129 |
+
U->>F: POST /analyze { message }
|
| 130 |
+
F->>R: Check 10 scam signal patterns
|
| 131 |
+
alt ≥2 patterns matched
|
| 132 |
+
R-->>F: HIGH RISK + matched signals
|
| 133 |
+
else Safe keywords found
|
| 134 |
+
R-->>F: LOW RISK + empty signals
|
| 135 |
+
else Ambiguous
|
| 136 |
+
R->>M: Tokenize + forward pass
|
| 137 |
+
M-->>R: Softmax probabilities
|
| 138 |
+
R-->>F: Adjusted label + risk_level + signals
|
| 139 |
+
end
|
| 140 |
+
F-->>U: JSON { label, scam_probability, risk_level, signals }
|
| 141 |
+
|
| 142 |
+
U->>F: POST /download_report { analysis data }
|
| 143 |
+
F->>P: generate_pdf(data, filepath)
|
| 144 |
+
P-->>F: SentinelAI_Report.pdf
|
| 145 |
+
F-->>U: PDF file download
|
| 146 |
+
```
|
| 147 |
+
|
| 148 |
+
---
|
| 149 |
+
|
| 150 |
+
## 🔬 Technical Deep Dive
|
| 151 |
+
|
| 152 |
+
### Hybrid Inference Strategy
|
| 153 |
+
|
| 154 |
+
The inference engine uses a **two-stage approach** to maximize both speed and accuracy:
|
| 155 |
+
|
| 156 |
+
**Stage 1 — Rule-Based Pre-filter** (instant, zero-cost):
|
| 157 |
+
- Scans the input against **10 regex-based scam signal patterns**
|
| 158 |
+
- If ≥2 patterns match → immediately returns `HIGH RISK` (no model inference needed)
|
| 159 |
+
- If safe keywords match and no scam signals → immediately returns `LOW RISK`
|
| 160 |
+
- This handles clear-cut cases in **<1ms** without loading the model
|
| 161 |
+
|
| 162 |
+
**Stage 2 — Neural Network** (for ambiguous cases):
|
| 163 |
+
- Tokenizes the input using DistilBERT's WordPiece tokenizer
|
| 164 |
+
- Performs a forward pass through the fine-tuned model
|
| 165 |
+
- Applies softmax to get SCAM vs SAFE probabilities
|
| 166 |
+
- Adjusts probabilities using partial rule scores for better calibration
|
| 167 |
+
|
| 168 |
+
### Detected Scam Signal Categories
|
| 169 |
+
|
| 170 |
+
| # | Signal | Example Pattern |
|
| 171 |
+
|---|---|---|
|
| 172 |
+
| 1 | Arrest / legal authority threat | *"FBI warrant", "CBI enforcement"* |
|
| 173 |
+
| 2 | Urgency / time pressure | *"immediate", "within 2 hours"* |
|
| 174 |
+
| 3 | Payment demand with urgency | *"transfer funds now"* |
|
| 175 |
+
| 4 | Phishing link / click-bait | *"click here to verify"* |
|
| 176 |
+
| 5 | Account suspension threat | *"your account is frozen"* |
|
| 177 |
+
| 6 | Prize / lottery scam | *"you have won a prize"* |
|
| 178 |
+
| 7 | Credential / remote access request | *"share OTP", "install AnyDesk"* |
|
| 179 |
+
| 8 | Digital arrest pattern | *"stay on the line"* |
|
| 180 |
+
| 9 | Isolation / secrecy demand | *"do not tell anyone"* |
|
| 181 |
+
| 10 | Document / ID fraud | *"your Aadhaar is blocked"* |
|
| 182 |
+
|
| 183 |
+
---
|
| 184 |
+
|
| 185 |
+
## 📁 Project Structure
|
| 186 |
+
|
| 187 |
+
```
|
| 188 |
+
Sentinel/
|
| 189 |
+
│
|
| 190 |
+
├── app/
|
| 191 |
+
│ ├── app.py # Flask routes: /, /analyze, /ocr_analyze, /download_report
|
| 192 |
+
│ ├── inference.py # Hybrid prediction engine (rules + DistilBERT)
|
| 193 |
+
│ ├── ocr.py # Image → text extraction via Tesseract
|
| 194 |
+
│ ├── pdf_generator.py # Structured Intelligence Brief PDF generator
|
| 195 |
+
│ └── templates/
|
| 196 |
+
│ └── index.html # Tactical HUD SPA (Tailwind CSS, dark theme)
|
| 197 |
+
│
|
| 198 |
+
├── models/
|
| 199 |
+
│ └── train_model_v2.py # Training script (for reference — model on HuggingFace)
|
| 200 |
+
│
|
| 201 |
+
├── data/
|
| 202 |
+
│ └── sentinel_dataset_v3_final.csv # Final training dataset (14,000 rows, 60:40 ratio)
|
| 203 |
+
│
|
| 204 |
+
├── Dockerfile # HuggingFace Spaces deployment (port 7860)
|
| 205 |
+
├── requirements.txt
|
| 206 |
+
├── .gitignore
|
| 207 |
+
└── README.md
|
| 208 |
+
```
|
| 209 |
+
|
| 210 |
+
> ⚠️ The trained model weights are **not stored locally** — they are loaded directly from
|
| 211 |
+
> [`Shade63/sentinel-model`](https://huggingface.co/Shade63/sentinel-model) on HuggingFace Hub.
|
| 212 |
+
|
| 213 |
+
---
|
| 214 |
+
|
| 215 |
+
## 🛠️ Tech Stack
|
| 216 |
+
|
| 217 |
+
| Layer | Technology | Purpose |
|
| 218 |
+
|---|---|---|
|
| 219 |
+
| **ML Model** | DistilBERT (HuggingFace Transformers) | Fine-tuned binary classifier for scam detection |
|
| 220 |
+
| **ML Framework** | PyTorch 2.2 | Tensor operations and model inference |
|
| 221 |
+
| **Backend** | Flask 3.0 | REST API serving `/analyze` and `/download_report` |
|
| 222 |
+
| **Frontend** | HTML + Tailwind CSS + Vanilla JS | Tactical HUD single-page application |
|
| 223 |
+
| **PDF Engine** | ReportLab 4.1 | Generates dark-themed Structured Intelligence Briefs |
|
| 224 |
+
| **Data Processing** | Pandas + scikit-learn | Dataset management and evaluation metrics |
|
| 225 |
+
|
| 226 |
+
---
|
| 227 |
+
|
| 228 |
+
## ⚙️ Setup & Installation
|
| 229 |
+
|
| 230 |
+
### Prerequisites
|
| 231 |
+
- Python 3.10+
|
| 232 |
+
- pip
|
| 233 |
+
|
| 234 |
+
### 1. Clone the repository
|
| 235 |
+
```bash
|
| 236 |
+
git clone https://github.com/Shade-63/Sentinel.git
|
| 237 |
+
cd Sentinel
|
| 238 |
+
```
|
| 239 |
+
|
| 240 |
+
### 2. Create and activate a virtual environment
|
| 241 |
+
```bash
|
| 242 |
+
python -m venv env
|
| 243 |
+
|
| 244 |
+
# Windows
|
| 245 |
+
env\Scripts\activate
|
| 246 |
+
|
| 247 |
+
# macOS / Linux
|
| 248 |
+
source env/bin/activate
|
| 249 |
+
```
|
| 250 |
+
|
| 251 |
+
### 3. Install dependencies
|
| 252 |
+
```bash
|
| 253 |
+
pip install -r requirements.txt
|
| 254 |
+
```
|
| 255 |
+
|
| 256 |
+
### 4. Download the model weights
|
| 257 |
+
|
| 258 |
+
> ⚠️ `model.safetensors` (~255 MB) is **not included** in this repo due to GitHub's 100 MB file limit.
|
| 259 |
+
|
| 260 |
+
**Option A — From Releases** *(recommended)*
|
| 261 |
+
Download `model.safetensors` from the [Releases page](../../releases) and place it at:
|
| 262 |
+
```
|
| 263 |
+
models/sentinel_model/model.safetensors
|
| 264 |
+
```
|
| 265 |
+
|
| 266 |
+
**Option B — Train from scratch**
|
| 267 |
+
```bash
|
| 268 |
+
cd models
|
| 269 |
+
python train_model_v2.py
|
| 270 |
+
```
|
| 271 |
+
|
| 272 |
+
### 5. Run the application
|
| 273 |
+
```bash
|
| 274 |
+
cd app
|
| 275 |
+
python app.py
|
| 276 |
+
```
|
| 277 |
+
|
| 278 |
+
Open **http://127.0.0.1:7860** in your browser.
|
| 279 |
+
|
| 280 |
+
---
|
| 281 |
+
|
| 282 |
+
## 🔌 API Reference
|
| 283 |
+
|
| 284 |
+
### `POST /analyze`
|
| 285 |
+
|
| 286 |
+
Analyzes a text message for scam indicators.
|
| 287 |
+
|
| 288 |
+
**Request:**
|
| 289 |
+
```json
|
| 290 |
+
{
|
| 291 |
+
"message": "FBI warrant arrest immediate payment"
|
| 292 |
+
}
|
| 293 |
+
```
|
| 294 |
+
|
| 295 |
+
**Response:**
|
| 296 |
+
```json
|
| 297 |
+
{
|
| 298 |
+
"label": "SCAM",
|
| 299 |
+
"scam_probability": 0.99,
|
| 300 |
+
"safe_probability": 0.01,
|
| 301 |
+
"risk_level": "HIGH",
|
| 302 |
+
"signals": [
|
| 303 |
+
"Arrest or legal authority threat",
|
| 304 |
+
"Urgency / time pressure",
|
| 305 |
+
"Payment demand with urgency"
|
| 306 |
+
]
|
| 307 |
+
}
|
| 308 |
+
```
|
| 309 |
+
|
| 310 |
+
### `POST /download_report`
|
| 311 |
+
|
| 312 |
+
Generates and returns a PDF Structured Intelligence Brief.
|
| 313 |
+
|
| 314 |
+
**Request:**
|
| 315 |
+
```json
|
| 316 |
+
{
|
| 317 |
+
"message": "...",
|
| 318 |
+
"risk_score": "99.0",
|
| 319 |
+
"risk_level": "HIGH",
|
| 320 |
+
"signals": ["Arrest or legal authority threat"]
|
| 321 |
+
}
|
| 322 |
+
```
|
| 323 |
+
|
| 324 |
+
**Response:** Binary PDF file download
|
| 325 |
+
|
| 326 |
+
---
|
| 327 |
+
|
| 328 |
+
## 📄 PDF Report — Structured Intelligence Brief
|
| 329 |
+
|
| 330 |
+
After analysis, click **DOWNLOAD INTELLIGENCE BRIEF** to get a formatted SIB containing:
|
| 331 |
+
|
| 332 |
+
```
|
| 333 |
+
┌──────────────────────────────────────────────────────────┐
|
| 334 |
+
│ SENTINELAI · STRUCTURED INTELLIGENCE BRIEF │
|
| 335 |
+
│ Report ID: SIB-20260320-170900 Generated: 20 Mar 2026 │
|
| 336 |
+
├──────────────────────────────────────────────────────────┤
|
| 337 |
+
│ ⬛ THREAT INTELLIGENCE REPORT — CONFIDENTIAL │
|
| 338 |
+
├──────────────┬──────────────┬──────────────┬─────────────┤
|
| 339 |
+
│ CLASSIFICATION│ REPORT TYPE │ ENGINE │ TIMESTAMP │
|
| 340 |
+
├──────────────┴──────────────┴──────────────┴─────────────┤
|
| 341 |
+
│ │
|
| 342 |
+
│ 🔴 HIGH RISK — SCAM DETECTED 99.0% │
|
| 343 |
+
│ █████████████████████████████████░ progress bar │
|
| 344 |
+
│ │
|
| 345 |
+
├──────────────────────────────────────────────────────────┤
|
| 346 |
+
│ 01 · INTERCEPTED COMMUNICATION │
|
| 347 |
+
│ 02 · DETECTED RISK INDICATORS (numbered table) │
|
| 348 |
+
│ 03 · AI MODEL INTERPRETATION (key-value table) │
|
| 349 |
+
│ 04 · RECOMMENDED IMMEDIATE ACTIONS (CRITICAL/HIGH/MED) │
|
| 350 |
+
├──────────────────────────────────────────────────────────┤
|
| 351 |
+
│ CONFIDENTIAL — FOR AUTHORIZED USE ONLY Page 1 │
|
| 352 |
+
└──────────────────────────────────────────────────────────┘
|
| 353 |
+
```
|
| 354 |
+
|
| 355 |
+
---
|
| 356 |
+
|
| 357 |
+
## 🚀 What Makes This Different
|
| 358 |
+
|
| 359 |
+
| Aspect | SentinelAI | Simple Keyword Matching |
|
| 360 |
+
|---|---|---|
|
| 361 |
+
| **Detection Method** | Fine-tuned transformer + rule engine | Static keyword lists |
|
| 362 |
+
| **Accuracy** | 97.42% | ~70% (high false-positive rate) |
|
| 363 |
+
| **Context Understanding** | Understands sentence-level semantics | Matches isolated words |
|
| 364 |
+
| **Signal Extraction** | Names specific tactics used | No explanation |
|
| 365 |
+
| **Risk Quantification** | Probabilistic score (0-100%) | Binary yes/no |
|
| 366 |
+
| **Output** | Professional PDF intelligence brief | Plain text alert |
|
| 367 |
+
|
| 368 |
+
---
|
| 369 |
+
|
| 370 |
+
## ⚠️ Disclaimer
|
| 371 |
+
|
| 372 |
+
SentinelAI provides AI-based probabilistic risk estimation and does **not** constitute legal advice. All findings are based on pattern recognition and should be verified through official law enforcement or financial authorities. This tool is intended for educational and informational purposes only.
|
| 373 |
+
|
| 374 |
+
---
|
| 375 |
+
|
| 376 |
+
<div align="center">
|
| 377 |
+
|
| 378 |
+
**Built with** Flask · HuggingFace Transformers · PyTorch · ReportLab
|
| 379 |
+
|
| 380 |
+
*Protecting innocents from digital threats through AI-powered intelligence*
|
| 381 |
+
|
| 382 |
+
</div>
|
| 383 |
+
|
app/app.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from flask import Flask, render_template, request, jsonify, send_file
|
| 2 |
+
from inference import predict
|
| 3 |
+
from pdf_generator import generate_pdf
|
| 4 |
+
from ocr import extract_text_from_image, allowed_file
|
| 5 |
+
import os
|
| 6 |
+
import tempfile
|
| 7 |
+
|
| 8 |
+
app = Flask(__name__)
|
| 9 |
+
|
| 10 |
+
# Limit incoming request body to 15 MB (guards /ocr_analyze against huge uploads)
|
| 11 |
+
app.config["MAX_CONTENT_LENGTH"] = 15 * 1024 * 1024
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@app.route("/")
|
| 15 |
+
def home():
|
| 16 |
+
return render_template("index.html")
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@app.route("/analyze", methods=["POST"])
|
| 20 |
+
def analyze():
|
| 21 |
+
data = request.get_json(silent=True)
|
| 22 |
+
|
| 23 |
+
if not data or not data.get("message"):
|
| 24 |
+
return jsonify({"error": "No message provided."}), 400
|
| 25 |
+
|
| 26 |
+
text = data["message"].strip()
|
| 27 |
+
if not text:
|
| 28 |
+
return jsonify({"error": "Message cannot be empty."}), 400
|
| 29 |
+
|
| 30 |
+
result = predict(text)
|
| 31 |
+
return jsonify(result)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@app.route("/ocr_analyze", methods=["POST"])
|
| 35 |
+
def ocr_analyze():
|
| 36 |
+
if "image" not in request.files:
|
| 37 |
+
return jsonify({"error": "No image uploaded."}), 400
|
| 38 |
+
|
| 39 |
+
file = request.files["image"]
|
| 40 |
+
|
| 41 |
+
if file.filename == "":
|
| 42 |
+
return jsonify({"error": "No file selected."}), 400
|
| 43 |
+
|
| 44 |
+
if not allowed_file(file.filename):
|
| 45 |
+
return jsonify({"error": "Unsupported file type. Use PNG, JPG, JPEG, WEBP, or BMP."}), 400
|
| 46 |
+
|
| 47 |
+
file_bytes = file.read()
|
| 48 |
+
ocr_result = extract_text_from_image(file_bytes)
|
| 49 |
+
|
| 50 |
+
if not ocr_result["success"]:
|
| 51 |
+
return jsonify({"error": ocr_result["error"]}), 422
|
| 52 |
+
|
| 53 |
+
extracted_text = ocr_result["text"]
|
| 54 |
+
|
| 55 |
+
# Pipe directly into existing inference — zero changes to inference.py
|
| 56 |
+
prediction = predict(extracted_text)
|
| 57 |
+
prediction["extracted_text"] = extracted_text
|
| 58 |
+
prediction["input_method"] = "ocr"
|
| 59 |
+
|
| 60 |
+
return jsonify(prediction)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
@app.route("/download_report", methods=["POST"])
|
| 64 |
+
def download_report():
|
| 65 |
+
data = request.get_json(silent=True)
|
| 66 |
+
if not data:
|
| 67 |
+
return jsonify({"error": "No data provided."}), 400
|
| 68 |
+
|
| 69 |
+
# Use a temp file so concurrent requests don't overwrite each other
|
| 70 |
+
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp:
|
| 71 |
+
filepath = tmp.name
|
| 72 |
+
|
| 73 |
+
try:
|
| 74 |
+
generate_pdf(data, filepath)
|
| 75 |
+
return send_file(
|
| 76 |
+
filepath,
|
| 77 |
+
as_attachment=True,
|
| 78 |
+
mimetype="application/pdf",
|
| 79 |
+
download_name="SentinelAI_Report.pdf",
|
| 80 |
+
)
|
| 81 |
+
finally:
|
| 82 |
+
# Clean up the temp file after Flask sends it
|
| 83 |
+
try:
|
| 84 |
+
os.unlink(filepath)
|
| 85 |
+
except OSError:
|
| 86 |
+
pass
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
if __name__ == "__main__":
|
| 90 |
+
app.run(host="0.0.0.0", port=7860, debug=False)
|
app/inference.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import torch
|
| 2 |
+
import os
|
| 3 |
+
import re
|
| 4 |
+
from transformers import DistilBertTokenizerFast, DistilBertForSequenceClassification
|
| 5 |
+
|
| 6 |
+
# Changed: load from HuggingFace Hub instead of local path
|
| 7 |
+
tokenizer = DistilBertTokenizerFast.from_pretrained("Shade63/sentinel-model")
|
| 8 |
+
model = DistilBertForSequenceClassification.from_pretrained("Shade63/sentinel-model")
|
| 9 |
+
model.eval()
|
| 10 |
+
|
| 11 |
+
# Rule-based scam indicators (high confidence patterns)
|
| 12 |
+
SCAM_SIGNAL_LABELS = [
|
| 13 |
+
("arrest or legal authority threat", r'\b(arrest|warrant|fbi|cbi|irs|enforcement|legal action)\b'),
|
| 14 |
+
("urgency / time pressure", r'\b(urgent|immediate|now|today|within \d+ (hours?|minutes?))\b'),
|
| 15 |
+
("payment demand with urgency", r'\b(pay|payment|transfer|wire|funds?|money)\b.*\b(immediate|now|urgent)\b'),
|
| 16 |
+
("phishing link / click-bait", r'\b(click|verify|confirm).*\b(link|here|now)\b'),
|
| 17 |
+
("account suspension threat", r'\b(suspended|blocked|frozen|invalid).*\b(account|card|number)\b'),
|
| 18 |
+
("prize / lottery scam", r'\b(won|prize|lottery|claim)\b'),
|
| 19 |
+
("credential / remote access request", r'\b(otp|password|remote access|teamviewer|anydesk)\b'),
|
| 20 |
+
("digital arrest pattern", r'\b(digital arrest|stay on (the )?line)\b'),
|
| 21 |
+
("isolation / secrecy demand", r'\b(do not (disconnect|tell|go to))\b'),
|
| 22 |
+
("document / ID fraud", r'\b(aadhaar|sim|passport|visa).*\b(illegal|invalid|blocked)\b'),
|
| 23 |
+
]
|
| 24 |
+
|
| 25 |
+
SAFE_KEYWORDS = [
|
| 26 |
+
r'\b(official public advisory|government agencies do not)\b',
|
| 27 |
+
r'\b(legitimate authority|verifiable credentials)\b',
|
| 28 |
+
r'\b(official portal|registered mail|written documentation)\b',
|
| 29 |
+
r'\b(meeting|agenda|schedule|call|project)\b',
|
| 30 |
+
r'\b(hello|hi|how are you)\b',
|
| 31 |
+
]
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def predict(text):
|
| 35 |
+
text_lower = text.lower()
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
# Rule-based scoring
|
| 39 |
+
scam_score = 0
|
| 40 |
+
safe_score = 0
|
| 41 |
+
matched_signals = []
|
| 42 |
+
|
| 43 |
+
for label, pattern in SCAM_SIGNAL_LABELS:
|
| 44 |
+
if re.search(pattern, text_lower, re.IGNORECASE):
|
| 45 |
+
scam_score += 1
|
| 46 |
+
matched_signals.append(label.capitalize())
|
| 47 |
+
|
| 48 |
+
for pattern in SAFE_KEYWORDS:
|
| 49 |
+
if re.search(pattern, text_lower, re.IGNORECASE):
|
| 50 |
+
safe_score += 1
|
| 51 |
+
|
| 52 |
+
# If strong rule-based signal, use it
|
| 53 |
+
if scam_score >= 2:
|
| 54 |
+
scam_prob = min(0.85 + (scam_score * 0.05), 0.99)
|
| 55 |
+
safe_prob = max(0.15 - (scam_score * 0.05), 0.01)
|
| 56 |
+
return {
|
| 57 |
+
"label": "SCAM",
|
| 58 |
+
"scam_probability": round(scam_prob, 4),
|
| 59 |
+
"safe_probability": round(safe_prob, 4),
|
| 60 |
+
"risk_level": "HIGH",
|
| 61 |
+
"signals": matched_signals,
|
| 62 |
+
}
|
| 63 |
+
elif safe_score >= 1 and scam_score == 0:
|
| 64 |
+
return {
|
| 65 |
+
"label": "SAFE",
|
| 66 |
+
"scam_probability": 0.15,
|
| 67 |
+
"safe_probability": 0.85,
|
| 68 |
+
"risk_level": "LOW",
|
| 69 |
+
"signals": [],
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
# Otherwise, use model prediction
|
| 73 |
+
inputs = tokenizer(text, return_tensors="pt", truncation=True, padding=True)
|
| 74 |
+
|
| 75 |
+
with torch.no_grad():
|
| 76 |
+
outputs = model(**inputs)
|
| 77 |
+
|
| 78 |
+
logits = outputs.logits
|
| 79 |
+
probabilities = torch.softmax(logits, dim=1)
|
| 80 |
+
|
| 81 |
+
scam_prob = probabilities[0][1].item()
|
| 82 |
+
safe_prob = probabilities[0][0].item()
|
| 83 |
+
|
| 84 |
+
# Adjust probabilities based on rule scores
|
| 85 |
+
if scam_score > 0:
|
| 86 |
+
scam_prob = min(scam_prob + (scam_score * 0.1), 0.95)
|
| 87 |
+
safe_prob = 1 - scam_prob
|
| 88 |
+
elif safe_score > 0:
|
| 89 |
+
safe_prob = min(safe_prob + (safe_score * 0.1), 0.95)
|
| 90 |
+
scam_prob = 1 - safe_prob
|
| 91 |
+
|
| 92 |
+
label = "SCAM" if scam_prob > 0.5 else "SAFE"
|
| 93 |
+
|
| 94 |
+
if label == "SCAM":
|
| 95 |
+
risk_level = "HIGH" if scam_prob >= 0.75 else "MEDIUM"
|
| 96 |
+
else:
|
| 97 |
+
risk_level = "LOW"
|
| 98 |
+
|
| 99 |
+
return {
|
| 100 |
+
"label": label,
|
| 101 |
+
"scam_probability": round(scam_prob, 4),
|
| 102 |
+
"safe_probability": round(safe_prob, 4),
|
| 103 |
+
"risk_level": risk_level,
|
| 104 |
+
"signals": matched_signals,
|
| 105 |
+
}
|
app/ocr.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytesseract
|
| 2 |
+
from PIL import Image, ImageFilter, ImageEnhance
|
| 3 |
+
import io
|
| 4 |
+
import re
|
| 5 |
+
import os
|
| 6 |
+
|
| 7 |
+
if os.name == 'nt': # 'nt' = Windows, anything else = Linux/Mac
|
| 8 |
+
pytesseract.pytesseract.tesseract_cmd = r'C:\Program Files\Tesseract-OCR\tesseract.exe'
|
| 9 |
+
|
| 10 |
+
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'webp', 'bmp'}
|
| 11 |
+
MAX_FILE_SIZE_MB = 10
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def allowed_file(filename: str) -> bool:
|
| 15 |
+
return '.' in filename and filename.rsplit('.', 1)[-1].lower() in ALLOWED_EXTENSIONS
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def preprocess_image(image: Image.Image) -> Image.Image:
|
| 19 |
+
"""Grayscale + sharpen + contrast boost — helps with dark WhatsApp screenshots."""
|
| 20 |
+
image = image.convert('L')
|
| 21 |
+
image = image.filter(ImageFilter.SHARPEN)
|
| 22 |
+
enhancer = ImageEnhance.Contrast(image)
|
| 23 |
+
return enhancer.enhance(2.0)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def clean_ocr_text(raw_text: str) -> str:
|
| 27 |
+
"""Strip blank lines, non-ASCII junk, and collapse whitespace."""
|
| 28 |
+
lines = [line.strip() for line in raw_text.splitlines() if line.strip()]
|
| 29 |
+
joined = ' '.join(lines)
|
| 30 |
+
joined = re.sub(r'[^\x20-\x7E\n]', ' ', joined)
|
| 31 |
+
return re.sub(r' +', ' ', joined).strip()
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def extract_text_from_image(file_bytes: bytes) -> dict:
|
| 35 |
+
"""
|
| 36 |
+
Main entry point. Takes raw image bytes, returns:
|
| 37 |
+
{ 'success': True, 'text': <cleaned str>, 'raw': <raw str> }
|
| 38 |
+
{ 'success': False, 'error': <message> }
|
| 39 |
+
"""
|
| 40 |
+
# Size check
|
| 41 |
+
size_mb = len(file_bytes) / (1024 * 1024)
|
| 42 |
+
if size_mb > MAX_FILE_SIZE_MB:
|
| 43 |
+
return {'success': False, 'error': f'File too large ({size_mb:.1f} MB). Max is {MAX_FILE_SIZE_MB} MB.'}
|
| 44 |
+
|
| 45 |
+
# Open image
|
| 46 |
+
try:
|
| 47 |
+
image = Image.open(io.BytesIO(file_bytes))
|
| 48 |
+
except Exception:
|
| 49 |
+
return {'success': False, 'error': 'Could not open image. Make sure it is a valid PNG, JPG, or WEBP.'}
|
| 50 |
+
|
| 51 |
+
# Resize if very wide (Tesseract slows down on huge images)
|
| 52 |
+
if image.width > 1600:
|
| 53 |
+
ratio = 1600 / image.width
|
| 54 |
+
image = image.resize((1600, int(image.height * ratio)), Image.LANCZOS)
|
| 55 |
+
|
| 56 |
+
# Preprocess and OCR
|
| 57 |
+
try:
|
| 58 |
+
processed = preprocess_image(image)
|
| 59 |
+
raw_text = pytesseract.image_to_string(processed, config='--oem 3 --psm 6')
|
| 60 |
+
except pytesseract.TesseractNotFoundError:
|
| 61 |
+
return {
|
| 62 |
+
'success': False,
|
| 63 |
+
'error': 'Tesseract binary not found. Make sure it is installed and the path is correct in ocr.py.'
|
| 64 |
+
}
|
| 65 |
+
except Exception as e:
|
| 66 |
+
return {'success': False, 'error': f'OCR failed: {str(e)}'}
|
| 67 |
+
|
| 68 |
+
cleaned = clean_ocr_text(raw_text)
|
| 69 |
+
|
| 70 |
+
if len(cleaned) < 10:
|
| 71 |
+
return {
|
| 72 |
+
'success': False,
|
| 73 |
+
'error': 'No readable text found. Try a clearer, higher-contrast screenshot.'
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
return {'success': True, 'text': cleaned, 'raw': raw_text}
|
app/pdf_generator.py
ADDED
|
@@ -0,0 +1,465 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from reportlab.platypus import (
|
| 2 |
+
SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle,
|
| 3 |
+
HRFlowable, KeepTogether
|
| 4 |
+
)
|
| 5 |
+
from reportlab.platypus import BaseDocTemplate, PageTemplate, Frame
|
| 6 |
+
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
|
| 7 |
+
from reportlab.lib import colors
|
| 8 |
+
from reportlab.lib.units import inch, mm
|
| 9 |
+
from reportlab.lib.enums import TA_LEFT, TA_CENTER, TA_RIGHT
|
| 10 |
+
from reportlab.lib.pagesizes import A4
|
| 11 |
+
from reportlab.pdfgen import canvas
|
| 12 |
+
from datetime import datetime
|
| 13 |
+
import os
|
| 14 |
+
|
| 15 |
+
# ── Palette ──────────────────────────────────────────────────────────────────
|
| 16 |
+
DARK_BG = colors.HexColor("#0D1117")
|
| 17 |
+
PANEL_BG = colors.HexColor("#161B22")
|
| 18 |
+
BORDER_COLOR = colors.HexColor("#30363D")
|
| 19 |
+
ACCENT_CYAN = colors.HexColor("#22D3EE")
|
| 20 |
+
ACCENT_PINK = colors.HexColor("#EC4899")
|
| 21 |
+
TEXT_PRIMARY = colors.HexColor("#E6EDF3")
|
| 22 |
+
TEXT_MUTED = colors.HexColor("#8B949E")
|
| 23 |
+
TEXT_DIM = colors.HexColor("#484F58")
|
| 24 |
+
|
| 25 |
+
RISK_HIGH_BG = colors.HexColor("#3D1515")
|
| 26 |
+
RISK_HIGH_FG = colors.HexColor("#F87171")
|
| 27 |
+
RISK_HIGH_BAR = colors.HexColor("#EF4444")
|
| 28 |
+
|
| 29 |
+
RISK_MED_BG = colors.HexColor("#2D2008")
|
| 30 |
+
RISK_MED_FG = colors.HexColor("#FBBF24")
|
| 31 |
+
RISK_MED_BAR = colors.HexColor("#F59E0B")
|
| 32 |
+
|
| 33 |
+
RISK_LOW_BG = colors.HexColor("#0D2818")
|
| 34 |
+
RISK_LOW_FG = colors.HexColor("#4ADE80")
|
| 35 |
+
RISK_LOW_BAR = colors.HexColor("#22C55E")
|
| 36 |
+
|
| 37 |
+
WHITE = colors.white
|
| 38 |
+
BLACK = colors.black
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
# ── Page canvas callback (header + footer on every page) ─────────────────────
|
| 42 |
+
def _draw_page(canvas_obj, doc, report_id, timestamp):
|
| 43 |
+
W, H = A4
|
| 44 |
+
canvas_obj.saveState()
|
| 45 |
+
|
| 46 |
+
# ── Top header bar ────────────────────────────────────────────────────────
|
| 47 |
+
canvas_obj.setFillColor(DARK_BG)
|
| 48 |
+
canvas_obj.rect(0, H - 58, W, 58, fill=1, stroke=0)
|
| 49 |
+
|
| 50 |
+
# Thin cyan accent line under header
|
| 51 |
+
canvas_obj.setFillColor(ACCENT_CYAN)
|
| 52 |
+
canvas_obj.rect(0, H - 60, W, 2, fill=1, stroke=0)
|
| 53 |
+
|
| 54 |
+
# Logo / product name
|
| 55 |
+
canvas_obj.setFillColor(WHITE)
|
| 56 |
+
canvas_obj.setFont("Helvetica-Bold", 16)
|
| 57 |
+
canvas_obj.drawString(40, H - 36, "SENTINEL")
|
| 58 |
+
canvas_obj.setFillColor(ACCENT_CYAN)
|
| 59 |
+
canvas_obj.setFont("Helvetica-Bold", 16)
|
| 60 |
+
canvas_obj.drawString(40 + canvas_obj.stringWidth("SENTINEL", "Helvetica-Bold", 16) + 3, H - 36, "AI")
|
| 61 |
+
|
| 62 |
+
# Sub-label
|
| 63 |
+
canvas_obj.setFillColor(TEXT_MUTED)
|
| 64 |
+
canvas_obj.setFont("Helvetica", 7)
|
| 65 |
+
canvas_obj.drawString(40, H - 48, "STRUCTURED INTELLIGENCE BRIEF · THREAT ANALYSIS DIVISION")
|
| 66 |
+
|
| 67 |
+
# Report ID (right side)
|
| 68 |
+
canvas_obj.setFillColor(TEXT_MUTED)
|
| 69 |
+
canvas_obj.setFont("Helvetica", 7)
|
| 70 |
+
id_text = f"REPORT ID: {report_id}"
|
| 71 |
+
canvas_obj.drawRightString(W - 40, H - 32, id_text)
|
| 72 |
+
canvas_obj.drawRightString(W - 40, H - 44, f"GENERATED: {timestamp}")
|
| 73 |
+
|
| 74 |
+
# ── Bottom footer bar ─────────────────────────────────────────────────────
|
| 75 |
+
canvas_obj.setFillColor(DARK_BG)
|
| 76 |
+
canvas_obj.rect(0, 0, W, 36, fill=1, stroke=0)
|
| 77 |
+
|
| 78 |
+
# Thin line above footer
|
| 79 |
+
canvas_obj.setFillColor(BORDER_COLOR)
|
| 80 |
+
canvas_obj.rect(0, 36, W, 1, fill=1, stroke=0)
|
| 81 |
+
|
| 82 |
+
canvas_obj.setFillColor(TEXT_DIM)
|
| 83 |
+
canvas_obj.setFont("Helvetica", 7)
|
| 84 |
+
canvas_obj.drawString(40, 14, "CONFIDENTIAL — FOR AUTHORIZED USE ONLY · SentinelAI © 2026")
|
| 85 |
+
canvas_obj.drawRightString(W - 40, 14, f"Page {doc.page}")
|
| 86 |
+
|
| 87 |
+
canvas_obj.restoreState()
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
# ── Style factory ─────────────────────────────────────────────────────────────
|
| 91 |
+
def _styles():
|
| 92 |
+
base = getSampleStyleSheet()
|
| 93 |
+
|
| 94 |
+
def ps(name, **kw):
|
| 95 |
+
defaults = dict(fontName="Helvetica", fontSize=9, leading=13,
|
| 96 |
+
textColor=TEXT_PRIMARY, spaceAfter=0, spaceBefore=0)
|
| 97 |
+
defaults.update(kw)
|
| 98 |
+
return ParagraphStyle(name, **defaults)
|
| 99 |
+
|
| 100 |
+
return {
|
| 101 |
+
"section_label": ps("sl",
|
| 102 |
+
fontName="Helvetica-Bold", fontSize=7, textColor=ACCENT_CYAN,
|
| 103 |
+
spaceBefore=18, spaceAfter=4, leading=9,
|
| 104 |
+
),
|
| 105 |
+
"section_rule": ps("sr"), # placeholder, we use HRFlowable
|
| 106 |
+
"body": ps("body", fontSize=9, leading=14, textColor=TEXT_PRIMARY),
|
| 107 |
+
"body_muted": ps("bm", fontSize=8, leading=12, textColor=TEXT_MUTED),
|
| 108 |
+
"risk_score": ps("rs",
|
| 109 |
+
fontName="Helvetica-Bold", fontSize=36, leading=40,
|
| 110 |
+
textColor=WHITE, alignment=TA_CENTER,
|
| 111 |
+
),
|
| 112 |
+
"risk_label": ps("rl",
|
| 113 |
+
fontName="Helvetica-Bold", fontSize=11, leading=14,
|
| 114 |
+
textColor=WHITE, alignment=TA_CENTER,
|
| 115 |
+
),
|
| 116 |
+
"risk_sublabel": ps("rsl",
|
| 117 |
+
fontSize=7, leading=10, textColor=TEXT_MUTED, alignment=TA_CENTER,
|
| 118 |
+
),
|
| 119 |
+
"signal_item": ps("si", fontSize=8, leading=13, textColor=TEXT_PRIMARY,
|
| 120 |
+
leftIndent=10),
|
| 121 |
+
"disclaimer": ps("disc", fontSize=7, leading=10, textColor=TEXT_DIM,
|
| 122 |
+
alignment=TA_CENTER),
|
| 123 |
+
"meta_key": ps("mk", fontName="Helvetica-Bold", fontSize=7,
|
| 124 |
+
textColor=TEXT_MUTED, leading=11),
|
| 125 |
+
"meta_val": ps("mv", fontSize=8, textColor=TEXT_PRIMARY, leading=11),
|
| 126 |
+
"table_header": ps("th", fontName="Helvetica-Bold", fontSize=7,
|
| 127 |
+
textColor=ACCENT_CYAN, leading=10),
|
| 128 |
+
"table_cell": ps("tc", fontSize=8, textColor=TEXT_PRIMARY, leading=11),
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
# ── Section header helper ─────────────────────────────────────────────────────
|
| 133 |
+
def _section(title, styles, elements):
|
| 134 |
+
elements.append(Spacer(1, 4))
|
| 135 |
+
elements.append(Paragraph(f"▸ {title.upper()}", styles["section_label"]))
|
| 136 |
+
elements.append(HRFlowable(
|
| 137 |
+
width="100%", thickness=0.5,
|
| 138 |
+
color=BORDER_COLOR, spaceAfter=8, spaceBefore=2
|
| 139 |
+
))
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
# ── Dark panel table helper ───────────────────────────────────────────────────
|
| 143 |
+
def _panel(inner_elements, bg=None, border=BORDER_COLOR, padding=10):
|
| 144 |
+
"""Wraps content in a dark-background rounded-ish table cell."""
|
| 145 |
+
bg = bg or PANEL_BG
|
| 146 |
+
t = Table([[inner_elements]], colWidths=["100%"])
|
| 147 |
+
t.setStyle(TableStyle([
|
| 148 |
+
("BACKGROUND", (0, 0), (-1, -1), bg),
|
| 149 |
+
("BOX", (0, 0), (-1, -1), 0.5, border),
|
| 150 |
+
("TOPPADDING", (0, 0), (-1, -1), padding),
|
| 151 |
+
("BOTTOMPADDING",(0,0), (-1, -1), padding),
|
| 152 |
+
("LEFTPADDING", (0, 0), (-1, -1), padding),
|
| 153 |
+
("RIGHTPADDING",(0, 0), (-1, -1), padding),
|
| 154 |
+
]))
|
| 155 |
+
return t
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
# ── Main generator ────────────────────────────────────────────────────────────
|
| 159 |
+
def generate_pdf(data, filepath):
|
| 160 |
+
W, H = A4
|
| 161 |
+
timestamp = datetime.now().strftime("%d %B %Y %H:%M UTC+5:30")
|
| 162 |
+
report_id = datetime.now().strftime("SIB-%Y%m%d-%H%M%S")
|
| 163 |
+
|
| 164 |
+
# Margins: leave room for header (58+2=60) and footer (36+1=37)
|
| 165 |
+
doc = SimpleDocTemplate(
|
| 166 |
+
filepath,
|
| 167 |
+
pagesize=A4,
|
| 168 |
+
leftMargin=40,
|
| 169 |
+
rightMargin=40,
|
| 170 |
+
topMargin=75,
|
| 171 |
+
bottomMargin=50,
|
| 172 |
+
)
|
| 173 |
+
|
| 174 |
+
styles = _styles()
|
| 175 |
+
elements = []
|
| 176 |
+
|
| 177 |
+
# ── 1. CLASSIFICATION BANNER ──────────────────────────────────────────────
|
| 178 |
+
banner = Table(
|
| 179 |
+
[[ Paragraph("⬛ THREAT INTELLIGENCE REPORT — CONFIDENTIAL", ParagraphStyle(
|
| 180 |
+
"banner", fontName="Helvetica-Bold", fontSize=8,
|
| 181 |
+
textColor=ACCENT_CYAN, alignment=TA_CENTER, leading=10
|
| 182 |
+
)) ]],
|
| 183 |
+
colWidths=[W - 80]
|
| 184 |
+
)
|
| 185 |
+
banner.setStyle(TableStyle([
|
| 186 |
+
("BACKGROUND", (0,0),(-1,-1), DARK_BG),
|
| 187 |
+
("BOX", (0,0),(-1,-1), 1, ACCENT_CYAN),
|
| 188 |
+
("TOPPADDING", (0,0),(-1,-1), 6),
|
| 189 |
+
("BOTTOMPADDING",(0,0),(-1,-1), 6),
|
| 190 |
+
]))
|
| 191 |
+
elements.append(banner)
|
| 192 |
+
elements.append(Spacer(1, 14))
|
| 193 |
+
|
| 194 |
+
# ── 2. METADATA ROW ───────────────────────────────────────────────────────
|
| 195 |
+
meta_rows = [
|
| 196 |
+
[
|
| 197 |
+
Paragraph("CLASSIFICATION", styles["meta_key"]),
|
| 198 |
+
Paragraph("REPORT TYPE", styles["meta_key"]),
|
| 199 |
+
Paragraph("ANALYSIS ENGINE", styles["meta_key"]),
|
| 200 |
+
Paragraph("TIMESTAMP", styles["meta_key"]),
|
| 201 |
+
],
|
| 202 |
+
[
|
| 203 |
+
Paragraph("RESTRICTED", styles["meta_val"]),
|
| 204 |
+
Paragraph("Digital Threat Assessment", styles["meta_val"]),
|
| 205 |
+
Paragraph("SentinelAI v2 · Transformer NLP", styles["meta_val"]),
|
| 206 |
+
Paragraph(timestamp, styles["meta_val"]),
|
| 207 |
+
],
|
| 208 |
+
]
|
| 209 |
+
meta_table = Table(meta_rows, colWidths=[(W - 80) / 4] * 4)
|
| 210 |
+
meta_table.setStyle(TableStyle([
|
| 211 |
+
("BACKGROUND", (0,0),(-1,-1), PANEL_BG),
|
| 212 |
+
("BOX", (0,0),(-1,-1), 0.5, BORDER_COLOR),
|
| 213 |
+
("LINEBELOW", (0,0),(-1,0), 0.5, BORDER_COLOR),
|
| 214 |
+
("LINEBEFORE", (1,0),(3,-1), 0.5, BORDER_COLOR),
|
| 215 |
+
("TOPPADDING", (0,0),(-1,-1), 7),
|
| 216 |
+
("BOTTOMPADDING",(0,0),(-1,-1), 7),
|
| 217 |
+
("LEFTPADDING", (0,0),(-1,-1), 10),
|
| 218 |
+
("RIGHTPADDING", (0,0),(-1,-1), 10),
|
| 219 |
+
]))
|
| 220 |
+
elements.append(meta_table)
|
| 221 |
+
elements.append(Spacer(1, 18))
|
| 222 |
+
|
| 223 |
+
# ── 3. RISK ASSESSMENT PANEL ──────────────────────────────────────────────
|
| 224 |
+
risk_level = data.get("risk_level", "LOW")
|
| 225 |
+
risk_score = float(data.get("risk_score", 0))
|
| 226 |
+
|
| 227 |
+
if risk_level == "HIGH":
|
| 228 |
+
risk_bg, risk_fg, risk_bar = RISK_HIGH_BG, RISK_HIGH_FG, RISK_HIGH_BAR
|
| 229 |
+
risk_label_text = "HIGH RISK — SCAM DETECTED"
|
| 230 |
+
risk_desc = "This message exhibits strong indicators of a digital arrest or financial scam. Immediate action is advised."
|
| 231 |
+
threat_icon = "🔴"
|
| 232 |
+
elif risk_level == "MEDIUM":
|
| 233 |
+
risk_bg, risk_fg, risk_bar = RISK_MED_BG, RISK_MED_FG, RISK_MED_BAR
|
| 234 |
+
risk_label_text = "MEDIUM RISK — SUSPICIOUS"
|
| 235 |
+
risk_desc = "This message contains several suspicious patterns. Exercise caution and verify through official channels."
|
| 236 |
+
threat_icon = "🟡"
|
| 237 |
+
else:
|
| 238 |
+
risk_bg, risk_fg, risk_bar = RISK_LOW_BG, RISK_LOW_FG, RISK_LOW_BAR
|
| 239 |
+
risk_label_text = "LOW RISK — LIKELY SAFE"
|
| 240 |
+
risk_desc = "No significant scam indicators detected. The message appears to be legitimate communication."
|
| 241 |
+
threat_icon = "🟢"
|
| 242 |
+
|
| 243 |
+
score_style = ParagraphStyle("score_dyn", fontName="Helvetica-Bold",
|
| 244 |
+
fontSize=42, leading=48, textColor=risk_fg,
|
| 245 |
+
alignment=TA_CENTER)
|
| 246 |
+
label_style = ParagraphStyle("label_dyn", fontName="Helvetica-Bold",
|
| 247 |
+
fontSize=10, leading=14, textColor=risk_fg,
|
| 248 |
+
alignment=TA_CENTER)
|
| 249 |
+
desc_style = ParagraphStyle("desc_dyn", fontSize=8, leading=12,
|
| 250 |
+
textColor=TEXT_MUTED, alignment=TA_CENTER)
|
| 251 |
+
|
| 252 |
+
# Progress bar simulation via a two-cell table
|
| 253 |
+
bar_filled = max(2, int((risk_score / 100) * 100))
|
| 254 |
+
bar_empty = 100 - bar_filled
|
| 255 |
+
bar_table = Table(
|
| 256 |
+
[["", ""]],
|
| 257 |
+
colWidths=[((W - 80 - 40) * bar_filled / 100),
|
| 258 |
+
((W - 80 - 40) * bar_empty / 100)]
|
| 259 |
+
)
|
| 260 |
+
bar_table.setStyle(TableStyle([
|
| 261 |
+
("BACKGROUND", (0,0),(0,0), risk_bar),
|
| 262 |
+
("BACKGROUND", (1,0),(1,0), BORDER_COLOR),
|
| 263 |
+
("TOPPADDING", (0,0),(-1,-1), 3),
|
| 264 |
+
("BOTTOMPADDING",(0,0),(-1,-1), 3),
|
| 265 |
+
("LEFTPADDING", (0,0),(-1,-1), 0),
|
| 266 |
+
("RIGHTPADDING", (0,0),(-1,-1), 0),
|
| 267 |
+
]))
|
| 268 |
+
|
| 269 |
+
risk_inner = [
|
| 270 |
+
Paragraph(f"{threat_icon} {risk_label_text}", label_style),
|
| 271 |
+
Spacer(1, 6),
|
| 272 |
+
Paragraph(f"{risk_score:.1f}%", score_style),
|
| 273 |
+
Spacer(1, 8),
|
| 274 |
+
bar_table,
|
| 275 |
+
Spacer(1, 8),
|
| 276 |
+
Paragraph(risk_desc, desc_style),
|
| 277 |
+
]
|
| 278 |
+
|
| 279 |
+
risk_panel = Table([[risk_inner]], colWidths=[W - 80])
|
| 280 |
+
risk_panel.setStyle(TableStyle([
|
| 281 |
+
("BACKGROUND", (0,0),(-1,-1), risk_bg),
|
| 282 |
+
("BOX", (0,0),(-1,-1), 1, risk_bar),
|
| 283 |
+
("TOPPADDING", (0,0),(-1,-1), 16),
|
| 284 |
+
("BOTTOMPADDING",(0,0),(-1,-1), 16),
|
| 285 |
+
("LEFTPADDING", (0,0),(-1,-1), 20),
|
| 286 |
+
("RIGHTPADDING", (0,0),(-1,-1), 20),
|
| 287 |
+
]))
|
| 288 |
+
elements.append(risk_panel)
|
| 289 |
+
elements.append(Spacer(1, 18))
|
| 290 |
+
|
| 291 |
+
# ── 4. ANALYZED MESSAGE ───────────────────────────────────────────────────
|
| 292 |
+
_section("01 · Intercepted Communication", styles, elements)
|
| 293 |
+
|
| 294 |
+
msg_text = data.get("message", "No message provided.")
|
| 295 |
+
msg_para = Paragraph(msg_text, ParagraphStyle(
|
| 296 |
+
"msg", fontSize=9, leading=15, textColor=TEXT_PRIMARY,
|
| 297 |
+
fontName="Courier", leftIndent=0
|
| 298 |
+
))
|
| 299 |
+
msg_panel = Table([[msg_para]], colWidths=[W - 80])
|
| 300 |
+
msg_panel.setStyle(TableStyle([
|
| 301 |
+
("BACKGROUND", (0,0),(-1,-1), PANEL_BG),
|
| 302 |
+
("LINEAFTER", (0,0),(0,-1), 3, ACCENT_CYAN), # left accent bar
|
| 303 |
+
("BOX", (0,0),(-1,-1), 0.5, BORDER_COLOR),
|
| 304 |
+
("TOPPADDING", (0,0),(-1,-1), 12),
|
| 305 |
+
("BOTTOMPADDING",(0,0),(-1,-1), 12),
|
| 306 |
+
("LEFTPADDING", (0,0),(-1,-1), 14),
|
| 307 |
+
("RIGHTPADDING", (0,0),(-1,-1), 14),
|
| 308 |
+
]))
|
| 309 |
+
elements.append(msg_panel)
|
| 310 |
+
elements.append(Spacer(1, 14))
|
| 311 |
+
|
| 312 |
+
# ── 5. DETECTED SIGNALS ───────────────────────────────────────────────────
|
| 313 |
+
_section("02 · Detected Risk Indicators", styles, elements)
|
| 314 |
+
|
| 315 |
+
signals = data.get("signals", [])
|
| 316 |
+
if signals:
|
| 317 |
+
signal_rows = []
|
| 318 |
+
for i, sig in enumerate(signals, 1):
|
| 319 |
+
signal_rows.append([
|
| 320 |
+
Paragraph(f"{i:02d}", ParagraphStyle(
|
| 321 |
+
"snum", fontName="Helvetica-Bold", fontSize=8,
|
| 322 |
+
textColor=ACCENT_CYAN, alignment=TA_CENTER, leading=12
|
| 323 |
+
)),
|
| 324 |
+
Paragraph(f"⚠ {sig}", ParagraphStyle(
|
| 325 |
+
"stxt", fontSize=8, leading=13, textColor=TEXT_PRIMARY
|
| 326 |
+
)),
|
| 327 |
+
])
|
| 328 |
+
|
| 329 |
+
sig_table = Table(signal_rows, colWidths=[30, W - 80 - 30])
|
| 330 |
+
sig_table.setStyle(TableStyle([
|
| 331 |
+
("BACKGROUND", (0,0),(-1,-1), PANEL_BG),
|
| 332 |
+
("BOX", (0,0),(-1,-1), 0.5, BORDER_COLOR),
|
| 333 |
+
("LINEBELOW", (0,0),(-1,-2), 0.3, BORDER_COLOR),
|
| 334 |
+
("VALIGN", (0,0),(-1,-1), "MIDDLE"),
|
| 335 |
+
("TOPPADDING", (0,0),(-1,-1), 7),
|
| 336 |
+
("BOTTOMPADDING",(0,0),(-1,-1), 7),
|
| 337 |
+
("LEFTPADDING", (0,0),(-1,-1), 10),
|
| 338 |
+
("RIGHTPADDING", (0,0),(-1,-1), 10),
|
| 339 |
+
("BACKGROUND", (0,0),(0,-1), colors.HexColor("#0D1F2D")),
|
| 340 |
+
]))
|
| 341 |
+
elements.append(sig_table)
|
| 342 |
+
else:
|
| 343 |
+
no_sig = Table(
|
| 344 |
+
[[Paragraph("✔ No high-confidence scam indicators detected in this message.", ParagraphStyle(
|
| 345 |
+
"nosig", fontSize=8, leading=12, textColor=RISK_LOW_FG
|
| 346 |
+
))]],
|
| 347 |
+
colWidths=[W - 80]
|
| 348 |
+
)
|
| 349 |
+
no_sig.setStyle(TableStyle([
|
| 350 |
+
("BACKGROUND", (0,0),(-1,-1), RISK_LOW_BG),
|
| 351 |
+
("BOX", (0,0),(-1,-1), 0.5, RISK_LOW_BAR),
|
| 352 |
+
("TOPPADDING", (0,0),(-1,-1), 10),
|
| 353 |
+
("BOTTOMPADDING",(0,0),(-1,-1), 10),
|
| 354 |
+
("LEFTPADDING", (0,0),(-1,-1), 14),
|
| 355 |
+
("RIGHTPADDING", (0,0),(-1,-1), 14),
|
| 356 |
+
]))
|
| 357 |
+
elements.append(no_sig)
|
| 358 |
+
|
| 359 |
+
elements.append(Spacer(1, 14))
|
| 360 |
+
|
| 361 |
+
# ── 6. AI INTERPRETATION ──────────────────────────────────────────────────
|
| 362 |
+
_section("03 · AI Model Interpretation", styles, elements)
|
| 363 |
+
|
| 364 |
+
interp_rows = [
|
| 365 |
+
["Model Architecture", "Fine-tuned Transformer (BERT-class) · Digital Scam Corpus"],
|
| 366 |
+
["Detection Method", "Linguistic pattern matching + structural scam framework analysis"],
|
| 367 |
+
["Confidence Basis", f"{max(risk_score, 100 - risk_score):.1f}% model confidence on primary classification"],
|
| 368 |
+
["Threat Category", "Digital Arrest Scam / Impersonation / Financial Coercion"],
|
| 369 |
+
]
|
| 370 |
+
interp_table = Table(
|
| 371 |
+
[[Paragraph(k, styles["meta_key"]), Paragraph(v, styles["table_cell"])]
|
| 372 |
+
for k, v in interp_rows],
|
| 373 |
+
colWidths=[130, W - 80 - 130]
|
| 374 |
+
)
|
| 375 |
+
interp_table.setStyle(TableStyle([
|
| 376 |
+
("BACKGROUND", (0,0),(-1,-1), PANEL_BG),
|
| 377 |
+
("BACKGROUND", (0,0),(0,-1), colors.HexColor("#0D1F2D")),
|
| 378 |
+
("BOX", (0,0),(-1,-1), 0.5, BORDER_COLOR),
|
| 379 |
+
("LINEBELOW", (0,0),(-1,-2), 0.3, BORDER_COLOR),
|
| 380 |
+
("LINEAFTER", (0,0),(0,-1), 0.5, BORDER_COLOR),
|
| 381 |
+
("VALIGN", (0,0),(-1,-1), "MIDDLE"),
|
| 382 |
+
("TOPPADDING", (0,0),(-1,-1), 7),
|
| 383 |
+
("BOTTOMPADDING",(0,0),(-1,-1), 7),
|
| 384 |
+
("LEFTPADDING", (0,0),(-1,-1), 10),
|
| 385 |
+
("RIGHTPADDING", (0,0),(-1,-1), 10),
|
| 386 |
+
]))
|
| 387 |
+
elements.append(interp_table)
|
| 388 |
+
elements.append(Spacer(1, 14))
|
| 389 |
+
|
| 390 |
+
# ── 7. RECOMMENDED ACTIONS ────────────────────────────────────────────────
|
| 391 |
+
_section("04 · Recommended Immediate Actions", styles, elements)
|
| 392 |
+
|
| 393 |
+
actions = [
|
| 394 |
+
("CRITICAL", "Do NOT transfer funds, share OTP, passwords, or any personal credentials."),
|
| 395 |
+
("CRITICAL", "Disconnect immediately from the suspicious communication channel."),
|
| 396 |
+
("HIGH", "Verify the sender's identity through official government or bank websites only."),
|
| 397 |
+
("HIGH", "Report the incident to the National Cybercrime Portal: cybercrime.gov.in"),
|
| 398 |
+
("MEDIUM", "Preserve all evidence — screenshots, call logs, and message history."),
|
| 399 |
+
("MEDIUM", "Alert family members and close contacts about this threat pattern."),
|
| 400 |
+
]
|
| 401 |
+
|
| 402 |
+
priority_colors = {
|
| 403 |
+
"CRITICAL": (colors.HexColor("#3D1515"), RISK_HIGH_FG),
|
| 404 |
+
"HIGH": (colors.HexColor("#2D2008"), RISK_MED_FG),
|
| 405 |
+
"MEDIUM": (colors.HexColor("#0D2818"), RISK_LOW_FG),
|
| 406 |
+
}
|
| 407 |
+
|
| 408 |
+
action_data = []
|
| 409 |
+
for priority, text in actions:
|
| 410 |
+
bg, fg = priority_colors[priority]
|
| 411 |
+
action_data.append([
|
| 412 |
+
Paragraph(priority, ParagraphStyle(
|
| 413 |
+
"pri", fontName="Helvetica-Bold", fontSize=6,
|
| 414 |
+
textColor=fg, alignment=TA_CENTER, leading=9
|
| 415 |
+
)),
|
| 416 |
+
Paragraph(text, ParagraphStyle(
|
| 417 |
+
"act", fontSize=8, leading=13, textColor=TEXT_PRIMARY
|
| 418 |
+
)),
|
| 419 |
+
])
|
| 420 |
+
|
| 421 |
+
action_table = Table(action_data, colWidths=[55, W - 80 - 55])
|
| 422 |
+
action_table.setStyle(TableStyle([
|
| 423 |
+
("BACKGROUND", (0,0),(-1,-1), PANEL_BG),
|
| 424 |
+
("BOX", (0,0),(-1,-1), 0.5, BORDER_COLOR),
|
| 425 |
+
("LINEBELOW", (0,0),(-1,-2), 0.3, BORDER_COLOR),
|
| 426 |
+
("LINEAFTER", (0,0),(0,-1), 0.5, BORDER_COLOR),
|
| 427 |
+
("VALIGN", (0,0),(-1,-1), "MIDDLE"),
|
| 428 |
+
("TOPPADDING", (0,0),(-1,-1), 7),
|
| 429 |
+
("BOTTOMPADDING",(0,0),(-1,-1), 7),
|
| 430 |
+
("LEFTPADDING", (0,0),(-1,-1), 8),
|
| 431 |
+
("RIGHTPADDING", (0,0),(-1,-1), 8),
|
| 432 |
+
# Per-row background for priority column
|
| 433 |
+
("BACKGROUND", (0,0),(0,1), colors.HexColor("#3D1515")),
|
| 434 |
+
("BACKGROUND", (0,2),(0,3), colors.HexColor("#2D2008")),
|
| 435 |
+
("BACKGROUND", (0,4),(0,5), colors.HexColor("#0D2818")),
|
| 436 |
+
]))
|
| 437 |
+
elements.append(action_table)
|
| 438 |
+
elements.append(Spacer(1, 20))
|
| 439 |
+
|
| 440 |
+
# ── 8. DISCLAIMER ─────────────────────────────────────────────────────────
|
| 441 |
+
disc_table = Table(
|
| 442 |
+
[[Paragraph(
|
| 443 |
+
"DISCLAIMER · SentinelAI provides AI-based probabilistic risk estimation and does not constitute "
|
| 444 |
+
"legal advice. All findings are based on pattern recognition and should be verified through official "
|
| 445 |
+
"law enforcement or financial authorities. This report is generated for informational purposes only.",
|
| 446 |
+
styles["disclaimer"]
|
| 447 |
+
)]],
|
| 448 |
+
colWidths=[W - 80]
|
| 449 |
+
)
|
| 450 |
+
disc_table.setStyle(TableStyle([
|
| 451 |
+
("BACKGROUND", (0,0),(-1,-1), DARK_BG),
|
| 452 |
+
("BOX", (0,0),(-1,-1), 0.5, BORDER_COLOR),
|
| 453 |
+
("TOPPADDING", (0,0),(-1,-1), 10),
|
| 454 |
+
("BOTTOMPADDING",(0,0),(-1,-1), 10),
|
| 455 |
+
("LEFTPADDING", (0,0),(-1,-1), 14),
|
| 456 |
+
("RIGHTPADDING", (0,0),(-1,-1), 14),
|
| 457 |
+
]))
|
| 458 |
+
elements.append(disc_table)
|
| 459 |
+
|
| 460 |
+
# ── Build ─────────────────────────────────────────────────────────────────
|
| 461 |
+
doc.build(
|
| 462 |
+
elements,
|
| 463 |
+
onFirstPage=lambda c, d: _draw_page(c, d, report_id, timestamp),
|
| 464 |
+
onLaterPages=lambda c, d: _draw_page(c, d, report_id, timestamp),
|
| 465 |
+
)
|
app/templates/index.html
ADDED
|
@@ -0,0 +1,1060 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html class="dark" lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8"/>
|
| 5 |
+
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
| 6 |
+
<title>SENTINELAI — Threat Analysis Engine</title>
|
| 7 |
+
<meta name="description" content="SentinelAI: AI-powered threat analysis engine for detecting digital arrest scams, phishing, and social engineering attacks."/>
|
| 8 |
+
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
| 9 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@100;300;400;500;600;700;800&family=JetBrains+Mono:wght@300;400;500;700&display=swap" rel="stylesheet"/>
|
| 10 |
+
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
| 11 |
+
<script id="tailwind-config">
|
| 12 |
+
tailwind.config = {
|
| 13 |
+
darkMode: "class",
|
| 14 |
+
theme: {
|
| 15 |
+
extend: {
|
| 16 |
+
colors: {
|
| 17 |
+
"background": "#11131e",
|
| 18 |
+
"inverse-primary": "#5646d7",
|
| 19 |
+
"tertiary": "#ffb3b4",
|
| 20 |
+
"on-error-container": "#ffdad6",
|
| 21 |
+
"secondary-fixed-dim": "#00e478",
|
| 22 |
+
"on-surface": "#e1e1f2",
|
| 23 |
+
"secondary-container": "#00e779",
|
| 24 |
+
"inverse-surface": "#e1e1f2",
|
| 25 |
+
"primary": "#c5c0ff",
|
| 26 |
+
"surface-tint": "#c5c0ff",
|
| 27 |
+
"primary-fixed-dim": "#c5c0ff",
|
| 28 |
+
"tertiary-fixed-dim": "#ffb3b4",
|
| 29 |
+
"surface-variant": "#323440",
|
| 30 |
+
"on-error": "#690005",
|
| 31 |
+
"surface-container-lowest": "#0c0e18",
|
| 32 |
+
"secondary": "#7cffa3",
|
| 33 |
+
"on-tertiary-container": "#5b0012",
|
| 34 |
+
"outline": "#928fa0",
|
| 35 |
+
"on-tertiary-fixed": "#40000a",
|
| 36 |
+
"on-background": "#e1e1f2",
|
| 37 |
+
"on-primary-fixed-variant": "#3d28bf",
|
| 38 |
+
"secondary-fixed": "#61ff98",
|
| 39 |
+
"tertiary-fixed": "#ffdad9",
|
| 40 |
+
"on-secondary-fixed-variant": "#005227",
|
| 41 |
+
"primary-fixed": "#e4dfff",
|
| 42 |
+
"surface": "#11131e",
|
| 43 |
+
"surface-container-high": "#272935",
|
| 44 |
+
"surface-container": "#1d1f2b",
|
| 45 |
+
"on-primary-fixed": "#150067",
|
| 46 |
+
"primary-container": "#8b80ff",
|
| 47 |
+
"on-primary": "#2600a1",
|
| 48 |
+
"on-tertiary": "#680016",
|
| 49 |
+
"error": "#ffb4ab",
|
| 50 |
+
"on-surface-variant": "#c8c4d7",
|
| 51 |
+
"outline-variant": "#474554",
|
| 52 |
+
"surface-container-highest": "#323440",
|
| 53 |
+
"surface-container-low": "#191b26",
|
| 54 |
+
"on-tertiary-fixed-variant": "#920023",
|
| 55 |
+
"surface-dim": "#11131e",
|
| 56 |
+
"tertiary-container": "#ff5262",
|
| 57 |
+
"on-secondary-container": "#006230",
|
| 58 |
+
"inverse-on-surface": "#2e303c",
|
| 59 |
+
"on-secondary": "#003919",
|
| 60 |
+
"on-primary-container": "#20008e",
|
| 61 |
+
"on-secondary-fixed": "#00210c",
|
| 62 |
+
"error-container": "#93000a",
|
| 63 |
+
"surface-bright": "#373845"
|
| 64 |
+
},
|
| 65 |
+
fontFamily: {
|
| 66 |
+
"headline": ["Inter"],
|
| 67 |
+
"body": ["Inter"],
|
| 68 |
+
"label": ["Inter"],
|
| 69 |
+
"mono": ["JetBrains Mono"]
|
| 70 |
+
},
|
| 71 |
+
borderRadius: {"DEFAULT": "0.25rem", "lg": "0.5rem", "xl": "0.75rem", "full": "9999px"},
|
| 72 |
+
},
|
| 73 |
+
},
|
| 74 |
+
}
|
| 75 |
+
</script>
|
| 76 |
+
<style>
|
| 77 |
+
body {
|
| 78 |
+
background-color: #0D0F1A;
|
| 79 |
+
background-image: radial-gradient(circle at center, #0D0F1A 0%, #07080F 100%),
|
| 80 |
+
radial-gradient(rgba(255, 255, 255, 0.03) 1px, transparent 1px);
|
| 81 |
+
background-size: 100% 100%, 24px 24px;
|
| 82 |
+
color: #E1E1F2;
|
| 83 |
+
}
|
| 84 |
+
.dot-grid {
|
| 85 |
+
background-image: radial-gradient(rgba(255, 255, 255, 0.03) 1px, transparent 0);
|
| 86 |
+
background-size: 24px 24px;
|
| 87 |
+
}
|
| 88 |
+
.material-symbols-outlined {
|
| 89 |
+
font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 24;
|
| 90 |
+
}
|
| 91 |
+
.glass-panel {
|
| 92 |
+
background: rgba(29, 31, 43, 0.8);
|
| 93 |
+
backdrop-filter: blur(12px);
|
| 94 |
+
}
|
| 95 |
+
.glow-red {
|
| 96 |
+
box-shadow: 0 0 60px rgba(255, 82, 98, 0.12);
|
| 97 |
+
}
|
| 98 |
+
.safe-glow {
|
| 99 |
+
box-shadow: 0 0 60px rgba(0, 231, 121, 0.08);
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
/* Smooth view transitions */
|
| 103 |
+
.view-transition {
|
| 104 |
+
transition: opacity 0.4s ease, transform 0.4s ease;
|
| 105 |
+
}
|
| 106 |
+
.view-hidden {
|
| 107 |
+
opacity: 0;
|
| 108 |
+
transform: translateY(12px);
|
| 109 |
+
pointer-events: none;
|
| 110 |
+
position: absolute;
|
| 111 |
+
top: 0; left: 0; right: 0;
|
| 112 |
+
}
|
| 113 |
+
.view-visible {
|
| 114 |
+
opacity: 1;
|
| 115 |
+
transform: translateY(0);
|
| 116 |
+
pointer-events: auto;
|
| 117 |
+
position: relative;
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
/* Processing pulse */
|
| 121 |
+
@keyframes pulse-ring {
|
| 122 |
+
0% { transform: scale(0.8); opacity: 0.6; }
|
| 123 |
+
50% { transform: scale(1.1); opacity: 0.2; }
|
| 124 |
+
100% { transform: scale(0.8); opacity: 0.6; }
|
| 125 |
+
}
|
| 126 |
+
.pulse-ring {
|
| 127 |
+
animation: pulse-ring 2s ease-in-out infinite;
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
/* Scanning line */
|
| 131 |
+
@keyframes scan-line {
|
| 132 |
+
0% { top: 0; opacity: 0; }
|
| 133 |
+
10% { opacity: 1; }
|
| 134 |
+
90% { opacity: 1; }
|
| 135 |
+
100% { top: 100%; opacity: 0; }
|
| 136 |
+
}
|
| 137 |
+
.scan-line {
|
| 138 |
+
animation: scan-line 2s ease-in-out infinite;
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
/* ── OCR Drop Zone ───────────────────────────────── */
|
| 142 |
+
#dropZone {
|
| 143 |
+
transition: border-color 0.25s ease, background-color 0.25s ease;
|
| 144 |
+
}
|
| 145 |
+
#dropZone.drag-over {
|
| 146 |
+
border-color: rgba(124, 111, 255, 0.5) !important;
|
| 147 |
+
background-color: rgba(124, 111, 255, 0.04);
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
/* ── Mode toggle pill transitions ────────────────── */
|
| 151 |
+
.mode-pill {
|
| 152 |
+
transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease;
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
/* ── OCR preview scrollbar ───────────────────────── */
|
| 156 |
+
#ocrPreviewText::-webkit-scrollbar { width: 3px; }
|
| 157 |
+
#ocrPreviewText::-webkit-scrollbar-track { background: transparent; }
|
| 158 |
+
#ocrPreviewText::-webkit-scrollbar-thumb { background: rgba(124,111,255,0.2); border-radius: 999px; }
|
| 159 |
+
</style>
|
| 160 |
+
</head>
|
| 161 |
+
<body class="font-body text-on-surface min-h-screen selection:bg-primary/30">
|
| 162 |
+
<!-- Background Layer -->
|
| 163 |
+
<div class="fixed inset-0 dot-grid pointer-events-none"></div>
|
| 164 |
+
|
| 165 |
+
<!-- ============================================================ -->
|
| 166 |
+
<!-- VIEW: IDLE STATE -->
|
| 167 |
+
<!-- ============================================================ -->
|
| 168 |
+
<div id="viewIdle" class="view-transition view-visible">
|
| 169 |
+
<main class="relative z-10 flex items-center justify-center min-h-screen p-6">
|
| 170 |
+
<div class="w-full max-w-[1100px] grid grid-cols-1 md:grid-cols-2 bg-surface-container-low/40 backdrop-blur-md rounded-xl overflow-hidden shadow-[0_20px_80px_rgba(0,0,0,0.45)] outline outline-1 outline-white/5">
|
| 171 |
+
|
| 172 |
+
<!-- Left Panel: Input -->
|
| 173 |
+
<section class="p-12 flex flex-col justify-between border-r border-outline-variant/10">
|
| 174 |
+
<div class="space-y-12">
|
| 175 |
+
<header>
|
| 176 |
+
<div class="flex items-baseline gap-2">
|
| 177 |
+
<h1 class="text-[22px] font-light tracking-[0.12em] text-white leading-none">SENTINEL<span class="text-[#7C6FFF] text-[11px] font-semibold tracking-normal align-top leading-none ml-1">AI</span></h1>
|
| 178 |
+
</div>
|
| 179 |
+
<p class="text-[9px] text-[#4E5A6E] tracking-[0.05em] font-mono mt-1 uppercase">THREAT ANALYSIS ENGINE</p>
|
| 180 |
+
</header>
|
| 181 |
+
|
| 182 |
+
<div class="space-y-4">
|
| 183 |
+
|
| 184 |
+
<!-- Label + Mode Toggle Row -->
|
| 185 |
+
<div class="flex items-center justify-between">
|
| 186 |
+
<label class="block text-[10px] text-[#4E5A6E] tracking-[0.15em] font-semibold uppercase">FORENSIC INPUT</label>
|
| 187 |
+
|
| 188 |
+
<!-- Mode Toggle Pills -->
|
| 189 |
+
<div class="flex items-center gap-0.5 p-0.5 bg-[#090C12] border border-white/5 rounded-lg">
|
| 190 |
+
<button
|
| 191 |
+
id="btnTextMode"
|
| 192 |
+
onclick="switchMode('text')"
|
| 193 |
+
class="mode-pill flex items-center gap-1.5 px-3 py-1.5 rounded-md
|
| 194 |
+
bg-[#7C6FFF]/15 text-[#7C6FFF] border border-[#7C6FFF]/20
|
| 195 |
+
text-[9px] font-mono tracking-widest uppercase font-semibold"
|
| 196 |
+
>
|
| 197 |
+
<span class="material-symbols-outlined text-[11px]">text_fields</span>
|
| 198 |
+
TEXT
|
| 199 |
+
</button>
|
| 200 |
+
<button
|
| 201 |
+
id="btnImageMode"
|
| 202 |
+
onclick="switchMode('image')"
|
| 203 |
+
class="mode-pill flex items-center gap-1.5 px-3 py-1.5 rounded-md
|
| 204 |
+
bg-transparent text-[#4E5A6E] border border-transparent
|
| 205 |
+
text-[9px] font-mono tracking-widest uppercase font-semibold
|
| 206 |
+
hover:text-[#7C6FFF]/60"
|
| 207 |
+
>
|
| 208 |
+
<span class="material-symbols-outlined text-[11px]">screenshot_monitor</span>
|
| 209 |
+
SCREENSHOT
|
| 210 |
+
</button>
|
| 211 |
+
</div>
|
| 212 |
+
</div>
|
| 213 |
+
|
| 214 |
+
<!-- ── TEXT ZONE ─────────────────────────────── -->
|
| 215 |
+
<div id="textZone" class="relative group">
|
| 216 |
+
<textarea id="messageInput"
|
| 217 |
+
class="w-full h-64 bg-[#090C12] border border-white/10 rounded-xl p-5 text-on-surface font-mono text-sm
|
| 218 |
+
focus:ring-1 focus:ring-primary/40 focus:border-primary/40 outline-none transition-all
|
| 219 |
+
placeholder:text-[#2E3A4E] resize-none"
|
| 220 |
+
placeholder="Paste any suspicious message, URL, or code snippet for immediate neural evaluation..."
|
| 221 |
+
></textarea>
|
| 222 |
+
<div class="absolute bottom-4 right-4 flex gap-2">
|
| 223 |
+
<span class="bg-surface-container text-[9px] font-mono px-2 py-1 rounded text-outline border border-outline-variant/20">UTF-8</span>
|
| 224 |
+
<span class="bg-surface-container text-[9px] font-mono px-2 py-1 rounded text-outline border border-outline-variant/20">RAW</span>
|
| 225 |
+
</div>
|
| 226 |
+
</div>
|
| 227 |
+
|
| 228 |
+
<!-- ── IMAGE ZONE ─────────────────────────────── -->
|
| 229 |
+
<div id="imageZone" class="space-y-3" style="display:none;">
|
| 230 |
+
|
| 231 |
+
<!-- Drop Zone — matches textarea dimensions and style -->
|
| 232 |
+
<div
|
| 233 |
+
id="dropZone"
|
| 234 |
+
class="relative w-full h-64 bg-[#090C12] border border-dashed border-white/10 rounded-xl
|
| 235 |
+
flex flex-col items-center justify-center gap-3 cursor-pointer overflow-hidden group"
|
| 236 |
+
onclick="document.getElementById('imgInput').click()"
|
| 237 |
+
ondragover="handleDragOver(event)"
|
| 238 |
+
ondragleave="handleDragLeave(event)"
|
| 239 |
+
ondrop="handleDrop(event)"
|
| 240 |
+
>
|
| 241 |
+
<!-- Idle placeholder -->
|
| 242 |
+
<div id="dropPlaceholder" class="flex flex-col items-center gap-3 pointer-events-none">
|
| 243 |
+
<span class="material-symbols-outlined text-[#2E3A4E] text-[42px] group-hover:text-[#7C6FFF]/30 transition-colors duration-300"
|
| 244 |
+
style="font-variation-settings:'FILL' 0,'wght' 200,'GRAD' 0,'opsz' 48;">screenshot_monitor</span>
|
| 245 |
+
<div class="text-center">
|
| 246 |
+
<p class="text-[11px] text-[#4E5A6E] tracking-[0.12em] font-semibold uppercase">Drop screenshot here</p>
|
| 247 |
+
<p class="text-[9px] text-[#2E3A4E] font-mono mt-1">or click to browse · PNG · JPG · WEBP · max 10 MB</p>
|
| 248 |
+
</div>
|
| 249 |
+
</div>
|
| 250 |
+
|
| 251 |
+
<!-- Image preview (hidden until file selected) -->
|
| 252 |
+
<img
|
| 253 |
+
id="imgPreview"
|
| 254 |
+
src="" alt="Screenshot preview"
|
| 255 |
+
class="hidden absolute inset-0 w-full h-full object-contain p-3"
|
| 256 |
+
/>
|
| 257 |
+
|
| 258 |
+
<!-- Overlay badge on top of preview -->
|
| 259 |
+
<div id="imgOverlay" class="hidden absolute bottom-0 inset-x-0 bg-gradient-to-t from-[#090C12]/90 to-transparent px-4 py-3 flex items-center gap-2">
|
| 260 |
+
<span class="material-symbols-outlined text-[#7C6FFF] text-[14px]">check_circle</span>
|
| 261 |
+
<p id="imgFilename" class="text-[9px] font-mono text-[#7C6FFF] truncate max-w-[200px]"></p>
|
| 262 |
+
<span class="text-[8px] text-[#4E5A6E] ml-auto">click to change</span>
|
| 263 |
+
</div>
|
| 264 |
+
|
| 265 |
+
<input type="file" id="imgInput" accept="image/*" class="hidden" onchange="handleFileSelect(event)"/>
|
| 266 |
+
</div>
|
| 267 |
+
|
| 268 |
+
<!-- OCR Extracted Text Preview — appears after analysis -->
|
| 269 |
+
<div id="ocrPreviewBox" class="hidden">
|
| 270 |
+
<div class="flex items-center gap-2 mb-1.5">
|
| 271 |
+
<span class="text-[8px] text-[#4E5A6E] tracking-[0.12em] font-mono uppercase">Extracted Text</span>
|
| 272 |
+
<div class="flex-1 h-[1px] bg-white/5"></div>
|
| 273 |
+
<span class="text-[8px] font-mono text-[#7C6FFF]/60">OCR OUTPUT</span>
|
| 274 |
+
</div>
|
| 275 |
+
<div
|
| 276 |
+
id="ocrPreviewText"
|
| 277 |
+
class="text-[10px] font-mono text-on-surface-variant bg-[#090C12] border border-white/5
|
| 278 |
+
rounded-lg p-3 max-h-16 overflow-y-auto whitespace-pre-wrap leading-relaxed"
|
| 279 |
+
></div>
|
| 280 |
+
</div>
|
| 281 |
+
|
| 282 |
+
</div>
|
| 283 |
+
<!-- ── END IMAGE ZONE ─────────────────────────── -->
|
| 284 |
+
|
| 285 |
+
<button id="analyzeBtn"
|
| 286 |
+
class="w-full py-4 px-6 bg-gradient-to-r from-[#7C6FFF] to-[#5B8DEF] text-on-primary
|
| 287 |
+
font-bold text-sm tracking-widest rounded-xl transition-all hover:scale-[1.01]
|
| 288 |
+
active:scale-95 shadow-[0_4px_20px_rgba(124,111,255,0.3)]">
|
| 289 |
+
RUN ANALYSIS
|
| 290 |
+
</button>
|
| 291 |
+
|
| 292 |
+
</div>
|
| 293 |
+
</div>
|
| 294 |
+
|
| 295 |
+
<footer class="mt-12">
|
| 296 |
+
<div class="flex items-center gap-2">
|
| 297 |
+
<span id="statusDot" class="w-2 h-2 rounded-full bg-secondary shadow-[0_0_10px_rgba(124,255,163,0.3)]"></span>
|
| 298 |
+
<p class="text-[10px] text-[#2E3A4E] font-mono tracking-tighter uppercase">Powered by DistilBERT · Hybrid Inference · v2.4.0-Stable</p>
|
| 299 |
+
</div>
|
| 300 |
+
</footer>
|
| 301 |
+
</section>
|
| 302 |
+
|
| 303 |
+
<!-- Right Panel: Atmospheric HUD -->
|
| 304 |
+
<section class="p-12 flex flex-col items-center justify-center relative overflow-hidden bg-surface-container-lowest/50">
|
| 305 |
+
<!-- Idle Reticle -->
|
| 306 |
+
<div id="hudIdle" class="relative w-80 h-80 flex items-center justify-center">
|
| 307 |
+
<div class="absolute inset-0 rounded-full border border-[#7C6FFF]/10"></div>
|
| 308 |
+
<div class="absolute inset-4 rounded-full border border-[#7C6FFF]/5"></div>
|
| 309 |
+
<div class="absolute inset-16 rounded-full border border-[#7C6FFF]/5"></div>
|
| 310 |
+
<!-- Tactical Notches -->
|
| 311 |
+
<div class="absolute inset-0 flex justify-center py-2"><div class="w-[1px] h-2 bg-[#7C6FFF]/20"></div></div>
|
| 312 |
+
<div class="absolute inset-0 flex justify-center items-end py-2"><div class="w-[1px] h-2 bg-[#7C6FFF]/20"></div></div>
|
| 313 |
+
<div class="absolute inset-0 flex items-center px-2"><div class="w-2 h-[1px] bg-[#7C6FFF]/20"></div></div>
|
| 314 |
+
<div class="absolute inset-0 flex items-center justify-end px-2"><div class="w-2 h-[1px] bg-[#7C6FFF]/20"></div></div>
|
| 315 |
+
<!-- Center Dot -->
|
| 316 |
+
<div class="w-1 h-1 rounded-full bg-[#7C6FFF]/40 shadow-[0_0_15px_rgba(124,111,255,0.4)]"></div>
|
| 317 |
+
<!-- HUD Text -->
|
| 318 |
+
<div id="hudStatusText" class="absolute bottom-8 left-1/2 -translate-x-1/2 flex flex-col items-center gap-2">
|
| 319 |
+
<span class="text-[10px] text-[#2E3A4E] tracking-[0.15em] font-semibold uppercase">AWAITING ANALYSIS</span>
|
| 320 |
+
<div class="flex gap-1">
|
| 321 |
+
<span class="w-1 h-1 bg-[#2E3A4E] rounded-full"></span>
|
| 322 |
+
<span class="w-1 h-1 bg-[#2E3A4E]/50 rounded-full"></span>
|
| 323 |
+
<span class="w-1 h-1 bg-[#2E3A4E]/20 rounded-full"></span>
|
| 324 |
+
</div>
|
| 325 |
+
</div>
|
| 326 |
+
<!-- Ghost Data Points -->
|
| 327 |
+
<div class="absolute top-10 right-10 flex flex-col items-end gap-1 opacity-20">
|
| 328 |
+
<span class="text-[8px] font-mono text-[#7C6FFF]">X: 42.190</span>
|
| 329 |
+
<span class="text-[8px] font-mono text-[#7C6FFF]">Y: 09.441</span>
|
| 330 |
+
</div>
|
| 331 |
+
<div class="absolute bottom-10 left-10 opacity-20">
|
| 332 |
+
<span id="scannerModeLabel" class="text-[8px] font-mono text-[#7C6FFF]">SCANNER_IDLE_MODE</span>
|
| 333 |
+
</div>
|
| 334 |
+
</div>
|
| 335 |
+
<!-- Ambient Glow Behind Reticle -->
|
| 336 |
+
<div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-96 h-96 bg-primary/5 blur-[120px] rounded-full pointer-events-none"></div>
|
| 337 |
+
</section>
|
| 338 |
+
|
| 339 |
+
</div>
|
| 340 |
+
</main>
|
| 341 |
+
|
| 342 |
+
<!-- Side Decorations -->
|
| 343 |
+
<div class="fixed left-6 top-1/2 -translate-y-1/2 flex flex-col gap-8 opacity-40">
|
| 344 |
+
<div class="h-32 w-[1px] bg-gradient-to-b from-transparent via-outline-variant/30 to-transparent"></div>
|
| 345 |
+
<div class="flex flex-col gap-4">
|
| 346 |
+
<span class="text-[8px] font-mono -rotate-90 text-[#4E5A6E] uppercase">Security_Protocol_Active</span>
|
| 347 |
+
<span class="text-[8px] font-mono -rotate-90 text-[#4E5A6E] uppercase">Neural_Link_Stable</span>
|
| 348 |
+
</div>
|
| 349 |
+
</div>
|
| 350 |
+
<div class="fixed right-8 top-1/2 -translate-y-1/2 flex flex-col gap-12 text-right opacity-30">
|
| 351 |
+
<div class="space-y-1">
|
| 352 |
+
<p class="text-[9px] font-mono text-outline-variant uppercase">Latency</p>
|
| 353 |
+
<p id="metricLatency" class="text-xs font-mono text-on-surface-variant">12ms</p>
|
| 354 |
+
</div>
|
| 355 |
+
<div class="space-y-1">
|
| 356 |
+
<p class="text-[9px] font-mono text-outline-variant uppercase">Throughput</p>
|
| 357 |
+
<p class="text-xs font-mono text-on-surface-variant">4.2GB/s</p>
|
| 358 |
+
</div>
|
| 359 |
+
<div class="space-y-1">
|
| 360 |
+
<p class="text-[9px] font-mono text-outline-variant uppercase">Entropy</p>
|
| 361 |
+
<p id="metricEntropy" class="text-xs font-mono text-on-surface-variant">0.02</p>
|
| 362 |
+
</div>
|
| 363 |
+
</div>
|
| 364 |
+
</div>
|
| 365 |
+
|
| 366 |
+
<!-- ============================================================ -->
|
| 367 |
+
<!-- VIEW: RESULT STATE (High Risk / Safe - dynamically styled) -->
|
| 368 |
+
<!-- ============================================================ -->
|
| 369 |
+
<div id="viewResult" class="view-transition view-hidden">
|
| 370 |
+
<main class="max-w-[1100px] mx-auto pt-16 px-8 pb-20 relative z-10">
|
| 371 |
+
<!-- Dashboard Header -->
|
| 372 |
+
<div class="flex flex-col md:flex-row justify-between items-start md:items-end mb-12 gap-6">
|
| 373 |
+
<div>
|
| 374 |
+
<div class="flex items-center gap-3 mb-2">
|
| 375 |
+
<span id="resultIncidentBadge" class="font-mono text-[10px] tracking-widest uppercase px-2 py-0.5 border rounded">Incident #---</span>
|
| 376 |
+
<span class="w-1 h-1 bg-outline-variant rounded-full"></span>
|
| 377 |
+
<span class="font-mono text-[10px] tracking-widest text-on-surface-variant uppercase">CRITICAL PRIORITY</span>
|
| 378 |
+
</div>
|
| 379 |
+
<h1 class="text-4xl font-light tracking-tight text-white leading-tight">Analysis Result: <span id="resultHeadline" class="font-semibold"></span></h1>
|
| 380 |
+
</div>
|
| 381 |
+
<div class="flex gap-3">
|
| 382 |
+
<div class="px-3 py-1.5 bg-surface-container-low border border-outline-variant/10 rounded font-mono text-[9px] text-on-surface-variant uppercase tracking-widest">ENGINE: SENTINELAI v2</div>
|
| 383 |
+
<!-- Input Method Badge — updates dynamically -->
|
| 384 |
+
<div id="resultInputBadge" class="px-3 py-1.5 bg-surface-container-low border border-outline-variant/10 rounded font-mono text-[9px] text-on-surface-variant uppercase tracking-widest">HYBRID INFERENCE MODE</div>
|
| 385 |
+
</div>
|
| 386 |
+
</div>
|
| 387 |
+
|
| 388 |
+
<!-- Main Content -->
|
| 389 |
+
<div class="grid grid-cols-1 lg:grid-cols-12 gap-8 items-start">
|
| 390 |
+
|
| 391 |
+
<!-- Left Panel: Input Source (readonly) -->
|
| 392 |
+
<div class="lg:col-span-5 flex flex-col gap-6">
|
| 393 |
+
<div class="glass-panel p-1 rounded-xl border border-outline-variant/10 bg-surface-container-lowest overflow-hidden shadow-2xl">
|
| 394 |
+
<div class="px-4 py-3 bg-surface-container-low/50 flex items-center justify-between border-b border-outline-variant/5">
|
| 395 |
+
<span id="resultSourceLabel" class="font-mono text-[10px] uppercase tracking-tighter text-on-surface-variant">Source Content (SMS/Text)</span>
|
| 396 |
+
<span class="material-symbols-outlined text-[14px] text-on-surface-variant">content_paste</span>
|
| 397 |
+
</div>
|
| 398 |
+
<textarea id="resultMessageDisplay"
|
| 399 |
+
class="w-full bg-transparent border-none focus:ring-0 text-on-surface/90 font-mono text-sm leading-relaxed p-6 h-[400px] resize-none overflow-y-auto"
|
| 400 |
+
readonly></textarea>
|
| 401 |
+
</div>
|
| 402 |
+
<!-- Tactical Indicator Bar -->
|
| 403 |
+
<div id="resultRecommendation" class="flex flex-col gap-2 p-4 rounded-r-lg">
|
| 404 |
+
<div id="resultRecommendationLabel" class="text-[10px] font-bold tracking-widest uppercase">System Recommendation</div>
|
| 405 |
+
<div id="resultRecommendationText" class="text-xs text-on-surface-variant"></div>
|
| 406 |
+
</div>
|
| 407 |
+
</div>
|
| 408 |
+
|
| 409 |
+
<!-- Right Panel: Intelligence Output -->
|
| 410 |
+
<div class="lg:col-span-7 space-y-8">
|
| 411 |
+
<!-- Score & Core Stats -->
|
| 412 |
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
| 413 |
+
<!-- Score Circle Card -->
|
| 414 |
+
<div id="resultScoreCard" class="glass-panel p-8 rounded-2xl flex flex-col items-center justify-center relative overflow-hidden">
|
| 415 |
+
<div id="resultScoreGradient" class="absolute inset-0 pointer-events-none"></div>
|
| 416 |
+
<div class="relative w-[200px] h-[200px] flex items-center justify-center">
|
| 417 |
+
<svg class="w-full h-full transform -rotate-90">
|
| 418 |
+
<circle class="text-surface-container-highest" cx="100" cy="100" fill="transparent" r="90" stroke="currentColor" stroke-width="6"></circle>
|
| 419 |
+
<circle id="resultArc" cx="100" cy="100" fill="transparent" r="90" stroke="currentColor" stroke-linecap="round" stroke-width="10" stroke-dasharray="565.48" stroke-dashoffset="565.48"></circle>
|
| 420 |
+
</svg>
|
| 421 |
+
<div class="absolute inset-0 flex flex-col items-center justify-center">
|
| 422 |
+
<span id="resultScorePercent" class="text-5xl font-mono font-bold text-white tracking-tighter">--%</span>
|
| 423 |
+
<span id="resultScoreLabel" class="text-[10px] font-bold tracking-[0.3em] uppercase mt-1"></span>
|
| 424 |
+
</div>
|
| 425 |
+
</div>
|
| 426 |
+
</div>
|
| 427 |
+
<!-- Details Card -->
|
| 428 |
+
<div class="glass-panel p-8 rounded-2xl flex flex-col justify-between">
|
| 429 |
+
<div class="space-y-6">
|
| 430 |
+
<div class="group">
|
| 431 |
+
<div class="text-[10px] font-mono text-on-surface-variant tracking-widest uppercase mb-1">CLASSIFICATION</div>
|
| 432 |
+
<div id="resultClassification" class="text-2xl font-bold tracking-tight"></div>
|
| 433 |
+
</div>
|
| 434 |
+
<div class="group">
|
| 435 |
+
<div class="text-[10px] font-mono text-on-surface-variant tracking-widest uppercase mb-1">CONFIDENCE</div>
|
| 436 |
+
<div id="resultConfidence" class="text-2xl font-mono font-medium text-white"></div>
|
| 437 |
+
</div>
|
| 438 |
+
<div class="group">
|
| 439 |
+
<div class="text-[10px] font-mono text-on-surface-variant tracking-widest uppercase mb-1">ANALYSIS DEPTH</div>
|
| 440 |
+
<div id="resultAnalysisDepth" class="text-sm text-on-surface">Linguistic + Pattern Analysis</div>
|
| 441 |
+
</div>
|
| 442 |
+
</div>
|
| 443 |
+
</div>
|
| 444 |
+
</div>
|
| 445 |
+
|
| 446 |
+
<!-- Threat Indicators -->
|
| 447 |
+
<div class="glass-panel p-8 rounded-2xl">
|
| 448 |
+
<h3 id="resultIndicatorsTitle" class="font-mono text-[10px] uppercase tracking-widest text-on-surface-variant mb-6 flex items-center gap-2">
|
| 449 |
+
<span class="material-symbols-outlined text-xs">radar</span>
|
| 450 |
+
THREAT INDICATORS
|
| 451 |
+
</h3>
|
| 452 |
+
<div id="resultIndicatorsContainer" class="flex flex-wrap gap-4">
|
| 453 |
+
<!-- Dynamically populated -->
|
| 454 |
+
</div>
|
| 455 |
+
</div>
|
| 456 |
+
|
| 457 |
+
<!-- Action Buttons -->
|
| 458 |
+
<div class="flex flex-col sm:flex-row gap-4 pt-4">
|
| 459 |
+
<button id="downloadReportBtn"
|
| 460 |
+
class="flex-1 bg-surface-container-high hover:bg-surface-container-highest text-white font-semibold
|
| 461 |
+
py-4 px-6 rounded-xl flex items-center justify-center gap-3 border border-outline-variant/10
|
| 462 |
+
transition-all active:scale-[0.98]">
|
| 463 |
+
<span class="material-symbols-outlined">download</span>
|
| 464 |
+
DOWNLOAD INTELLIGENCE BRIEF
|
| 465 |
+
</button>
|
| 466 |
+
<button id="newAnalysisBtn"
|
| 467 |
+
class="flex-1 bg-surface-container-high hover:bg-surface-container-highest text-on-surface font-bold
|
| 468 |
+
py-4 px-6 rounded-xl flex items-center justify-center gap-3 border border-outline-variant/10
|
| 469 |
+
transition-all active:scale-[0.98]">
|
| 470 |
+
New Analysis
|
| 471 |
+
<span class="material-symbols-outlined">arrow_forward</span>
|
| 472 |
+
</button>
|
| 473 |
+
</div>
|
| 474 |
+
</div>
|
| 475 |
+
</div>
|
| 476 |
+
|
| 477 |
+
<!-- System Log Section -->
|
| 478 |
+
<div class="mt-16 pt-12 border-t border-outline-variant/10">
|
| 479 |
+
<div class="grid grid-cols-1 md:grid-cols-3 gap-12">
|
| 480 |
+
<div class="space-y-4">
|
| 481 |
+
<div class="font-mono text-[9px] uppercase tracking-widest text-on-surface-variant">Linguistic Markers</div>
|
| 482 |
+
<div id="resultLinguistic" class="text-xs text-on-surface-variant leading-relaxed"></div>
|
| 483 |
+
</div>
|
| 484 |
+
<div class="space-y-4">
|
| 485 |
+
<div class="font-mono text-[9px] uppercase tracking-widest text-on-surface-variant">Domain Intelligence</div>
|
| 486 |
+
<div id="resultDomain" class="text-xs text-on-surface-variant leading-relaxed"></div>
|
| 487 |
+
</div>
|
| 488 |
+
<div class="space-y-4">
|
| 489 |
+
<div class="font-mono text-[9px] uppercase tracking-widest text-on-surface-variant">AI Confidence Map</div>
|
| 490 |
+
<div class="flex items-center gap-2 h-4 w-full bg-surface-container rounded-full overflow-hidden">
|
| 491 |
+
<div id="resultConfBar1" class="h-full transition-all duration-700" style="width: 0%"></div>
|
| 492 |
+
<div id="resultConfBar2" class="h-full transition-all duration-700" style="width: 0%"></div>
|
| 493 |
+
</div>
|
| 494 |
+
<div class="flex justify-between font-mono text-[8px] text-on-surface-variant uppercase">
|
| 495 |
+
<span id="resultHeuristicLabel">Heuristic: --</span>
|
| 496 |
+
<span id="resultNeuralLabel">Neural: --</span>
|
| 497 |
+
</div>
|
| 498 |
+
</div>
|
| 499 |
+
</div>
|
| 500 |
+
</div>
|
| 501 |
+
</main>
|
| 502 |
+
|
| 503 |
+
<!-- Footer -->
|
| 504 |
+
<footer class="max-w-[1100px] mx-auto py-12 px-8 flex justify-between items-center opacity-40">
|
| 505 |
+
<div class="text-[12px] font-mono tracking-widest uppercase">System Status: Nominal</div>
|
| 506 |
+
<div class="text-[12px] font-mono tracking-widest uppercase">©2026 Sentinel Core Systems</div>
|
| 507 |
+
</footer>
|
| 508 |
+
</div>
|
| 509 |
+
|
| 510 |
+
<!-- ============================================================ -->
|
| 511 |
+
<!-- JAVASCRIPT: Backend Wiring & State Management -->
|
| 512 |
+
<!-- ============================================================ -->
|
| 513 |
+
<script>
|
| 514 |
+
(function() {
|
| 515 |
+
// ── State ─────────────────────────────────────────────────────────────
|
| 516 |
+
let lastAnalysisData = null;
|
| 517 |
+
let currentView = 'idle'; // 'idle' | 'result'
|
| 518 |
+
let currentMode = 'text'; // 'text' | 'image'
|
| 519 |
+
|
| 520 |
+
// ── DOM References ─────────────────────────────────────────────────────
|
| 521 |
+
const viewIdle = document.getElementById('viewIdle');
|
| 522 |
+
const viewResult = document.getElementById('viewResult');
|
| 523 |
+
const messageInput = document.getElementById('messageInput');
|
| 524 |
+
const analyzeBtn = document.getElementById('analyzeBtn');
|
| 525 |
+
const downloadReportBtn = document.getElementById('downloadReportBtn');
|
| 526 |
+
const newAnalysisBtn = document.getElementById('newAnalysisBtn');
|
| 527 |
+
const hudStatusText = document.getElementById('hudStatusText');
|
| 528 |
+
const scannerModeLabel = document.getElementById('scannerModeLabel');
|
| 529 |
+
|
| 530 |
+
// Mode toggle
|
| 531 |
+
const btnTextMode = document.getElementById('btnTextMode');
|
| 532 |
+
const btnImageMode = document.getElementById('btnImageMode');
|
| 533 |
+
const textZone = document.getElementById('textZone');
|
| 534 |
+
const imageZone = document.getElementById('imageZone');
|
| 535 |
+
const dropZone = document.getElementById('dropZone');
|
| 536 |
+
const dropPlaceholder = document.getElementById('dropPlaceholder');
|
| 537 |
+
const imgInput = document.getElementById('imgInput');
|
| 538 |
+
const imgPreview = document.getElementById('imgPreview');
|
| 539 |
+
const imgOverlay = document.getElementById('imgOverlay');
|
| 540 |
+
const imgFilename = document.getElementById('imgFilename');
|
| 541 |
+
const ocrPreviewBox = document.getElementById('ocrPreviewBox');
|
| 542 |
+
const ocrPreviewText = document.getElementById('ocrPreviewText');
|
| 543 |
+
|
| 544 |
+
// Result DOM
|
| 545 |
+
const resultIncidentBadge = document.getElementById('resultIncidentBadge');
|
| 546 |
+
const resultHeadline = document.getElementById('resultHeadline');
|
| 547 |
+
const resultInputBadge = document.getElementById('resultInputBadge');
|
| 548 |
+
const resultSourceLabel = document.getElementById('resultSourceLabel');
|
| 549 |
+
const resultMessageDisplay = document.getElementById('resultMessageDisplay');
|
| 550 |
+
const resultRecommendation = document.getElementById('resultRecommendation');
|
| 551 |
+
const resultRecommendationLabel = document.getElementById('resultRecommendationLabel');
|
| 552 |
+
const resultRecommendationText = document.getElementById('resultRecommendationText');
|
| 553 |
+
const resultScoreCard = document.getElementById('resultScoreCard');
|
| 554 |
+
const resultScoreGradient = document.getElementById('resultScoreGradient');
|
| 555 |
+
const resultArc = document.getElementById('resultArc');
|
| 556 |
+
const resultScorePercent = document.getElementById('resultScorePercent');
|
| 557 |
+
const resultScoreLabel = document.getElementById('resultScoreLabel');
|
| 558 |
+
const resultClassification = document.getElementById('resultClassification');
|
| 559 |
+
const resultConfidence = document.getElementById('resultConfidence');
|
| 560 |
+
const resultAnalysisDepth = document.getElementById('resultAnalysisDepth');
|
| 561 |
+
const resultIndicatorsTitle = document.getElementById('resultIndicatorsTitle');
|
| 562 |
+
const resultIndicatorsContainer = document.getElementById('resultIndicatorsContainer');
|
| 563 |
+
const resultLinguistic = document.getElementById('resultLinguistic');
|
| 564 |
+
const resultDomain = document.getElementById('resultDomain');
|
| 565 |
+
const resultConfBar1 = document.getElementById('resultConfBar1');
|
| 566 |
+
const resultConfBar2 = document.getElementById('resultConfBar2');
|
| 567 |
+
const resultHeuristicLabel = document.getElementById('resultHeuristicLabel');
|
| 568 |
+
const resultNeuralLabel = document.getElementById('resultNeuralLabel');
|
| 569 |
+
|
| 570 |
+
// ── Mode Switching ─────────────────────────────────────────────────────
|
| 571 |
+
window.switchMode = function(mode) {
|
| 572 |
+
currentMode = mode;
|
| 573 |
+
const isImage = mode === 'image';
|
| 574 |
+
|
| 575 |
+
textZone.style.display = isImage ? 'none' : 'block';
|
| 576 |
+
imageZone.style.display = isImage ? 'block' : 'none';
|
| 577 |
+
|
| 578 |
+
// Active pill: filled purple bg
|
| 579 |
+
const activeClasses = ['bg-[#7C6FFF]/15', 'text-[#7C6FFF]', 'border', 'border-[#7C6FFF]/20'];
|
| 580 |
+
const inactiveClasses = ['bg-transparent', 'text-[#4E5A6E]', 'border', 'border-transparent'];
|
| 581 |
+
|
| 582 |
+
if (isImage) {
|
| 583 |
+
// IMAGE active
|
| 584 |
+
btnImageMode.className = btnImageMode.className
|
| 585 |
+
.replace('bg-transparent', 'bg-[#7C6FFF]/15')
|
| 586 |
+
.replace('text-[#4E5A6E]', 'text-[#7C6FFF]')
|
| 587 |
+
.replace('border-transparent', 'border-[#7C6FFF]/20');
|
| 588 |
+
// TEXT inactive
|
| 589 |
+
btnTextMode.className = btnTextMode.className
|
| 590 |
+
.replace('bg-[#7C6FFF]/15', 'bg-transparent')
|
| 591 |
+
.replace('text-[#7C6FFF]', 'text-[#4E5A6E]')
|
| 592 |
+
.replace('border-[#7C6FFF]/20', 'border-transparent');
|
| 593 |
+
} else {
|
| 594 |
+
// TEXT active
|
| 595 |
+
btnTextMode.className = btnTextMode.className
|
| 596 |
+
.replace('bg-transparent', 'bg-[#7C6FFF]/15')
|
| 597 |
+
.replace('text-[#4E5A6E]', 'text-[#7C6FFF]')
|
| 598 |
+
.replace('border-transparent', 'border-[#7C6FFF]/20');
|
| 599 |
+
// IMAGE inactive
|
| 600 |
+
btnImageMode.className = btnImageMode.className
|
| 601 |
+
.replace('bg-[#7C6FFF]/15', 'bg-transparent')
|
| 602 |
+
.replace('text-[#7C6FFF]', 'text-[#4E5A6E]')
|
| 603 |
+
.replace('border-[#7C6FFF]/20', 'border-transparent');
|
| 604 |
+
}
|
| 605 |
+
|
| 606 |
+
// Reset OCR preview when switching back to text
|
| 607 |
+
if (!isImage) {
|
| 608 |
+
ocrPreviewBox.classList.add('hidden');
|
| 609 |
+
}
|
| 610 |
+
};
|
| 611 |
+
|
| 612 |
+
// ── Drag & Drop Handlers ───────────────────────────────────────────────
|
| 613 |
+
window.handleDragOver = function(e) {
|
| 614 |
+
e.preventDefault();
|
| 615 |
+
dropZone.classList.add('drag-over');
|
| 616 |
+
};
|
| 617 |
+
|
| 618 |
+
window.handleDragLeave = function(e) {
|
| 619 |
+
dropZone.classList.remove('drag-over');
|
| 620 |
+
};
|
| 621 |
+
|
| 622 |
+
window.handleDrop = function(e) {
|
| 623 |
+
e.preventDefault();
|
| 624 |
+
dropZone.classList.remove('drag-over');
|
| 625 |
+
const file = e.dataTransfer.files[0];
|
| 626 |
+
if (file && file.type.startsWith('image/')) {
|
| 627 |
+
// Manually set input files via DataTransfer (modern browsers)
|
| 628 |
+
try {
|
| 629 |
+
const dt = new DataTransfer();
|
| 630 |
+
dt.items.add(file);
|
| 631 |
+
imgInput.files = dt.files;
|
| 632 |
+
} catch (_) { /* Safari fallback – preview still works */ }
|
| 633 |
+
previewFile(file);
|
| 634 |
+
}
|
| 635 |
+
};
|
| 636 |
+
|
| 637 |
+
window.handleFileSelect = function(e) {
|
| 638 |
+
const file = e.target.files[0];
|
| 639 |
+
if (file) previewFile(file);
|
| 640 |
+
};
|
| 641 |
+
|
| 642 |
+
function previewFile(file) {
|
| 643 |
+
const reader = new FileReader();
|
| 644 |
+
reader.onload = function(e) {
|
| 645 |
+
imgPreview.src = e.target.result;
|
| 646 |
+
imgPreview.classList.remove('hidden');
|
| 647 |
+
dropPlaceholder.style.display = 'none';
|
| 648 |
+
imgFilename.textContent = file.name;
|
| 649 |
+
imgOverlay.classList.remove('hidden');
|
| 650 |
+
// Reset OCR preview on new image
|
| 651 |
+
ocrPreviewBox.classList.add('hidden');
|
| 652 |
+
ocrPreviewText.textContent = '';
|
| 653 |
+
};
|
| 654 |
+
reader.readAsDataURL(file);
|
| 655 |
+
}
|
| 656 |
+
|
| 657 |
+
// ── View Switching ─────────────────────────────────────────────────────
|
| 658 |
+
function showView(name) {
|
| 659 |
+
if (name === 'idle') {
|
| 660 |
+
viewResult.classList.remove('view-visible');
|
| 661 |
+
viewResult.classList.add('view-hidden');
|
| 662 |
+
setTimeout(() => {
|
| 663 |
+
viewIdle.classList.remove('view-hidden');
|
| 664 |
+
viewIdle.classList.add('view-visible');
|
| 665 |
+
}, 100);
|
| 666 |
+
} else {
|
| 667 |
+
viewIdle.classList.remove('view-visible');
|
| 668 |
+
viewIdle.classList.add('view-hidden');
|
| 669 |
+
setTimeout(() => {
|
| 670 |
+
viewResult.classList.remove('view-hidden');
|
| 671 |
+
viewResult.classList.add('view-visible');
|
| 672 |
+
window.scrollTo({ top: 0, behavior: 'smooth' });
|
| 673 |
+
}, 100);
|
| 674 |
+
}
|
| 675 |
+
currentView = name;
|
| 676 |
+
}
|
| 677 |
+
|
| 678 |
+
// ── Processing State ───────────────────────────────────────────────────
|
| 679 |
+
function setProcessingState(isProcessing, label) {
|
| 680 |
+
const btnLabel = label || (currentMode === 'image' ? 'READING SCREENSHOT...' : 'PROCESSING...');
|
| 681 |
+
if (isProcessing) {
|
| 682 |
+
analyzeBtn.disabled = true;
|
| 683 |
+
analyzeBtn.innerHTML = `
|
| 684 |
+
<span class="inline-block w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2"></span>
|
| 685 |
+
${btnLabel}
|
| 686 |
+
`;
|
| 687 |
+
analyzeBtn.classList.add('opacity-80', 'cursor-wait');
|
| 688 |
+
hudStatusText.innerHTML = `
|
| 689 |
+
<span class="text-[10px] text-[#7C6FFF] tracking-[0.15em] font-semibold uppercase">NEURAL PROCESSING</span>
|
| 690 |
+
<div class="flex gap-1.5 mt-1">
|
| 691 |
+
<span class="w-1.5 h-1.5 bg-[#7C6FFF] rounded-full animate-pulse"></span>
|
| 692 |
+
<span class="w-1.5 h-1.5 bg-[#7C6FFF]/60 rounded-full animate-pulse" style="animation-delay: 0.2s"></span>
|
| 693 |
+
<span class="w-1.5 h-1.5 bg-[#7C6FFF]/30 rounded-full animate-pulse" style="animation-delay: 0.4s"></span>
|
| 694 |
+
</div>
|
| 695 |
+
`;
|
| 696 |
+
scannerModeLabel.textContent = 'SCANNER_ACTIVE_MODE';
|
| 697 |
+
} else {
|
| 698 |
+
analyzeBtn.disabled = false;
|
| 699 |
+
analyzeBtn.innerHTML = 'RUN ANALYSIS';
|
| 700 |
+
analyzeBtn.classList.remove('opacity-80', 'cursor-wait');
|
| 701 |
+
hudStatusText.innerHTML = `
|
| 702 |
+
<span class="text-[10px] text-[#2E3A4E] tracking-[0.15em] font-semibold uppercase">AWAITING ANALYSIS</span>
|
| 703 |
+
<div class="flex gap-1">
|
| 704 |
+
<span class="w-1 h-1 bg-[#2E3A4E] rounded-full"></span>
|
| 705 |
+
<span class="w-1 h-1 bg-[#2E3A4E]/50 rounded-full"></span>
|
| 706 |
+
<span class="w-1 h-1 bg-[#2E3A4E]/20 rounded-full"></span>
|
| 707 |
+
</div>
|
| 708 |
+
`;
|
| 709 |
+
scannerModeLabel.textContent = 'SCANNER_IDLE_MODE';
|
| 710 |
+
}
|
| 711 |
+
}
|
| 712 |
+
|
| 713 |
+
// ── Generate Incident ID ───────────────────────────────────────────────
|
| 714 |
+
function generateIncidentId() {
|
| 715 |
+
const num = Math.floor(1000 + Math.random() * 9000);
|
| 716 |
+
const suffix = String.fromCharCode(65 + Math.floor(Math.random() * 26));
|
| 717 |
+
return `Incident #${num}-${suffix}`;
|
| 718 |
+
}
|
| 719 |
+
|
| 720 |
+
// ── SVG Arc Calculation ────────────────────────────────────────────────
|
| 721 |
+
function setArcPercentage(percent) {
|
| 722 |
+
const circumference = 2 * Math.PI * 90;
|
| 723 |
+
const offset = circumference - (percent / 100) * circumference;
|
| 724 |
+
resultArc.style.strokeDasharray = circumference;
|
| 725 |
+
resultArc.style.strokeDashoffset = circumference;
|
| 726 |
+
requestAnimationFrame(() => {
|
| 727 |
+
resultArc.style.transition = 'stroke-dashoffset 1.2s ease-out';
|
| 728 |
+
resultArc.style.strokeDashoffset = offset;
|
| 729 |
+
});
|
| 730 |
+
}
|
| 731 |
+
|
| 732 |
+
// ── Populate Result View ───────────────────────────────────────────────
|
| 733 |
+
function populateResult(data, originalMessage, inputMethod) {
|
| 734 |
+
const isScam = data.label === 'SCAM';
|
| 735 |
+
const scamProb = data.scam_probability;
|
| 736 |
+
const safeProb = data.safe_probability;
|
| 737 |
+
const riskLevel = data.risk_level;
|
| 738 |
+
const signals = data.signals || [];
|
| 739 |
+
const isOCR = (inputMethod === 'ocr');
|
| 740 |
+
|
| 741 |
+
const scamPercent = Math.round(scamProb * 100);
|
| 742 |
+
const safePercent = Math.round(safeProb * 100);
|
| 743 |
+
const confidence = Math.max(scamPercent, safePercent);
|
| 744 |
+
|
| 745 |
+
const accentColor = isScam ? '#ff5262' : '#00E87A';
|
| 746 |
+
const glowClass = isScam ? 'glow-red' : 'safe-glow';
|
| 747 |
+
|
| 748 |
+
// ─ Header ─
|
| 749 |
+
resultIncidentBadge.textContent = generateIncidentId();
|
| 750 |
+
resultIncidentBadge.style.color = accentColor;
|
| 751 |
+
resultIncidentBadge.style.borderColor = accentColor + '4D';
|
| 752 |
+
|
| 753 |
+
resultHeadline.textContent = isScam ? 'Threat Detected' : 'No Threat Detected';
|
| 754 |
+
resultHeadline.style.color = accentColor;
|
| 755 |
+
|
| 756 |
+
// ─ Input method badge ─
|
| 757 |
+
if (isOCR) {
|
| 758 |
+
resultInputBadge.textContent = 'OCR · SCREENSHOT INPUT';
|
| 759 |
+
resultInputBadge.style.color = '#7C6FFF';
|
| 760 |
+
resultInputBadge.style.borderColor = 'rgba(124,111,255,0.3)';
|
| 761 |
+
resultSourceLabel.textContent = 'Source Content (Screenshot — OCR Extracted)';
|
| 762 |
+
} else {
|
| 763 |
+
resultInputBadge.textContent = 'HYBRID INFERENCE MODE';
|
| 764 |
+
resultInputBadge.style.color = '';
|
| 765 |
+
resultInputBadge.style.borderColor = '';
|
| 766 |
+
resultSourceLabel.textContent = 'Source Content (SMS/Text)';
|
| 767 |
+
}
|
| 768 |
+
|
| 769 |
+
// ─ Message display ─
|
| 770 |
+
resultMessageDisplay.value = originalMessage;
|
| 771 |
+
|
| 772 |
+
// ─ Analysis Depth label ─
|
| 773 |
+
resultAnalysisDepth.textContent = isOCR
|
| 774 |
+
? 'OCR Extraction + Linguistic + Pattern'
|
| 775 |
+
: 'Linguistic + Pattern Analysis';
|
| 776 |
+
|
| 777 |
+
// ─ Recommendation ─
|
| 778 |
+
resultRecommendation.className = `flex flex-col gap-2 p-4 rounded-r-lg border-l-2`;
|
| 779 |
+
resultRecommendation.style.borderColor = accentColor;
|
| 780 |
+
resultRecommendation.style.background = isScam ? 'rgba(255,82,98,0.05)' : 'rgba(0,232,122,0.05)';
|
| 781 |
+
resultRecommendationLabel.style.color = accentColor;
|
| 782 |
+
|
| 783 |
+
if (isScam) {
|
| 784 |
+
resultRecommendationText.textContent = riskLevel === 'HIGH'
|
| 785 |
+
? 'Isolate communication channel immediately. High probability of credential harvesting or social engineering attack. Do NOT share any personal information.'
|
| 786 |
+
: 'Exercise caution. Several suspicious patterns detected. Verify through official channels before responding.';
|
| 787 |
+
} else {
|
| 788 |
+
resultRecommendationText.textContent = 'Content verified. No immediate risks identified. Message classification suggests legitimate communication.';
|
| 789 |
+
}
|
| 790 |
+
|
| 791 |
+
// ─ Score Circle ─
|
| 792 |
+
resultScoreCard.className = `glass-panel ${glowClass} p-8 rounded-2xl flex flex-col items-center justify-center relative overflow-hidden`;
|
| 793 |
+
resultScoreGradient.className = `absolute inset-0 bg-gradient-to-br ${isScam ? 'from-tertiary-container/5' : 'from-secondary-container/5'} to-transparent pointer-events-none`;
|
| 794 |
+
|
| 795 |
+
resultArc.classList.remove('text-tertiary-container', 'text-secondary');
|
| 796 |
+
resultArc.classList.add(isScam ? 'text-tertiary-container' : 'text-secondary');
|
| 797 |
+
|
| 798 |
+
resultScorePercent.textContent = scamPercent + '%';
|
| 799 |
+
resultScoreLabel.textContent = isScam ? (riskLevel === 'HIGH' ? 'HIGH RISK' : 'MEDIUM RISK') : 'SAFE';
|
| 800 |
+
resultScoreLabel.style.color = accentColor;
|
| 801 |
+
|
| 802 |
+
setArcPercentage(scamPercent);
|
| 803 |
+
|
| 804 |
+
// ─ Details ─
|
| 805 |
+
resultClassification.textContent = isScam ? 'SCAM DETECTED' : 'LEGITIMATE';
|
| 806 |
+
resultClassification.style.color = accentColor;
|
| 807 |
+
resultConfidence.textContent = confidence.toFixed(1) + '%';
|
| 808 |
+
|
| 809 |
+
// ─ Threat Indicators ─
|
| 810 |
+
resultIndicatorsContainer.innerHTML = '';
|
| 811 |
+
if (isScam && signals.length > 0) {
|
| 812 |
+
resultIndicatorsTitle.innerHTML = `<span class="material-symbols-outlined text-xs">radar</span> THREAT INDICATORS`;
|
| 813 |
+
const iconMap = {
|
| 814 |
+
'arrest or legal authority threat': 'security',
|
| 815 |
+
'urgency / time pressure': 'priority_high',
|
| 816 |
+
'payment demand with urgency': 'payments',
|
| 817 |
+
'phishing link / click-bait': 'link',
|
| 818 |
+
'account suspension threat': 'lock',
|
| 819 |
+
'prize / lottery scam': 'emoji_events',
|
| 820 |
+
'credential / remote access request': 'key',
|
| 821 |
+
'digital arrest pattern': 'gavel',
|
| 822 |
+
'isolation / secrecy demand': 'visibility_off',
|
| 823 |
+
'document / id fraud': 'badge',
|
| 824 |
+
};
|
| 825 |
+
signals.forEach(sig => {
|
| 826 |
+
const sigLower = sig.toLowerCase();
|
| 827 |
+
let icon = 'warning';
|
| 828 |
+
for (const [key, val] of Object.entries(iconMap)) {
|
| 829 |
+
if (sigLower.includes(key) || key.includes(sigLower.replace(/^\w/, ''))) {
|
| 830 |
+
icon = val; break;
|
| 831 |
+
}
|
| 832 |
+
}
|
| 833 |
+
const chip = document.createElement('div');
|
| 834 |
+
chip.className = 'px-5 py-3 rounded-lg border border-tertiary-container/20 bg-tertiary-container/[0.08] flex items-center gap-3 group hover:bg-tertiary-container/[0.12] transition-colors';
|
| 835 |
+
chip.innerHTML = `
|
| 836 |
+
<span class="material-symbols-outlined text-tertiary-container text-sm">${icon}</span>
|
| 837 |
+
<span class="font-mono text-xs font-bold text-tertiary-container/85 tracking-tighter uppercase">${sig}</span>
|
| 838 |
+
`;
|
| 839 |
+
resultIndicatorsContainer.appendChild(chip);
|
| 840 |
+
});
|
| 841 |
+
} else {
|
| 842 |
+
resultIndicatorsTitle.innerHTML = `<span class="material-symbols-outlined text-xs">check_circle</span> THREAT STATUS`;
|
| 843 |
+
const chip = document.createElement('div');
|
| 844 |
+
chip.className = 'px-5 py-3 rounded-lg border border-secondary/20 bg-secondary/[0.08] flex items-center gap-3 group hover:bg-secondary/[0.12] transition-colors';
|
| 845 |
+
chip.innerHTML = `
|
| 846 |
+
<span class="material-symbols-outlined text-secondary text-sm">verified</span>
|
| 847 |
+
<span class="font-mono text-xs font-bold text-secondary/85 tracking-tighter uppercase">NO THREATS DETECTED</span>
|
| 848 |
+
`;
|
| 849 |
+
resultIndicatorsContainer.appendChild(chip);
|
| 850 |
+
}
|
| 851 |
+
|
| 852 |
+
// ─ New Analysis button styling ─
|
| 853 |
+
newAnalysisBtn.className = isScam
|
| 854 |
+
? 'flex-1 bg-gradient-to-r from-tertiary-container to-[#FF4058] hover:opacity-90 text-on-tertiary font-bold py-4 px-6 rounded-xl flex items-center justify-center gap-3 shadow-lg shadow-tertiary-container/20 transition-all active:scale-[0.98]'
|
| 855 |
+
: 'flex-1 bg-surface-container-high hover:bg-surface-container-highest text-on-surface font-bold py-4 px-6 rounded-xl flex items-center justify-center gap-3 border border-outline-variant/10 transition-all active:scale-[0.98]';
|
| 856 |
+
|
| 857 |
+
// ─ System Log ─
|
| 858 |
+
if (isScam) {
|
| 859 |
+
resultLinguistic.innerHTML = `System detected artificial pressure nodes typical of social engineering campaigns. ${signals.length} risk indicator(s) flagged during analysis.${isOCR ? ' <span class="text-[#7C6FFF]/60">[Source: OCR]</span>' : ''}`;
|
| 860 |
+
resultDomain.innerHTML = `Content analysis flagged suspicious patterns. <span class="text-tertiary-container underline underline-offset-4 decoration-tertiary-container/30">High-risk indicators present</span>. Exercise extreme caution.`;
|
| 861 |
+
resultConfBar1.style.width = scamPercent + '%';
|
| 862 |
+
resultConfBar1.className = 'h-full bg-tertiary-container transition-all duration-700';
|
| 863 |
+
resultConfBar2.style.width = safePercent + '%';
|
| 864 |
+
resultConfBar2.className = 'h-full bg-surface-variant transition-all duration-700';
|
| 865 |
+
resultHeuristicLabel.textContent = riskLevel === 'HIGH' ? 'Heuristic: High' : 'Heuristic: Medium';
|
| 866 |
+
resultNeuralLabel.textContent = `Neural: ${scamPercent}.${Math.floor(Math.random()*9)}`;
|
| 867 |
+
} else {
|
| 868 |
+
resultLinguistic.innerHTML = `System identified <span class="text-white">Natural language patterns</span>. Absence of artificial pressure nodes or common phishing keywords. Sentiment is professional.${isOCR ? ' <span class="text-[#7C6FFF]/60">[Source: OCR]</span>' : ''}`;
|
| 869 |
+
resultDomain.innerHTML = `Content verified. <span class="text-secondary underline underline-offset-4 decoration-secondary/30">No suspicious patterns</span>. Metadata consistent with legitimate communication.`;
|
| 870 |
+
resultConfBar1.style.width = scamPercent + '%';
|
| 871 |
+
resultConfBar1.className = 'h-full bg-secondary transition-all duration-700';
|
| 872 |
+
resultConfBar2.style.width = safePercent + '%';
|
| 873 |
+
resultConfBar2.className = 'h-full bg-surface-variant transition-all duration-700';
|
| 874 |
+
resultHeuristicLabel.textContent = 'Low heuristic score';
|
| 875 |
+
resultNeuralLabel.textContent = `Neural: ${safePercent}.${Math.floor(Math.random()*9)}% Legit`;
|
| 876 |
+
}
|
| 877 |
+
}
|
| 878 |
+
|
| 879 |
+
// ── TEXT ANALYSIS ──────────────────────────────────────────────────────
|
| 880 |
+
async function runTextAnalysis() {
|
| 881 |
+
const text = messageInput.value.trim();
|
| 882 |
+
if (!text) {
|
| 883 |
+
messageInput.classList.add('border-tertiary-container/60');
|
| 884 |
+
messageInput.setAttribute('placeholder', '⚠ Please paste a message for analysis...');
|
| 885 |
+
setTimeout(() => {
|
| 886 |
+
messageInput.classList.remove('border-tertiary-container/60');
|
| 887 |
+
messageInput.setAttribute('placeholder', 'Paste any suspicious message, URL, or code snippet for immediate neural evaluation...');
|
| 888 |
+
}, 2000);
|
| 889 |
+
return;
|
| 890 |
+
}
|
| 891 |
+
|
| 892 |
+
setProcessingState(true);
|
| 893 |
+
try {
|
| 894 |
+
const response = await fetch('/analyze', {
|
| 895 |
+
method: 'POST',
|
| 896 |
+
headers: { 'Content-Type': 'application/json' },
|
| 897 |
+
body: JSON.stringify({ message: text })
|
| 898 |
+
});
|
| 899 |
+
if (!response.ok) throw new Error('Server error: ' + response.status);
|
| 900 |
+
|
| 901 |
+
const data = await response.json();
|
| 902 |
+
lastAnalysisData = {
|
| 903 |
+
message: text,
|
| 904 |
+
risk_score: (data.scam_probability * 100).toFixed(1),
|
| 905 |
+
risk_level: data.risk_level || (data.label === 'SCAM' ? 'HIGH' : 'LOW'),
|
| 906 |
+
signals: data.signals || []
|
| 907 |
+
};
|
| 908 |
+
|
| 909 |
+
populateResult(data, text, 'text');
|
| 910 |
+
setProcessingState(false);
|
| 911 |
+
showView('result');
|
| 912 |
+
|
| 913 |
+
} catch (error) {
|
| 914 |
+
console.error('Analysis error:', error);
|
| 915 |
+
setProcessingState(false);
|
| 916 |
+
hudStatusText.innerHTML = `
|
| 917 |
+
<span class="text-[10px] text-tertiary-container tracking-[0.15em] font-semibold uppercase">ANALYSIS FAILED</span>
|
| 918 |
+
<div class="text-[8px] text-on-surface-variant mt-1">Check connection and retry</div>
|
| 919 |
+
`;
|
| 920 |
+
}
|
| 921 |
+
}
|
| 922 |
+
|
| 923 |
+
// ── OCR ANALYSIS ───────────────────────────────────────────────────────
|
| 924 |
+
async function runOCRAnalysis() {
|
| 925 |
+
const file = imgInput.files[0];
|
| 926 |
+
if (!file) {
|
| 927 |
+
// Flash the drop zone border
|
| 928 |
+
dropZone.style.borderColor = 'rgba(255,82,98,0.5)';
|
| 929 |
+
setTimeout(() => { dropZone.style.borderColor = ''; }, 1800);
|
| 930 |
+
return;
|
| 931 |
+
}
|
| 932 |
+
|
| 933 |
+
setProcessingState(true, 'READING SCREENSHOT...');
|
| 934 |
+
|
| 935 |
+
try {
|
| 936 |
+
const formData = new FormData();
|
| 937 |
+
formData.append('image', file);
|
| 938 |
+
|
| 939 |
+
const response = await fetch('/ocr_analyze', {
|
| 940 |
+
method: 'POST',
|
| 941 |
+
body: formData
|
| 942 |
+
});
|
| 943 |
+
|
| 944 |
+
const data = await response.json();
|
| 945 |
+
|
| 946 |
+
if (data.error) {
|
| 947 |
+
setProcessingState(false);
|
| 948 |
+
// Show error in the OCR preview area without disrupting layout
|
| 949 |
+
ocrPreviewText.textContent = '⚠ ' + data.error;
|
| 950 |
+
ocrPreviewBox.classList.remove('hidden');
|
| 951 |
+
return;
|
| 952 |
+
}
|
| 953 |
+
|
| 954 |
+
const extractedText = data.extracted_text;
|
| 955 |
+
|
| 956 |
+
// Show extracted text preview for user verification
|
| 957 |
+
ocrPreviewText.textContent = extractedText;
|
| 958 |
+
ocrPreviewBox.classList.remove('hidden');
|
| 959 |
+
|
| 960 |
+
// Also populate the text textarea (for reference if user switches mode)
|
| 961 |
+
messageInput.value = extractedText;
|
| 962 |
+
|
| 963 |
+
lastAnalysisData = {
|
| 964 |
+
message: extractedText,
|
| 965 |
+
risk_score: (data.scam_probability * 100).toFixed(1),
|
| 966 |
+
risk_level: data.risk_level || (data.label === 'SCAM' ? 'HIGH' : 'LOW'),
|
| 967 |
+
signals: data.signals || []
|
| 968 |
+
};
|
| 969 |
+
|
| 970 |
+
populateResult(data, extractedText, 'ocr');
|
| 971 |
+
setProcessingState(false);
|
| 972 |
+
showView('result');
|
| 973 |
+
|
| 974 |
+
} catch (err) {
|
| 975 |
+
console.error('OCR error:', err);
|
| 976 |
+
setProcessingState(false);
|
| 977 |
+
ocrPreviewText.textContent = '⚠ Network error. Make sure the server is running.';
|
| 978 |
+
ocrPreviewBox.classList.remove('hidden');
|
| 979 |
+
}
|
| 980 |
+
}
|
| 981 |
+
|
| 982 |
+
// ── ANALYZE BUTTON — branches on mode ─────────────────────────────────
|
| 983 |
+
analyzeBtn.addEventListener('click', function(e) {
|
| 984 |
+
e.preventDefault();
|
| 985 |
+
if (currentMode === 'image') {
|
| 986 |
+
runOCRAnalysis();
|
| 987 |
+
} else {
|
| 988 |
+
runTextAnalysis();
|
| 989 |
+
}
|
| 990 |
+
});
|
| 991 |
+
|
| 992 |
+
// ── DOWNLOAD REPORT ────────────────────────────────────────────────────
|
| 993 |
+
downloadReportBtn.addEventListener('click', async function() {
|
| 994 |
+
if (!lastAnalysisData) return;
|
| 995 |
+
|
| 996 |
+
const originalText = this.innerHTML;
|
| 997 |
+
this.innerHTML = `
|
| 998 |
+
<span class="inline-block w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin"></span>
|
| 999 |
+
GENERATING...
|
| 1000 |
+
`;
|
| 1001 |
+
this.disabled = true;
|
| 1002 |
+
|
| 1003 |
+
try {
|
| 1004 |
+
const response = await fetch('/download_report', {
|
| 1005 |
+
method: 'POST',
|
| 1006 |
+
headers: { 'Content-Type': 'application/json' },
|
| 1007 |
+
body: JSON.stringify(lastAnalysisData)
|
| 1008 |
+
});
|
| 1009 |
+
if (!response.ok) throw new Error('Server error: ' + response.status);
|
| 1010 |
+
|
| 1011 |
+
const blob = await response.blob();
|
| 1012 |
+
const url = window.URL.createObjectURL(blob);
|
| 1013 |
+
const a = document.createElement('a');
|
| 1014 |
+
a.href = url;
|
| 1015 |
+
a.download = 'SentinelAI_Report.pdf';
|
| 1016 |
+
document.body.appendChild(a);
|
| 1017 |
+
a.click();
|
| 1018 |
+
document.body.removeChild(a);
|
| 1019 |
+
window.URL.revokeObjectURL(url);
|
| 1020 |
+
|
| 1021 |
+
this.innerHTML = `<span class="material-symbols-outlined">check_circle</span> DOWNLOADED`;
|
| 1022 |
+
setTimeout(() => { this.innerHTML = originalText; }, 2500);
|
| 1023 |
+
|
| 1024 |
+
} catch (err) {
|
| 1025 |
+
console.error('Download error:', err);
|
| 1026 |
+
this.innerHTML = `<span class="material-symbols-outlined">error</span> FAILED — RETRY`;
|
| 1027 |
+
setTimeout(() => { this.innerHTML = originalText; }, 3000);
|
| 1028 |
+
} finally {
|
| 1029 |
+
this.disabled = false;
|
| 1030 |
+
}
|
| 1031 |
+
});
|
| 1032 |
+
|
| 1033 |
+
// ── NEW ANALYSIS ───────────────────────────────────────────────────────
|
| 1034 |
+
newAnalysisBtn.addEventListener('click', function() {
|
| 1035 |
+
messageInput.value = '';
|
| 1036 |
+
lastAnalysisData = null;
|
| 1037 |
+
|
| 1038 |
+
// Reset image zone
|
| 1039 |
+
imgInput.value = '';
|
| 1040 |
+
imgPreview.src = '';
|
| 1041 |
+
imgPreview.classList.add('hidden');
|
| 1042 |
+
imgOverlay.classList.add('hidden');
|
| 1043 |
+
dropPlaceholder.style.display = '';
|
| 1044 |
+
ocrPreviewBox.classList.add('hidden');
|
| 1045 |
+
ocrPreviewText.textContent = '';
|
| 1046 |
+
|
| 1047 |
+
showView('idle');
|
| 1048 |
+
});
|
| 1049 |
+
|
| 1050 |
+
// ── Keyboard shortcut: Ctrl+Enter ─────────────────────────────────────
|
| 1051 |
+
messageInput.addEventListener('keydown', function(e) {
|
| 1052 |
+
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
| 1053 |
+
analyzeBtn.click();
|
| 1054 |
+
}
|
| 1055 |
+
});
|
| 1056 |
+
|
| 1057 |
+
})();
|
| 1058 |
+
</script>
|
| 1059 |
+
</body>
|
| 1060 |
+
</html>
|
models/train_model_v2.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import pandas as pd
|
| 3 |
+
import torch
|
| 4 |
+
from sklearn.model_selection import train_test_split
|
| 5 |
+
from transformers import DistilBertTokenizerFast, DistilBertForSequenceClassification, Trainer, TrainingArguments
|
| 6 |
+
from datasets import Dataset
|
| 7 |
+
import numpy as np
|
| 8 |
+
from sklearn.metrics import accuracy_score, precision_recall_fscore_support, confusion_matrix
|
| 9 |
+
from sklearn.utils.class_weight import compute_class_weight
|
| 10 |
+
|
| 11 |
+
# Load dataset
|
| 12 |
+
df = pd.read_csv("D:\Sentinel\data\sentinel_dataset_expanded.csv")
|
| 13 |
+
|
| 14 |
+
# Basic cleaning
|
| 15 |
+
df = df.dropna()
|
| 16 |
+
df["text"] = df["text"].astype(str)
|
| 17 |
+
|
| 18 |
+
print(f"Dataset size: {len(df)}")
|
| 19 |
+
print(f"Label distribution: {df['label'].value_counts().to_dict()}")
|
| 20 |
+
print(f"\nAverage text length: {df['text'].str.len().mean():.1f} characters")
|
| 21 |
+
|
| 22 |
+
# Train-test split
|
| 23 |
+
train_texts, val_texts, train_labels, val_labels = train_test_split(
|
| 24 |
+
df["text"].tolist(),
|
| 25 |
+
df["label"].tolist(),
|
| 26 |
+
test_size=0.2,
|
| 27 |
+
random_state=42,
|
| 28 |
+
stratify=df["label"].tolist() # Ensure balanced split
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
# Compute class weights for balanced training
|
| 32 |
+
class_weights = compute_class_weight(
|
| 33 |
+
class_weight='balanced',
|
| 34 |
+
classes=np.unique(train_labels),
|
| 35 |
+
y=train_labels
|
| 36 |
+
)
|
| 37 |
+
class_weights = torch.tensor(class_weights, dtype=torch.float)
|
| 38 |
+
print(f"\nClass weights: SAFE={class_weights[0]:.4f}, SCAM={class_weights[1]:.4f}")
|
| 39 |
+
print(f"Training set - SAFE: {train_labels.count(0)}, SCAM: {train_labels.count(1)}")
|
| 40 |
+
|
| 41 |
+
# Tokenizer
|
| 42 |
+
tokenizer = DistilBertTokenizerFast.from_pretrained("distilbert-base-uncased")
|
| 43 |
+
|
| 44 |
+
train_encodings = tokenizer(train_texts, truncation=True, padding=True, max_length=128)
|
| 45 |
+
val_encodings = tokenizer(val_texts, truncation=True, padding=True, max_length=128)
|
| 46 |
+
|
| 47 |
+
train_dataset = Dataset.from_dict({
|
| 48 |
+
"input_ids": train_encodings["input_ids"],
|
| 49 |
+
"attention_mask": train_encodings["attention_mask"],
|
| 50 |
+
"labels": train_labels
|
| 51 |
+
})
|
| 52 |
+
|
| 53 |
+
val_dataset = Dataset.from_dict({
|
| 54 |
+
"input_ids": val_encodings["input_ids"],
|
| 55 |
+
"attention_mask": val_encodings["attention_mask"],
|
| 56 |
+
"labels": val_labels
|
| 57 |
+
})
|
| 58 |
+
|
| 59 |
+
# Model with class weights
|
| 60 |
+
model = DistilBertForSequenceClassification.from_pretrained(
|
| 61 |
+
"distilbert-base-uncased",
|
| 62 |
+
num_labels=2
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
# Custom trainer class to use class weights
|
| 66 |
+
class WeightedTrainer(Trainer):
|
| 67 |
+
def compute_loss(self, model, inputs, return_outputs=False, **kwargs):
|
| 68 |
+
labels = inputs.pop("labels")
|
| 69 |
+
outputs = model(**inputs)
|
| 70 |
+
logits = outputs.logits
|
| 71 |
+
loss_fct = torch.nn.CrossEntropyLoss(weight=class_weights.to(logits.device))
|
| 72 |
+
loss = loss_fct(logits.view(-1, self.model.config.num_labels), labels.view(-1))
|
| 73 |
+
return (loss, outputs) if return_outputs else loss
|
| 74 |
+
|
| 75 |
+
# Metrics
|
| 76 |
+
def compute_metrics(pred):
|
| 77 |
+
labels = pred.label_ids
|
| 78 |
+
preds = np.argmax(pred.predictions, axis=1)
|
| 79 |
+
precision, recall, f1, _ = precision_recall_fscore_support(labels, preds, average='binary')
|
| 80 |
+
acc = accuracy_score(labels, preds)
|
| 81 |
+
|
| 82 |
+
# Confusion matrix
|
| 83 |
+
cm = confusion_matrix(labels, preds)
|
| 84 |
+
print(f"\nConfusion Matrix:")
|
| 85 |
+
print(f" Predicted SAFE Predicted SCAM")
|
| 86 |
+
print(f"Actual SAFE: {cm[0][0]:14d} {cm[0][1]:14d}")
|
| 87 |
+
print(f"Actual SCAM: {cm[1][0]:14d} {cm[1][1]:14d}")
|
| 88 |
+
|
| 89 |
+
return {
|
| 90 |
+
"accuracy": acc,
|
| 91 |
+
"f1": f1,
|
| 92 |
+
"precision": precision,
|
| 93 |
+
"recall": recall
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
# Training args - MORE aggressive parameters
|
| 97 |
+
training_args = TrainingArguments(
|
| 98 |
+
output_dir="./results",
|
| 99 |
+
num_train_epochs=10, # Increased to 10 epochs
|
| 100 |
+
per_device_train_batch_size=16,
|
| 101 |
+
per_device_eval_batch_size=16,
|
| 102 |
+
warmup_steps=50, # Reduced warmup
|
| 103 |
+
weight_decay=0.01,
|
| 104 |
+
learning_rate=3e-5, # Slightly higher learning rate
|
| 105 |
+
logging_dir="./logs",
|
| 106 |
+
logging_steps=5,
|
| 107 |
+
eval_strategy="epoch",
|
| 108 |
+
save_strategy="epoch",
|
| 109 |
+
load_best_model_at_end=True,
|
| 110 |
+
metric_for_best_model="f1",
|
| 111 |
+
save_total_limit=2
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
trainer = WeightedTrainer(
|
| 115 |
+
model=model,
|
| 116 |
+
args=training_args,
|
| 117 |
+
train_dataset=train_dataset,
|
| 118 |
+
eval_dataset=val_dataset,
|
| 119 |
+
compute_metrics=compute_metrics
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
print("\n" + "="*60)
|
| 123 |
+
print("Starting training with 10 epochs...")
|
| 124 |
+
print("="*60)
|
| 125 |
+
trainer.train()
|
| 126 |
+
|
| 127 |
+
print("\n" + "="*60)
|
| 128 |
+
print("Final Evaluation...")
|
| 129 |
+
print("="*60)
|
| 130 |
+
eval_results = trainer.evaluate()
|
| 131 |
+
print(f"\nFinal Evaluation Results:")
|
| 132 |
+
print(f" Accuracy: {eval_results['eval_accuracy']:.4f}")
|
| 133 |
+
print(f" F1 Score: {eval_results['eval_f1']:.4f}")
|
| 134 |
+
print(f" Precision: {eval_results['eval_precision']:.4f}")
|
| 135 |
+
print(f" Recall: {eval_results['eval_recall']:.4f}")
|
| 136 |
+
|
| 137 |
+
script_dir = os.path.dirname(os.path.abspath(__file__))
|
| 138 |
+
save_path = os.path.join(script_dir, "sentinel_model")
|
| 139 |
+
model.save_pretrained(save_path)
|
| 140 |
+
tokenizer.save_pretrained(save_path)
|
| 141 |
+
|
| 142 |
+
print(f"\n{'='*60}")
|
| 143 |
+
print(f"Model training complete and saved to: {save_path}")
|
| 144 |
+
print("="*60)
|
requirements.txt
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ── Production dependencies (app runtime only) ────────────────────────────────
|
| 2 |
+
Flask==3.0.3
|
| 3 |
+
torch==2.2.2
|
| 4 |
+
transformers==4.40.2
|
| 5 |
+
numpy==1.26.4
|
| 6 |
+
reportlab==4.1.0
|
| 7 |
+
pytesseract==0.3.13
|
| 8 |
+
Pillow==10.3.0
|
| 9 |
+
|
| 10 |
+
# ── Dev / Training only (not needed by the running app) ───────────────────────
|
| 11 |
+
# pandas==2.2.2
|
| 12 |
+
# scikit-learn==1.4.2
|
| 13 |
+
# datasets==2.19.1
|