dharmit-63 commited on
Commit
50776af
·
0 Parent(s):

Deploy: SentinelAI clean build — post-cleanup

Browse files
Files changed (10) hide show
  1. .gitignore +68 -0
  2. Dockerfile +31 -0
  3. README.md +383 -0
  4. app/app.py +90 -0
  5. app/inference.py +105 -0
  6. app/ocr.py +76 -0
  7. app/pdf_generator.py +465 -0
  8. app/templates/index.html +1060 -0
  9. models/train_model_v2.py +144 -0
  10. 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
+ [![Python](https://img.shields.io/badge/Python-3.10+-3776AB?style=flat-square&logo=python&logoColor=white)](https://python.org)
19
+ [![Flask](https://img.shields.io/badge/Flask-3.0-000000?style=flat-square&logo=flask&logoColor=white)](https://flask.palletsprojects.com)
20
+ [![Transformers](https://img.shields.io/badge/HuggingFace-DistilBERT-FFD21E?style=flat-square&logo=huggingface&logoColor=black)](https://huggingface.co)
21
+ [![PyTorch](https://img.shields.io/badge/PyTorch-2.2-EE4C2C?style=flat-square&logo=pytorch&logoColor=white)](https://pytorch.org)
22
+ [![Accuracy](https://img.shields.io/badge/Accuracy-97.42%25-22C55E?style=flat-square)](/)
23
+ [![F1 Score](https://img.shields.io/badge/F1_Score-97.59%25-22C55E?style=flat-square)](/)
24
+ [![License: MIT](https://img.shields.io/badge/License-MIT-22D3EE?style=flat-square)](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